optimization
Some checks failed
Deploy MyApp on Same Server / build-and-deploy (push) Failing after 1s

This commit is contained in:
2025-12-19 23:17:30 +03:30
parent 31b58b7151
commit 618960bb5c
101 changed files with 4812 additions and 2319 deletions

View File

@@ -31,6 +31,8 @@ import {
LucideIcon
} from 'lucide-react'
import Loading from '@/components/Loading'
import { PageHeader, EmptyState, Modal, IconButton, Badge } from '@/components/common'
import { confirmDialog } from '@/components/utils'
type SensorType = 0 | 1 | 2 | 3 | 4
type ComparisonType = 0 | 1 | 2 | 3
@@ -172,9 +174,14 @@ function AlertSettingsContent() {
}
const handleDelete = async (id: number) => {
if (!window.confirm('آیا از حذف این هشدار اطمینان دارید؟')) {
return
}
const confirmed = await confirmDialog({
message: 'آیا از حذف این هشدار اطمینان دارید؟',
variant: 'danger',
confirmText: 'حذف',
cancelText: 'انصراف'
})
if (!confirmed) return
try {
await api.deleteAlertCondition(id)
@@ -338,21 +345,12 @@ function AlertSettingsContent() {
<div className="min-h-screen p-4 md:p-6 bg-gray-50">
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-orange-500 to-red-600 rounded-xl shadow-md">
<Bell className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
تنظیمات هشدارها
</h1>
<p className="text-sm text-gray-500 mt-1">
مدیریت شرایط و هشدارهای دستگاه
</p>
</div>
</div>
<PageHeader
icon={Bell}
title="تنظیمات هشدارها"
subtitle="مدیریت شرایط و هشدارهای دستگاه"
iconGradient="from-orange-500 to-red-600"
action={
<button
onClick={openCreateModal}
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 text-white rounded-xl transition-all duration-200 font-medium shadow-md hover:shadow-lg"
@@ -360,8 +358,8 @@ function AlertSettingsContent() {
<Plus className="w-5 h-5" />
افزودن هشدار
</button>
</div>
</div>
}
/>
{/* Alerts List */}
<div className="bg-white rounded-2xl shadow-md border border-gray-200 overflow-hidden">
@@ -372,17 +370,19 @@ function AlertSettingsContent() {
</div>
{alerts.length === 0 ? (
<div className="p-12 text-center">
<AlertTriangle className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<p className="text-gray-500 mb-4">هیچ هشداری ثبت نشده است</p>
<button
onClick={openCreateModal}
className="inline-flex items-center gap-2 px-4 py-2 bg-orange-500 hover:bg-orange-600 text-white rounded-lg transition-colors"
>
<Plus className="w-4 h-4" />
افزودن اولین هشدار
</button>
</div>
<EmptyState
icon={AlertTriangle}
message="هیچ هشداری ثبت نشده است"
action={
<button
onClick={openCreateModal}
className="inline-flex items-center gap-2 px-4 py-2 bg-orange-500 hover:bg-orange-600 text-white rounded-lg transition-colors"
>
<Plus className="w-4 h-4" />
افزودن اولین هشدار
</button>
}
/>
) : (
<div className="divide-y divide-gray-200">
{alerts.map((alert) => (
@@ -391,28 +391,18 @@ function AlertSettingsContent() {
<div className="flex-1 space-y-3">
{/* Header */}
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 px-3 py-1 bg-orange-100 text-orange-800 rounded-full text-sm font-medium">
{NOTIFICATION_TYPES.find(n => n.value === alert.notificationType)?.icon && (
<span className="w-4 h-4">
{(() => {
const Icon = NOTIFICATION_TYPES.find(n => n.value === alert.notificationType)!.icon
return <Icon className="w-4 h-4" />
})()}
</span>
)}
<Badge
variant="warning"
icon={NOTIFICATION_TYPES.find(n => n.value === alert.notificationType)?.icon}
>
{getNotificationLabel(alert.notificationType)}
</div>
<div className="flex items-center gap-2 px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium">
{TIME_TYPES.find(t => t.value === alert.timeType)?.icon && (
<span className="w-4 h-4">
{(() => {
const Icon = TIME_TYPES.find(t => t.value === alert.timeType)!.icon
return <Icon className="w-4 h-4" />
})()}
</span>
)}
</Badge>
<Badge
variant="info"
icon={TIME_TYPES.find(t => t.value === alert.timeType)?.icon}
>
{getTimeTypeLabel(alert.timeType)}
</div>
</Badge>
<button
onClick={() => toggleActive(alert)}
className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm font-medium transition-colors ${
@@ -455,20 +445,18 @@ function AlertSettingsContent() {
{/* Actions */}
<div className="flex items-center gap-2">
<button
<IconButton
icon={Pencil}
variant="primary"
onClick={() => openEditModal(alert)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="ویرایش"
>
<Pencil className="w-4 h-4" />
</button>
<button
/>
<IconButton
icon={Trash2}
variant="danger"
onClick={() => handleDelete(alert.id)}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="حذف"
>
<Trash2 className="w-4 h-4" />
</button>
/>
</div>
</div>
</div>
@@ -479,43 +467,33 @@ function AlertSettingsContent() {
</div>
{/* Modal */}
{showModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
{/* Modal Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 sticky top-0 bg-white z-10">
<h2 className="text-xl font-bold text-gray-900">
{editingAlert ? 'ویرایش هشدار' : 'افزودن هشدار جدید'}
</h2>
<button
onClick={closeModal}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<Modal
isOpen={showModal}
onClose={closeModal}
title={editingAlert ? 'ویرایش هشدار' : 'افزودن هشدار جدید'}
size="xl"
>
{/* Modal Body */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Preview - Sticky at top */}
<div className="sticky top-[73px] z-10 bg-gradient-to-r from-blue-500 to-indigo-600 shadow-lg">
<div className="px-6 py-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center backdrop-blur-sm">
<Bell className="w-5 h-5 text-white" />
</div>
<div className="flex-1">
<div className="text-xs font-semibold text-white/90 mb-1.5">📢 پیش‌نمایش زنده</div>
<div className="text-sm md:text-base text-white leading-relaxed font-medium">
{generatePreviewText()}
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Preview - Sticky at top */}
<div className="sticky top-[73px] z-10 bg-gradient-to-r from-blue-500 to-indigo-600 shadow-lg -mx-6 -mt-6">
<div className="px-6 py-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center backdrop-blur-sm">
<Bell className="w-5 h-5 text-white" />
</div>
<div className="flex-1">
<div className="text-xs font-semibold text-white/90 mb-1.5">📢 پیش‌نمایش زنده</div>
<div className="text-sm md:text-base text-white leading-relaxed font-medium">
{generatePreviewText()}
</div>
</div>
</div>
</div>
</div>
<div className="p-6 space-y-6">
{/* Notification Type */}
<div className="px-6 space-y-6">
{/* Notification Type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
نوع اطلاع‌رسانی
@@ -605,9 +583,6 @@ function AlertSettingsContent() {
<div className="space-y-3">
{formData.rules.map((rule, index) => {
const needsMax = COMPARISON_TYPES.find(c => c.value === rule.comparisonType)?.needsMax
const selectedSensor = SENSOR_TYPES.find(s => s.value === rule.sensorType)
const SensorIcon = selectedSensor?.icon
const selectedComp = COMPARISON_TYPES.find(c => c.value === rule.comparisonType)
return (
<div key={index} className="bg-gradient-to-br from-orange-50 to-red-50 border-2 border-orange-200 rounded-xl p-4 space-y-3 relative shadow-sm">
@@ -760,29 +735,27 @@ function AlertSettingsContent() {
</label>
</div>
{/* Modal Footer */}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
disabled={saving}
>
انصراف
</button>
<button
type="submit"
className="px-6 py-2 bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 text-white rounded-lg transition-all duration-200 font-medium shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
disabled={saving}
>
{saving ? 'در حال ذخیره...' : editingAlert ? 'ذخیره تغییرات' : 'افزودن هشدار'}
</button>
</div>
</div>
</form>
{/* Modal Footer */}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 px-6">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
disabled={saving}
>
انصراف
</button>
<button
type="submit"
className="px-6 py-2 bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 text-white rounded-lg transition-all duration-200 font-medium shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
disabled={saving}
>
{saving ? 'در حال ذخیره...' : editingAlert ? 'ذخیره تغییرات' : 'افزودن هشدار'}
</button>
</div>
</div>
</div>
)}
</form>
</Modal>
</div>
)
}

View File

@@ -1,7 +1,7 @@
"use client"
import { useEffect, useMemo, useState } from 'react'
import { api } from '@/lib/api'
import { getCurrentPersianYear, getCurrentPersianMonth } from '@/lib/persian-date'
import { getCurrentPersianYear, getCurrentPersianMonth } from '@/lib/date/persian-date'
import Loading from '@/components/Loading'
function useQueryParam(name: string) {

View File

@@ -2,10 +2,13 @@
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { api } from '@/lib/api'
import { getCurrentPersianYear } from '@/lib/persian-date'
import { Calendar as CalendarIcon, ChevronRight, Database, TrendingUp } from 'lucide-react'
import Link from 'next/link'
import { getCurrentPersianYear } from '@/lib/date/persian-date'
import { Calendar as CalendarIcon, Database, TrendingUp } from 'lucide-react'
import Loading from '@/components/Loading'
import { PageHeader, BackLink, Card } from '@/components/common'
import { YearSelector } from '@/components/calendar'
import { MonthCard } from '@/components/cards'
import { StatsCard } from '@/components/cards'
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
@@ -75,54 +78,36 @@ export default function CalendarPage() {
<div className="min-h-screen p-4 md:p-6">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="mb-6">
<Link
href="/"
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors mb-4"
>
<ChevronRight className="w-4 h-4" />
بازگشت به صفحه اصلی
</Link>
<div className="flex items-center gap-3 mb-2">
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-xl shadow-md">
<CalendarIcon className="w-6 h-6 text-white" />
</div>
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">انتخاب سال و ماه</h1>
</div>
</div>
<BackLink href="/" label="بازگشت به صفحه اصلی" />
<PageHeader
icon={CalendarIcon}
title="انتخاب سال و ماه"
iconGradient="from-green-500 to-green-600"
/>
{/* Main Card */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 md:p-8">
<Card padding="lg">
{/* Year Selector */}
<div className="flex flex-wrap items-center justify-center gap-4 mb-6">
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
<CalendarIcon className="w-4 h-4 text-gray-500" />
انتخاب سال:
</label>
<select
className="px-4 py-2 border-2 border-gray-200 rounded-xl focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all bg-white font-medium"
value={year}
onChange={e => setYear(Number(e.target.value))}
>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
<YearSelector
years={years}
selectedYear={year}
onYearChange={setYear}
className="mb-6"
/>
{/* Summary Stats */}
<div className="bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-xl p-4 mb-6">
<div className="flex items-center justify-center gap-6 flex-wrap">
<div className="flex items-center gap-2">
<Database className="w-5 h-5 text-green-600" />
<span className="text-sm text-gray-700">
<span className="font-semibold text-green-700">{totalDays}</span> روز دارای داده
</span>
</div>
<div className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-green-600" />
<span className="text-sm text-gray-700">
<span className="font-semibold text-green-700">{totalRecords}</span> رکورد
</span>
</div>
<StatsCard
icon={Database}
label="روز دارای داده"
value={totalDays}
/>
<StatsCard
icon={TrendingUp}
label="رکورد"
value={totalRecords}
/>
</div>
</div>
@@ -133,40 +118,17 @@ export default function CalendarPage() {
const isActive = activeMonths.includes(m)
const stats = monthDays[m]
return (
<button
<MonthCard
key={m}
name={name}
isActive={isActive}
stats={stats}
onClick={() => isActive && router.replace(`/day-details?deviceId=${deviceId}&year=${year}&month=${m}`)}
disabled={!isActive}
className={`group relative rounded-xl border-2 p-5 text-center transition-all duration-300 ${
isActive
? 'bg-white border-green-200 hover:border-green-400 hover:shadow-lg hover:-translate-y-1'
: 'bg-gray-50 border-gray-200 opacity-50 cursor-not-allowed'
}`}
>
<div className={`text-lg font-semibold mb-2 ${isActive ? 'text-gray-900' : 'text-gray-400'}`}>
{name}
</div>
{isActive && stats ? (
<div className="space-y-1">
<div className="inline-flex items-center gap-1 bg-green-600 text-white text-xs rounded-full px-3 py-1.5 font-medium">
<Database className="w-3 h-3" />
{stats.days} روز
</div>
<div className="text-xs text-gray-600 mt-2">
{stats.records} رکورد
</div>
</div>
) : (
<div className="text-xs text-gray-400">بدون داده</div>
)}
{isActive && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-green-500 to-green-600 rounded-b-xl transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300" />
)}
</button>
/>
)
})}
</div>
</div>
</Card>
</div>
</div>
)

View File

@@ -1,26 +1,24 @@
"use client"
import { useEffect, useMemo, useState, useCallback, Suspense } from 'react'
import { useEffect, useMemo, useState, useCallback, Suspense, lazy } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { api, TelemetryDto, DailyReportDto } from '@/lib/api'
import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCurrentPersianMonth, getPreviousPersianDay, getNextPersianDay } from '@/lib/persian-date'
import { BarChart3, ChevronRight, ChevronLeft, Calendar as CalendarIcon, Bell } from 'lucide-react'
import { api, TelemetryDto } from '@/lib/api'
import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCurrentPersianMonth, getPreviousPersianDay, getNextPersianDay } from '@/lib/date/persian-date'
import { formatPersianDate, ensureDateFormat } from '@/lib/format/persian-date'
import { TABS, TabType } from '@/features/daily-report'
import { detectDataGaps } from '@/features/daily-report/utils'
import { BarChart3, Bell, CalendarIcon, ChevronRight } from 'lucide-react'
import Link from 'next/link'
import Loading from '@/components/Loading'
import {
SummaryTab,
ChartsTab,
WeatherTab,
AnalysisTab,
TABS,
TabType,
WeatherData,
ensureDateFormat,
formatPersianDate,
QOM_LAT,
QOM_LON,
detectDataGaps,
DataGap
} from '@/components/daily-report'
import { SummaryTab } from '@/components/daily-report'
import { fetchForecastWeather, isToday as checkIsToday, WeatherData } from '@/features/weather'
import { Tabs, PageHeader, Button } from '@/components/common'
import { DateNavigation } from '@/components/navigation'
import { usePullToRefresh } from '@/hooks/usePullToRefresh'
// Lazy load heavy components
const ChartsTab = lazy(() => import('@/components/daily-report/ChartsTab').then(m => ({ default: m.ChartsTab })))
const WeatherTab = lazy(() => import('@/components/daily-report/WeatherTab').then(m => ({ default: m.WeatherTab })))
const AnalysisTab = lazy(() => import('@/components/daily-report/AnalysisTab').then(m => ({ default: m.AnalysisTab })))
function DailyReportContent() {
const router = useRouter()
@@ -28,15 +26,8 @@ function DailyReportContent() {
const [telemetry, setTelemetry] = useState<TelemetryDto[]>([])
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<TabType>('summary')
const [dailyReport, setDailyReport] = useState<DailyReportDto | null>(null)
const [analysisLoading, setAnalysisLoading] = useState(false)
const [analysisError, setAnalysisError] = useState<string | null>(null)
const [weatherData, setWeatherData] = useState<WeatherData | null>(null)
const [weatherLoading, setWeatherLoading] = useState(false)
const [weatherError, setWeatherError] = useState<string | null>(null)
const [expandedDayIndex, setExpandedDayIndex] = useState<number | null>(null)
const [chartStartMinute, setChartStartMinute] = useState(0) // 00:00
const [chartEndMinute, setChartEndMinute] = useState(1439) // 23:59
const [forecastWeather, setForecastWeather] = useState<WeatherData | null>(null)
const [forecastWeatherLoading, setForecastWeatherLoading] = useState(false)
const deviceId = Number(searchParams.get('deviceId') ?? '1')
const dateParam = searchParams.get('date') ?? formatPersianDate(getCurrentPersianYear(), getCurrentPersianMonth(), getCurrentPersianDay())
@@ -103,156 +94,48 @@ function DailyReportContent() {
}
}, [deviceId, selectedDate])
const loadAnalysis = useCallback(async () => {
if (!selectedDate || dailyReport) return
setAnalysisLoading(true)
setAnalysisError(null)
try {
const report = await api.getDailyReport(deviceId, selectedDate)
setDailyReport(report)
} catch (error) {
console.error('Error loading analysis:', error)
setAnalysisError('خطا در دریافت تحلیل. لطفاً دوباره تلاش کنید.')
} finally {
setAnalysisLoading(false)
}
}, [deviceId, selectedDate, dailyReport])
const loadWeather = useCallback(async () => {
if (weatherData) return
setWeatherLoading(true)
setWeatherError(null)
try {
if (!selectedDate) {
setWeatherError('تاریخ انتخاب نشده است')
return
}
// تبدیل تاریخ شمسی به میلادی
const [year, month, day] = selectedDate.split('/').map(Number)
const gregorianDate = persianToGregorian(year, month, day)
// بررسی اینکه تاریخ امروز است یا گذشته
const today = new Date()
today.setHours(0, 0, 0, 0)
gregorianDate.setHours(0, 0, 0, 0)
const isPast = gregorianDate.getTime() < today.getTime()
let weather: WeatherData
if (isPast) {
// استفاده از Historical API برای روزهای گذشته
const dateStr = gregorianDate.toISOString().split('T')[0] // YYYY-MM-DD
const response = await fetch(
`https://archive-api.open-meteo.com/v1/archive?latitude=${QOM_LAT}&longitude=${QOM_LON}&start_date=${dateStr}&end_date=${dateStr}&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,sunshine_duration&timezone=Asia/Tehran`
)
if (!response.ok) {
throw new Error('Failed to fetch historical weather data')
}
const data = await response.json()
// ساختار داده برای روزهای گذشته (بدون current و hourly)
weather = {
current: {
temperature: data.daily.temperature_2m_max?.[0] || 0,
humidity: 0, // Historical API رطوبت ندارد
windSpeed: data.daily.wind_speed_10m_max?.[0] || 0,
weatherCode: data.daily.weather_code?.[0] || 0,
},
hourly: [], // برای گذشته hourly نداریم
daily: [{
date: data.daily.time?.[0] || dateStr,
tempMax: data.daily.temperature_2m_max?.[0] || 0,
tempMin: data.daily.temperature_2m_min?.[0] || 0,
weatherCode: data.daily.weather_code?.[0] || 0,
precipitation: data.daily.precipitation_sum?.[0] || 0,
precipitationProbability: 0,
uvIndexMax: 0,
sunshineDuration: data.daily.sunshine_duration?.[0] || 0,
windSpeedMax: data.daily.wind_speed_10m_max?.[0] || 0,
}]
}
} else {
// استفاده از Forecast API برای امروز و آینده
const response = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${QOM_LAT}&longitude=${QOM_LON}&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&hourly=temperature_2m,relative_humidity_2m,weather_code,precipitation&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,uv_index_max,sunshine_duration,wind_speed_10m_max&timezone=Asia/Tehran&forecast_days=7`
)
if (!response.ok) {
throw new Error('Failed to fetch weather data')
}
const data = await response.json()
// Get only today's hourly data (first 24 hours)
const todayHourly = data.hourly.time.slice(0, 24).map((time: string, i: number) => ({
time,
temperature: data.hourly.temperature_2m[i],
humidity: data.hourly.relative_humidity_2m[i],
weatherCode: data.hourly.weather_code[i],
precipitation: data.hourly.precipitation[i],
}))
weather = {
current: {
temperature: data.current.temperature_2m,
humidity: data.current.relative_humidity_2m,
windSpeed: data.current.wind_speed_10m,
weatherCode: data.current.weather_code,
},
hourly: todayHourly,
daily: data.daily.time.map((date: string, i: number) => ({
date,
tempMax: data.daily.temperature_2m_max[i],
tempMin: data.daily.temperature_2m_min[i],
weatherCode: data.daily.weather_code[i],
precipitation: data.daily.precipitation_sum[i],
precipitationProbability: data.daily.precipitation_probability_max[i],
uvIndexMax: data.daily.uv_index_max[i],
sunshineDuration: data.daily.sunshine_duration[i],
windSpeedMax: data.daily.wind_speed_10m_max[i],
}))
}
}
setWeatherData(weather)
} catch (error) {
console.error('Error loading weather:', error)
setWeatherError('خطا در دریافت اطلاعات آب و هوا. لطفاً دوباره تلاش کنید.')
} finally {
setWeatherLoading(false)
}
}, [weatherData, selectedDate])
// Pull-to-refresh for mobile
usePullToRefresh(loadData)
useEffect(() => {
// Reset states when date or device changes
setDailyReport(null)
setWeatherData(null)
setAnalysisError(null)
setWeatherError(null)
loadData()
}, [loadData, deviceId, selectedDate])
// Load analysis when switching to analysis tab
// Load forecast weather data when selectedDate is today - lazy load after main data is loaded
useEffect(() => {
if (activeTab === 'analysis') {
loadAnalysis()
if (!selectedDate || loading) {
setForecastWeather(null)
setForecastWeatherLoading(false)
return
}
}, [activeTab, loadAnalysis])
// Load weather when switching to weather tab
useEffect(() => {
if (activeTab === 'weather') {
loadWeather()
const isTodayDate = checkIsToday(selectedDate)
if (isTodayDate) {
// Delay fetch to ensure page is fully loaded first
setForecastWeatherLoading(true)
setForecastWeather(null)
// Use setTimeout to ensure page is fully rendered before fetching
const timer = setTimeout(() => {
fetchForecastWeather()
.then(setForecastWeather)
.catch((error) => {
console.error('Error loading forecast weather:', error)
setForecastWeather(null)
})
.finally(() => {
setForecastWeatherLoading(false)
})
}, 100) // Small delay to ensure page is rendered
return () => clearTimeout(timer)
} else {
setForecastWeather(null)
setForecastWeatherLoading(false)
}
}, [activeTab, loadWeather])
}, [selectedDate, loading])
const sortedTelemetry = useMemo(() => {
return [...telemetry].sort((a, b) => {
@@ -269,141 +152,12 @@ function DailyReportContent() {
const gas = useMemo(() => sortedTelemetry.map(t => Number(t.gasPPM ?? 0)), [sortedTelemetry])
const lux = useMemo(() => sortedTelemetry.map(t => Number(t.lux ?? 0)), [sortedTelemetry])
// Min/Max calculations (not currently used but kept for potential future use)
// const tempMinMax = useMemo(() => {
// const min = Math.min(...temp)
// const max = Math.max(...temp)
// return {
// min: min < 0 ? Math.floor(min / 10) * 10 : 0,
// max: max > 40 ? Math.floor(max / 10) * 10 : 40
// }
// }, [temp])
// const luxMinMax = useMemo(() => {
// const max = Math.max(...lux)
// return {
// min: 0,
// max: max > 2000 ? Math.floor(max / 1000) * 1000 : 2000
// }
// }, [lux])
// Detect data gaps in the full day data
const dataGaps = useMemo(() => {
const timestamps = sortedTelemetry.map(t => t.serverTimestampUtc || t.timestampUtc)
return detectDataGaps(timestamps, 30) // 30 minutes threshold
}, [sortedTelemetry])
// Filtered telemetry for charts based on minute range
const filteredTelemetryForCharts = useMemo(() => {
return sortedTelemetry.filter(t => {
const timestamp = t.serverTimestampUtc || t.timestampUtc
const date = new Date(timestamp)
const minuteOfDay = date.getHours() * 60 + date.getMinutes()
return minuteOfDay >= chartStartMinute && minuteOfDay <= chartEndMinute
})
}, [sortedTelemetry, chartStartMinute, chartEndMinute])
// Detect gaps in filtered data
const filteredDataGaps = useMemo(() => {
const timestamps = filteredTelemetryForCharts.map(t => t.serverTimestampUtc || t.timestampUtc)
return detectDataGaps(timestamps, 30)
}, [filteredTelemetryForCharts])
// Filtered chart labels
const chartLabels = useMemo(() => {
return filteredTelemetryForCharts.map(t => {
const timestamp = t.serverTimestampUtc || t.timestampUtc
const date = new Date(timestamp)
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
const seconds = date.getSeconds().toString().padStart(2, '0')
return `${hours}:${minutes}:${seconds}`
})
}, [filteredTelemetryForCharts])
// Helper function to insert nulls for gaps
const insertGapsInData = (data: number[], timestamps: string[], gaps: DataGap[]): (number | null)[] => {
if (gaps.length === 0 || data.length < 2) return data
const result: (number | null)[] = []
for (let i = 0; i < data.length; i++) {
result.push(data[i])
// Check if there's a gap after this point
if (i < data.length - 1) {
const currentTime = new Date(timestamps[i])
const nextTime = new Date(timestamps[i + 1])
const currentMinute = currentTime.getHours() * 60 + currentTime.getMinutes()
const nextMinute = nextTime.getHours() * 60 + nextTime.getMinutes()
// Find if any gap exists between current and next
const hasGap = gaps.some(gap =>
currentMinute <= gap.startMinute && nextMinute >= gap.endMinute
)
if (hasGap) {
result.push(null) // Insert null to break the line
}
}
}
return result
}
// Filtered data arrays for charts (with gaps as null)
const filteredTimestamps = useMemo(() =>
filteredTelemetryForCharts.map(t => t.serverTimestampUtc || t.timestampUtc),
[filteredTelemetryForCharts]
)
const chartSoil = useMemo(() => {
const data = filteredTelemetryForCharts.map(t => Number(t.soilPercent ?? 0))
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
const chartTemp = useMemo(() => {
const data = filteredTelemetryForCharts.map(t => Number(t.temperatureC ?? 0))
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
const chartHum = useMemo(() => {
const data = filteredTelemetryForCharts.map(t => Number(t.humidityPercent ?? 0))
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
const chartGas = useMemo(() => {
const data = filteredTelemetryForCharts.map(t => Number(t.gasPPM ?? 0))
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
const chartLux = useMemo(() => {
const data = filteredTelemetryForCharts.map(t => Number(t.lux ?? 0))
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
// Min/Max calculations for filtered charts (filter out nulls)
const chartTempMinMax = useMemo(() => {
const validTemps = chartTemp.filter((t): t is number => t !== null)
if (validTemps.length === 0) return { min: 0, max: 40 }
const min = Math.min(...validTemps)
const max = Math.max(...validTemps)
return {
min: min < 0 ? Math.floor(min / 10) * 10 : 0,
max: max > 40 ? Math.floor(max / 10) * 10 : 40
}
}, [chartTemp])
const chartLuxMinMax = useMemo(() => {
const validLux = chartLux.filter((l): l is number => l !== null)
if (validLux.length === 0) return { min: 0, max: 2000 }
const max = Math.max(...validLux)
return {
min: 0,
max: max > 2000 ? Math.floor(max / 1000) * 1000 : 2000
}
}, [chartLux])
if (loading) {
return <Loading message="در حال بارگذاری داده‌ها..." />
}
@@ -414,13 +168,13 @@ function DailyReportContent() {
<div className="text-center">
<CalendarIcon className="w-12 h-12 text-red-500 mx-auto mb-4" />
<div className="text-lg text-red-600 mb-4">تاریخ انتخاب نشده است</div>
<button
<Button
onClick={goToCalendar}
className="inline-flex items-center gap-2 border border-gray-300 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
variant="outline"
icon={ChevronRight}
>
<ChevronRight className="w-4 h-4" />
بازگشت به تقویم
</button>
</Button>
</div>
</div>
)
@@ -430,21 +184,11 @@ function DailyReportContent() {
<div className="min-h-screen p-4 md:p-6">
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl shadow-md">
<BarChart3 className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
گزارش روزانه {selectedDate}
</h1>
<p className="text-sm text-gray-500 mt-1">
مشاهده خلاصه و نمودارهای روز
</p>
</div>
</div>
<PageHeader
icon={BarChart3}
title="گزارش روزانه"
iconGradient="from-indigo-500 to-purple-600"
action={
<Link
href={`/alert-settings?deviceId=${deviceId}`}
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-orange-300 hover:border-orange-500 hover:bg-orange-50 text-orange-700 hover:text-orange-800 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
@@ -452,161 +196,45 @@ function DailyReportContent() {
<Bell className="w-5 h-5" />
<span className="hidden sm:inline">تنظیمات هشدار</span>
</Link>
</div>
</div>
}
/>
{/* Date Navigation Buttons */}
<div className="flex items-center justify-center gap-3 mb-4">
<button
onClick={goToPreviousDay}
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-gray-200 hover:border-indigo-500 hover:bg-indigo-50 text-gray-700 hover:text-indigo-600 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
>
<ChevronRight className="w-5 h-5" />
روز قبل
</button>
<button
onClick={goToCalendar}
className="flex items-center gap-2 px-6 py-2.5 bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white rounded-xl transition-all duration-200 font-medium shadow-md hover:shadow-lg"
>
<CalendarIcon className="w-5 h-5" />
انتخاب تاریخ
</button>
<button
onClick={goToNextDay}
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-gray-200 hover:border-indigo-500 hover:bg-indigo-50 text-gray-700 hover:text-indigo-600 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
>
روز بعد
<ChevronLeft className="w-5 h-5" />
</button>
</div>
{selectedDate && (
<DateNavigation
selectedDate={selectedDate}
onPrevious={goToPreviousDay}
onNext={goToNextDay}
onCalendar={goToCalendar}
/>
)}
{/* Tabs */}
<div className="bg-white rounded-2xl shadow-md border border-gray-200 overflow-hidden">
{/* Segmented Control for Mobile */}
<div className="p-3 md:p-6 md:pb-0">
<div className="bg-gray-100 rounded-xl p-1 flex md:hidden">
{TABS.map(tab => (
<button
key={tab.value}
onClick={() => setActiveTab(tab.value)}
className={`flex-1 px-2 py-2.5 text-xs font-medium rounded-lg transition-all duration-200 ${
activeTab === tab.value
? 'bg-white text-indigo-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Desktop Tabs */}
<div className="hidden md:flex border-b border-gray-200 -mx-6 -mt-6 mb-6">
{TABS.map(tab => (
<button
key={tab.value}
onClick={() => setActiveTab(tab.value)}
className={`flex-1 px-6 py-4 text-sm font-medium transition-all duration-200 whitespace-nowrap ${
activeTab === tab.value
? 'text-indigo-600 border-b-2 border-indigo-600 bg-indigo-50/50'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
<div className="p-4 md:p-6 md:pt-0">
{/* Summary Tab */}
{activeTab === 'summary' && (
<SummaryTab
temperature={{
current: temp.at(-1) ?? 0,
min: Math.min(...temp),
max: Math.max(...temp),
data: temp
}}
humidity={{
current: hum.at(-1) ?? 0,
min: Math.min(...hum),
max: Math.max(...hum),
data: hum
}}
soil={{
current: soil.at(-1) ?? 0,
min: Math.min(...soil),
max: Math.max(...soil),
data: soil
}}
gas={{
current: gas.at(-1) ?? 0,
min: Math.min(...gas),
max: Math.max(...gas),
data: gas
}}
lux={{
current: lux.at(-1) ?? 0,
min: Math.min(...lux),
max: Math.max(...lux),
data: lux
}}
/>
)}
{/* Charts Tab */}
{activeTab === 'charts' && (
<ChartsTab
chartStartMinute={chartStartMinute}
chartEndMinute={chartEndMinute}
onStartMinuteChange={setChartStartMinute}
onEndMinuteChange={setChartEndMinute}
labels={chartLabels}
soil={chartSoil}
humidity={chartHum}
temperature={chartTemp}
lux={chartLux}
gas={chartGas}
tempMinMax={chartTempMinMax}
luxMinMax={chartLuxMinMax}
totalRecords={filteredTelemetryForCharts.length}
dataGaps={dataGaps}
/>
)}
{/* Weather Tab */}
{activeTab === 'weather' && (
<WeatherTab
loading={weatherLoading}
error={weatherError}
weatherData={weatherData}
onRetry={() => {
setWeatherData(null)
setWeatherError(null)
loadWeather()
}}
expandedDayIndex={expandedDayIndex}
onDayToggle={setExpandedDayIndex}
selectedDate={selectedDate}
/>
)}
{/* Analysis Tab */}
{activeTab === 'analysis' && (
<AnalysisTab
loading={analysisLoading}
error={analysisError}
dailyReport={dailyReport}
onRetry={() => {
setDailyReport(null)
setAnalysisError(null)
loadAnalysis()
}}
/>
)}
</div>
</div>
<Tabs
tabs={TABS}
activeTab={activeTab}
setActiveTab={setActiveTab}
className="md:mx-0 mx-[-1rem] md:rounded-xl rounded-none"
>
{{
summary: <SummaryTab temperature={temp} humidity={hum} soil={soil} gas={gas} lux={lux} forecastWeather={forecastWeather} forecastWeatherLoading={forecastWeatherLoading} />,
charts: (
<Suspense fallback={<Loading message="در حال بارگذاری نمودارها..." />}>
<ChartsTab sortedTelemetry={sortedTelemetry} dataGaps={dataGaps} />
</Suspense>
),
weather: selectedDate ? (
<Suspense fallback={<Loading message="در حال بارگذاری اطلاعات آب و هوا..." />}>
<WeatherTab selectedDate={selectedDate} />
</Suspense>
) : null,
analysis: selectedDate ? (
<Suspense fallback={<Loading message="در حال بارگذاری تحلیل..." />}>
<AnalysisTab deviceId={deviceId} selectedDate={selectedDate} />
</Suspense>
) : null,
}}
</Tabs>
</div>
</div>
)

View File

@@ -2,10 +2,12 @@
import { useEffect, useState, useMemo, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { api } from '@/lib/api'
import { getCurrentPersianYear, getCurrentPersianMonth, getPersianMonthStartWeekday, getPersianMonthDays } from '@/lib/persian-date'
import { Calendar as CalendarIcon, ChevronRight, Database } from 'lucide-react'
import Link from 'next/link'
import { getCurrentPersianYear, getCurrentPersianMonth, getPersianMonthStartWeekday, getPersianMonthDays } from '@/lib/date/persian-date'
import { Calendar as CalendarIcon, Database } from 'lucide-react'
import Loading from '@/components/Loading'
import { PageHeader, BackLink, Card } from '@/components/common'
import { WeekdayHeaders } from '@/components/calendar'
import { CalendarDayCell, StatsCard } from '@/components/cards'
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
@@ -72,34 +74,17 @@ function DayDetailsContent() {
<div className="min-h-screen p-4 md:p-6">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="mb-6">
<Link
href={`/calendar?deviceId=${deviceId}`}
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors mb-4"
>
<ChevronRight className="w-4 h-4" />
بازگشت به تقویم
</Link>
<div className="flex items-center gap-3">
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-xl shadow-md">
<CalendarIcon className="w-6 h-6 text-white" />
</div>
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
{monthNames[month - 1]} {year}
</h1>
</div>
</div>
<BackLink href={`/calendar?deviceId=${deviceId}`} label="بازگشت به تقویم" />
<PageHeader
icon={CalendarIcon}
title={`${monthNames[month - 1]} ${year}`}
iconGradient="from-green-500 to-green-600"
/>
{/* Calendar Grid */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden">
<Card className="overflow-hidden" padding="none">
{/* Weekday Headers */}
<div className="grid grid-cols-7 text-center bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200">
{['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'].map(day => (
<div key={day} className="p-3 md:p-4 text-sm font-semibold text-gray-700">
{day}
</div>
))}
</div>
<WeekdayHeaders />
{/* Days Grid */}
<div className="grid grid-cols-7 gap-1 p-1 bg-gray-50">
@@ -123,41 +108,34 @@ function DayDetailsContent() {
const dateStr = `${year}/${monthStr}/${dayStr}`
return (
<button
<CalendarDayCell
key={day}
day={day}
hasData={true}
recordCount={recordCount}
onClick={() => router.replace(`/daily-report?deviceId=${deviceId}&date=${encodeURIComponent(dateStr)}`)}
className="group min-h-[90px] md:min-h-[100px] bg-white border-2 border-green-200 hover:border-green-400 hover:bg-gradient-to-br hover:from-green-50 hover:to-emerald-50 transition-all cursor-pointer rounded-lg p-2 md:p-3 flex flex-col items-center justify-center shadow-sm hover:shadow-md"
>
<div className="text-base md:text-lg font-semibold text-gray-900 mb-1.5">{day}</div>
<div className="flex items-center gap-1 bg-gradient-to-r from-green-500 to-green-600 text-white text-xs rounded-full px-2.5 py-1 font-medium">
<Database className="w-3 h-3" />
{recordCount}
</div>
</button>
/>
)
} else {
return (
<div
<CalendarDayCell
key={day}
className="min-h-[90px] md:min-h-[100px] bg-gray-50 border border-gray-200 rounded-lg p-2 md:p-3 flex items-center justify-center text-gray-400"
>
<div className="text-sm md:text-base">{day}</div>
</div>
day={day}
hasData={false}
/>
)
}
})}
</div>
</div>
</Card>
{/* Summary */}
<div className="mt-6 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-xl p-4 text-center">
<div className="flex items-center justify-center gap-2 text-sm text-gray-700">
<Database className="w-4 h-4 text-green-600" />
<span>
<span className="font-semibold text-green-700">{items.length}</span> روز دارای داده از{' '}
<span className="font-semibold text-green-700">{totalDays}</span> روز ماه
</span>
</div>
<StatsCard
icon={Database}
label={`روز دارای داده از ${totalDays} روز ماه`}
value={items.length}
/>
</div>
</div>
</div>

View File

@@ -1,9 +1,11 @@
"use client"
import { useEffect, useState, useCallback } from 'react'
import { api, DeviceSettingsDto } from '@/lib/api'
import { Settings, ChevronRight, Thermometer, Wind, Sun, Droplets, Save, Loader2, AlertCircle, CheckCircle2, Bell } from 'lucide-react'
import { Settings, Thermometer, Wind, Sun, Droplets, Save, Loader2, AlertCircle, Bell } from 'lucide-react'
import Link from 'next/link'
import Loading from '@/components/Loading'
import { PageHeader, BackLink, ErrorMessage, SuccessMessage, EmptyState, Card } from '@/components/common'
import { SettingsSection, SettingsInputGroup } from '@/components/settings'
function useQueryParam(name: string) {
if (typeof window === 'undefined') return null as string | null
@@ -93,19 +95,13 @@ export default function DeviceSettingsPage() {
if (!deviceId) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="text-center">
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<div className="text-lg text-red-600 mb-4">شناسه دستگاه مشخص نشده است</div>
<Link
href="/devices"
className="inline-flex items-center gap-2 border border-gray-300 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
>
<ChevronRight className="w-4 h-4" />
بازگشت به انتخاب دستگاه
</Link>
</div>
</div>
<ErrorMessage
message="شناسه دستگاه مشخص نشده است"
fullPage
action={
<BackLink href="/devices" label="بازگشت به انتخاب دستگاه" />
}
/>
)
}
@@ -113,23 +109,12 @@ export default function DeviceSettingsPage() {
<div className="min-h-screen p-4 md:p-6">
<div className="max-w-5xl mx-auto">
{/* Header */}
<div className="mb-6">
<Link
href={`/devices`}
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors mb-4"
>
<ChevronRight className="w-4 h-4" />
بازگشت به انتخاب دستگاه
</Link>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl shadow-md">
<Settings className="w-6 h-6 text-white" />
</div>
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
تنظیمات {deviceName}
</h1>
</div>
<BackLink href="/devices" label="بازگشت به انتخاب دستگاه" />
<PageHeader
icon={Settings}
title={`تنظیمات ${deviceName}`}
iconGradient="from-blue-500 to-blue-600"
action={
<Link
href={`/alert-settings?deviceId=${deviceId}`}
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-orange-300 hover:border-orange-500 hover:bg-orange-50 text-orange-700 hover:text-orange-800 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
@@ -137,188 +122,107 @@ export default function DeviceSettingsPage() {
<Bell className="w-5 h-5" />
<span className="hidden sm:inline">تنظیمات هشدار</span>
</Link>
</div>
</div>
}
/>
{/* Messages */}
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-red-700 flex items-center gap-2">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
{error}
</div>
)}
{success && (
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl text-green-700 flex items-center gap-2">
<CheckCircle2 className="w-5 h-5 flex-shrink-0" />
{success}
</div>
)}
{error && <ErrorMessage message={error} className="mb-6" />}
{success && <SuccessMessage message={success} className="mb-6" />}
{!settings ? (
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-8 text-center">
<AlertCircle className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<div className="text-lg text-gray-600 mb-6">
تنظیمات برای این دستگاه وجود ندارد
</div>
<button
onClick={initializeDefaultSettings}
className="bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white px-8 py-3 rounded-xl transition-all shadow-md hover:shadow-lg font-medium"
>
ایجاد تنظیمات پیش‌فرض
</button>
</div>
<EmptyState
icon={AlertCircle}
title="تنظیمات وجود ندارد"
message="تنظیمات برای این دستگاه وجود ندارد"
action={
<button
onClick={initializeDefaultSettings}
className="bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white px-8 py-3 rounded-xl transition-all shadow-md hover:shadow-lg font-medium"
>
ایجاد تنظیمات پیش‌فرض
</button>
}
/>
) : (
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 md:p-8">
<Card padding="lg">
<div className="grid gap-8 md:grid-cols-2">
{/* Temperature Settings */}
<div className="space-y-5">
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
<div className="w-10 h-10 bg-gradient-to-br from-red-500 to-orange-500 rounded-lg flex items-center justify-center">
<Thermometer className="w-5 h-5 text-white" />
</div>
<h3 className="text-lg font-semibold text-gray-900">
تنظیمات دما
</h3>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
حداکثر دما (°C)
</label>
<input
type="number"
step="0.1"
value={settings.maxTemperature}
onChange={(e) => handleInputChange('maxTemperature', parseFloat(e.target.value) || 0)}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/20 transition-all bg-gray-50 focus:bg-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
حداقل دما (°C)
</label>
<input
type="number"
step="0.1"
value={settings.minTemperature}
onChange={(e) => handleInputChange('minTemperature', parseFloat(e.target.value) || 0)}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/20 transition-all bg-gray-50 focus:bg-white"
/>
</div>
</div>
<SettingsSection
icon={Thermometer}
title="تنظیمات دما"
iconGradient="from-red-500 to-orange-500"
>
<SettingsInputGroup
label="محدوده دما"
minLabel="حداقل دما"
maxLabel="حداکثر دما"
minValue={settings.minTemperature}
maxValue={settings.maxTemperature}
onMinChange={(value) => handleInputChange('minTemperature', value)}
onMaxChange={(value) => handleInputChange('maxTemperature', value)}
minUnit="°C"
maxUnit="°C"
/>
</SettingsSection>
{/* Gas Settings */}
<div className="space-y-5">
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
<div className="w-10 h-10 bg-gradient-to-br from-gray-600 to-gray-700 rounded-lg flex items-center justify-center">
<Wind className="w-5 h-5 text-white" />
</div>
<h3 className="text-lg font-semibold text-gray-900">
تنظیمات گاز
</h3>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
حداکثر گاز CO (ppm)
</label>
<input
type="number"
value={settings.maxGasPPM}
onChange={(e) => handleInputChange('maxGasPPM', parseInt(e.target.value) || 0)}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-600/20 transition-all bg-gray-50 focus:bg-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
حداقل گاز CO (ppm)
</label>
<input
type="number"
value={settings.minGasPPM}
onChange={(e) => handleInputChange('minGasPPM', parseInt(e.target.value) || 0)}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-600/20 transition-all bg-gray-50 focus:bg-white"
/>
</div>
</div>
<SettingsSection
icon={Wind}
title="تنظیمات گاز"
iconGradient="from-gray-600 to-gray-700"
>
<SettingsInputGroup
label="محدوده گاز CO"
minLabel="حداقل گاز"
maxLabel="حداکثر گاز"
minValue={settings.minGasPPM}
maxValue={settings.maxGasPPM}
onMinChange={(value) => handleInputChange('minGasPPM', value)}
onMaxChange={(value) => handleInputChange('maxGasPPM', value)}
minUnit="ppm"
maxUnit="ppm"
minStep={1}
maxStep={1}
/>
</SettingsSection>
{/* Light Settings */}
<div className="space-y-5">
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
<div className="w-10 h-10 bg-gradient-to-br from-yellow-500 to-orange-500 rounded-lg flex items-center justify-center">
<Sun className="w-5 h-5 text-white" />
</div>
<h3 className="text-lg font-semibold text-gray-900">
تنظیمات نور
</h3>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
حداکثر نور (Lux)
</label>
<input
type="number"
step="0.1"
value={settings.maxLux}
onChange={(e) => handleInputChange('maxLux', parseFloat(e.target.value) || 0)}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-yellow-500 focus:outline-none focus:ring-2 focus:ring-yellow-500/20 transition-all bg-gray-50 focus:bg-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
حداقل نور (Lux)
</label>
<input
type="number"
step="0.1"
value={settings.minLux}
onChange={(e) => handleInputChange('minLux', parseFloat(e.target.value) || 0)}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-yellow-500 focus:outline-none focus:ring-2 focus:ring-yellow-500/20 transition-all bg-gray-50 focus:bg-white"
/>
</div>
</div>
<SettingsSection
icon={Sun}
title="تنظیمات نور"
iconGradient="from-yellow-500 to-orange-500"
>
<SettingsInputGroup
label="محدوده نور"
minLabel="حداقل نور"
maxLabel="حداکثر نور"
minValue={settings.minLux}
maxValue={settings.maxLux}
onMinChange={(value) => handleInputChange('minLux', value)}
onMaxChange={(value) => handleInputChange('maxLux', value)}
minUnit="Lux"
maxUnit="Lux"
/>
</SettingsSection>
{/* Humidity Settings */}
<div className="space-y-5">
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-lg flex items-center justify-center">
<Droplets className="w-5 h-5 text-white" />
</div>
<h3 className="text-lg font-semibold text-gray-900">
تنظیمات رطوبت هوا
</h3>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
حداکثر رطوبت (%)
</label>
<input
type="number"
step="0.1"
value={settings.maxHumidityPercent}
onChange={(e) => handleInputChange('maxHumidityPercent', parseFloat(e.target.value) || 0)}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 transition-all bg-gray-50 focus:bg-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
حداقل رطوبت (%)
</label>
<input
type="number"
step="0.1"
value={settings.minHumidityPercent}
onChange={(e) => handleInputChange('minHumidityPercent', parseFloat(e.target.value) || 0)}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 transition-all bg-gray-50 focus:bg-white"
/>
</div>
</div>
<SettingsSection
icon={Droplets}
title="تنظیمات رطوبت هوا"
iconGradient="from-blue-500 to-cyan-500"
>
<SettingsInputGroup
label="محدوده رطوبت"
minLabel="حداقل رطوبت"
maxLabel="حداکثر رطوبت"
minValue={settings.minHumidityPercent}
maxValue={settings.maxHumidityPercent}
onMinChange={(value) => handleInputChange('minHumidityPercent', value)}
onMaxChange={(value) => handleInputChange('maxHumidityPercent', value)}
minUnit="%"
maxUnit="%"
/>
</SettingsSection>
</div>
{/* Save Button */}
@@ -345,7 +249,7 @@ export default function DeviceSettingsPage() {
)}
</button>
</div>
</div>
</Card>
)}
</div>
</div>

View File

@@ -2,10 +2,13 @@
import { useEffect, useState, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { api, DeviceDto, PagedResult } from '@/lib/api'
import { Settings, Calendar, LogOut, ArrowRight, Search, ChevronRight, ChevronLeft } from 'lucide-react'
import Link from 'next/link'
import { Settings, LogOut } from 'lucide-react'
import Loading from '@/components/Loading'
import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/persian-date'
import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/date/persian-date'
import { PageHeader, ErrorMessage, EmptyState, BackLink } from '@/components/common'
import { SearchInput } from '@/components/forms'
import { DeviceCard } from '@/components/cards'
import { Pagination } from '@/components/navigation'
export default function DevicesPage() {
const router = useRouter()
@@ -75,9 +78,10 @@ export default function DevicesPage() {
}
}, [user, currentPage, searchTerm, fetchDevices])
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
setSearchTerm(searchInput)
const handleSearch = (searchValue?: string) => {
const valueToUse = searchValue ?? searchInput
setSearchTerm(valueToUse)
setSearchInput(valueToUse)
setCurrentPage(1)
}
@@ -96,24 +100,19 @@ export default function DevicesPage() {
if (error && (!pagedResult || pagedResult.items.length === 0)) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-xl p-8 border border-gray-100 text-center">
<div className="text-red-600 mb-4">
<Settings className="w-16 h-16 mx-auto" />
</div>
<h2 className="text-xl font-bold text-gray-900 mb-2">خطا</h2>
<p className="text-gray-600 mb-6">{error}</p>
<button
onClick={handleLogout}
className="w-full py-3 rounded-xl bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium transition-all duration-200 flex items-center justify-center gap-2"
>
<LogOut className="w-5 h-5" />
خروج
</button>
</div>
</div>
</div>
<ErrorMessage
message={error}
fullPage
action={
<button
onClick={handleLogout}
className="w-full py-3 rounded-xl bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium transition-all duration-200 flex items-center justify-center gap-2"
>
<LogOut className="w-5 h-5" />
خروج
</button>
}
/>
)
}
@@ -121,47 +120,30 @@ export default function DevicesPage() {
<div className="min-h-screen p-4 md:p-8">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="mb-6 flex items-center justify-between flex-wrap gap-4">
<div>
<h1 className="text-2xl md:text-3xl font-bold text-gray-900 mb-2">
انتخاب دستگاه
</h1>
{user && (
<p className="text-sm text-gray-600">
{user.name} {user.family} ({user.mobile})
</p>
)}
</div>
<button
onClick={handleLogout}
className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-xl transition-all duration-200 text-sm"
>
<LogOut className="w-4 h-4" />
خروج
</button>
</div>
<PageHeader
icon={Settings}
title="انتخاب دستگاه"
subtitle={user ? `${user.name} ${user.family} (${user.mobile})` : undefined}
action={
<button
onClick={handleLogout}
className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-xl transition-all duration-200 text-sm"
>
<LogOut className="w-4 h-4" />
خروج
</button>
}
iconGradient="from-green-500 to-green-600"
/>
{/* Search */}
<div className="mb-6">
<form onSubmit={handleSearch} className="flex gap-2">
<div className="flex-1 relative">
<Search className="absolute right-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="جستجو در نام دستگاه، نام صاحب، نام خانوادگی صاحب، موقعیت..."
className="w-full pr-12 pl-4 py-3 rounded-xl border-2 border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-200"
/>
</div>
<button
type="submit"
className="px-6 py-3 bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white font-medium rounded-xl transition-all duration-200 shadow-md hover:shadow-lg flex items-center gap-2"
>
<Search className="w-5 h-5" />
جستجو
</button>
</form>
<SearchInput
value={searchInput}
onValueChange={setSearchInput}
onSubmit={handleSearch}
placeholder="جستجو در نام دستگاه، نام صاحب، نام خانوادگی صاحب، موقعیت..."
/>
{searchTerm && (
<div className="mt-2 flex items-center gap-2">
<span className="text-sm text-gray-600">
@@ -195,113 +177,36 @@ export default function DevicesPage() {
{pagedResult.items.map((device) => {
const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}`
return (
<Link
<DeviceCard
key={device.id}
device={device}
href={`/daily-report?deviceId=${device.id}&date=${encodeURIComponent(today)}`}
className="group bg-white rounded-2xl shadow-md hover:shadow-xl transition-all duration-300 overflow-hidden border border-gray-100 p-6 relative"
>
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-14 h-14 rounded-xl bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center shadow-md group-hover:scale-110 transition-transform duration-300">
<Settings className="w-7 h-7 text-white" />
</div>
<div className="flex-1">
<h3 className="text-xl font-semibold text-gray-900 mb-1 group-hover:text-green-600 transition-colors">
{device.deviceName}
</h3>
<p className="text-sm text-gray-500 mb-2">
{device.location || 'بدون موقعیت'}
</p>
<div className="flex items-center gap-2 text-xs text-gray-400">
<span>{device.userName} {device.userFamily}</span>
</div>
</div>
<div className="flex-shrink-0">
<Calendar className="w-5 h-5 text-gray-400 group-hover:text-green-600 transition-colors" />
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-green-500 to-green-600 transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300" />
</Link>
/>
)
})}
</div>
{/* Pagination */}
{showPagination && (
<div className="flex items-center justify-center gap-2 mt-6">
<button
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className={`px-4 py-2 rounded-xl transition-all duration-200 flex items-center gap-2 ${
currentPage === 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white border-2 border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
}`}
>
<ChevronRight className="w-4 h-4" />
قبلی
</button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum: number
if (totalPages <= 5) {
pageNum = i + 1
} else if (currentPage <= 3) {
pageNum = i + 1
} else if (currentPage >= totalPages - 2) {
pageNum = totalPages - 4 + i
} else {
pageNum = currentPage - 2 + i
}
return (
<button
key={pageNum}
onClick={() => setCurrentPage(pageNum)}
className={`w-10 h-10 rounded-xl transition-all duration-200 ${
currentPage === pageNum
? 'bg-gradient-to-r from-green-500 to-green-600 text-white shadow-md'
: 'bg-white border-2 border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
}`}
>
{pageNum}
</button>
)
})}
</div>
<button
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className={`px-4 py-2 rounded-xl transition-all duration-200 flex items-center gap-2 ${
currentPage === totalPages
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white border-2 border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
}`}
>
بعدی
<ChevronLeft className="w-4 h-4" />
</button>
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
className="mt-6"
/>
)}
</>
) : (
!loading && (
<div className="bg-white rounded-2xl shadow-md border border-gray-100 p-8 text-center">
<p className="text-gray-600">هیچ دستگاهی یافت نشد</p>
</div>
<EmptyState
message="هیچ دستگاهی یافت نشد"
/>
)
)}
{/* Back to Home */}
<div className="mt-6 text-center">
<Link
href="/"
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors"
>
<ArrowRight className="w-4 h-4" />
بازگشت به صفحه اصلی
</Link>
<BackLink href="/" label="بازگشت به صفحه اصلی" />
</div>
</div>
</div>

View File

@@ -21,6 +21,13 @@
* {
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html {
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
body {
@@ -29,6 +36,27 @@ body {
color: var(--foreground);
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
font-weight: 400;
overscroll-behavior-y: contain; /* Prevent pull-to-refresh on Android */
-webkit-overflow-scrolling: touch;
}
/* Better touch targets (minimum 44x44px for mobile) */
button, a, [role="button"] {
min-height: 44px;
min-width: 44px;
touch-action: manipulation; /* Disable double-tap zoom */
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
/* Hardware acceleration for animations */
.transition-all,
.transition-colors,
.transition-transform,
.transition-opacity {
will-change: transform, opacity;
transform: translateZ(0); /* Force GPU acceleration */
}
.persian-number {
@@ -107,4 +135,25 @@ body {
border: none;
border-top: 1px solid #e5e7eb;
margin: 1.5em 0;
}
/* Loading skeleton animation */
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
.skeleton {
background: linear-gradient(
90deg,
#f0f0f0 0%,
#e0e0e0 50%,
#f0f0f0 100%
);
background-size: 2000px 100%;
animation: shimmer 2s infinite;
}

View File

@@ -16,8 +16,13 @@ export const metadata: Metadata = {
export const viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false
maximumScale: 5, // Allow zoom for accessibility
userScalable: true, // Better for accessibility
viewportFit: 'cover', // For notch support
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#16a34a' },
{ media: '(prefers-color-scheme: dark)', color: '#16a34a' },
],
}
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {

View File

@@ -1,44 +1,17 @@
"use client"
import { useState, useRef, useEffect } from 'react'
import { useState } from 'react'
import { api } from '@/lib/api'
import { Smartphone, ArrowLeft } from 'lucide-react'
import { useRouter } from 'next/navigation'
import Loading from '@/components/Loading'
import { MobileInput } from '@/components/forms'
import { ErrorMessage } from '@/components/common'
export default function LoginPage() {
const [mobile, setMobile] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const router = useRouter()
const mobileRef = useRef<HTMLInputElement>(null)
useEffect(() => {
const checkAutoFill = () => {
setTimeout(() => {
if (mobileRef.current) {
const filledMobile = mobileRef.current.value
if (filledMobile && filledMobile !== mobile) {
setMobile(filledMobile)
}
}
}, 100)
}
checkAutoFill()
window.addEventListener('load', checkAutoFill)
return () => {
window.removeEventListener('load', checkAutoFill)
}
}, [mobile])
const handleInputChange = (e: React.FormEvent<HTMLInputElement>) => {
const value = e.currentTarget.value
// Only allow digits
const digitsOnly = value.replace(/\D/g, '')
setMobile(digitsOnly)
setError(null)
}
const normalizeMobile = (mobile: string): string => {
const digitsOnly = mobile.replace(/\D/g, '')
@@ -111,24 +84,17 @@ export default function LoginPage() {
<label className="block text-sm font-medium text-gray-700 mb-2">
شماره موبایل
</label>
<input
ref={mobileRef}
type="tel"
inputMode="numeric"
className="w-full px-4 py-3 rounded-xl border-2 border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-200 text-left"
placeholder="09123456789"
<MobileInput
value={mobile}
onInput={handleInputChange}
onValueChange={(value) => {
setMobile(value)
setError(null)
}}
disabled={loading}
maxLength={11}
/>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-xl text-sm text-center">
{error}
</div>
)}
{error && <ErrorMessage message={error} />}
<button
type="submit"

View File

@@ -1,16 +1,17 @@
"use client"
import { useEffect, useState, useRef } from 'react'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { api } from '@/lib/api'
import { Smartphone, ArrowLeft } from 'lucide-react'
import Loading from '@/components/Loading'
import { MobileInput } from '@/components/forms'
import { ErrorMessage } from '@/components/common'
export default function Home() {
const router = useRouter()
const [mobile, setMobile] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const mobileRef = useRef<HTMLInputElement>(null)
useEffect(() => {
// Check if user is logged in
@@ -28,34 +29,6 @@ export default function Home() {
}
}, [router])
useEffect(() => {
const checkAutoFill = () => {
setTimeout(() => {
if (mobileRef.current) {
const filledMobile = mobileRef.current.value
if (filledMobile && filledMobile !== mobile) {
setMobile(filledMobile)
}
}
}, 100)
}
checkAutoFill()
window.addEventListener('load', checkAutoFill)
return () => {
window.removeEventListener('load', checkAutoFill)
}
}, [mobile])
const handleInputChange = (e: React.FormEvent<HTMLInputElement>) => {
const value = e.currentTarget.value
// Only allow digits
const digitsOnly = value.replace(/\D/g, '')
setMobile(digitsOnly)
setError(null)
}
const normalizeMobile = (mobile: string): string => {
const digitsOnly = mobile.replace(/\D/g, '')
if (digitsOnly.startsWith('9') && digitsOnly.length === 10) {
@@ -127,24 +100,17 @@ export default function Home() {
<label className="block text-sm font-medium text-gray-700 mb-2">
شماره موبایل
</label>
<input
ref={mobileRef}
type="tel"
inputMode="numeric"
className="w-full px-4 py-3 rounded-xl border-2 border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-200 text-left"
placeholder="09123456789"
<MobileInput
value={mobile}
onInput={handleInputChange}
onValueChange={(value) => {
setMobile(value)
setError(null)
}}
disabled={loading}
maxLength={11}
/>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-xl text-sm text-center">
{error}
</div>
)}
{error && <ErrorMessage message={error} />}
<button
type="submit"

View File

@@ -43,18 +43,41 @@ self.addEventListener('fetch', (event: FetchEvent) => {
if (event.request.mode === 'navigate') {
event.respondWith(
(async () => {
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(event.request);
// Return cached version immediately (fast)
if (cachedResponse) {
// Update cache in background (stale-while-revalidate)
fetch(event.request)
.then((response) => {
if (response.ok) {
cache.put(event.request, response.clone());
}
})
.catch(() => {
// Ignore network errors in background update
});
return cachedResponse;
}
// If no cache, fetch from network
try {
const preloadResponse = await event.preloadResponse;
if (preloadResponse) {
cache.put(event.request, preloadResponse.clone());
return preloadResponse;
}
return await fetch(event.request);
const networkResponse = await fetch(event.request);
if (networkResponse.ok) {
cache.put(event.request, networkResponse.clone());
}
return networkResponse;
} catch {
const cache = await caches.open(CACHE_NAME);
return await cache.match('/') || new Response('', {
status: 404,
statusText: 'Not Found',
return await cache.match('/') || new Response('Offline', {
status: 503,
statusText: 'Service Unavailable',
});
}
})()
@@ -62,15 +85,28 @@ self.addEventListener('fetch', (event: FetchEvent) => {
return;
}
// Stale-while-revalidate for other requests
event.respondWith(
(async () => {
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(event.request);
// Return cached version immediately if available
if (cachedResponse) {
// Update cache in background
fetch(event.request)
.then((response) => {
if (response.ok && response.type === 'basic') {
cache.put(event.request, response.clone());
}
})
.catch(() => {
// Ignore network errors in background update
});
return cachedResponse;
}
// If no cache, fetch from network
try {
const response = await fetch(event.request);
@@ -85,9 +121,9 @@ self.addEventListener('fetch', (event: FetchEvent) => {
return response;
} catch {
return new Response('', {
status: 404,
statusText: 'Not Found',
return new Response('Resource not available offline', {
status: 503,
statusText: 'Service Unavailable',
});
}
})()

View File

@@ -1,10 +1,13 @@
"use client"
import { useState, useEffect, useRef, useCallback, Suspense } from 'react'
import { useState, useEffect, useCallback, Suspense } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { api } from '@/lib/api'
import { Shield, ArrowRight, ArrowLeft, RotateCcw } from 'lucide-react'
import { Shield, ArrowRight, ArrowLeft } from 'lucide-react'
import Link from 'next/link'
import Loading from '@/components/Loading'
import { CodeInput } from '@/components/forms'
import { ErrorMessage } from '@/components/common'
import { ResendButton } from '@/components/utils'
function VerifyCodeContent() {
const searchParams = useSearchParams()
@@ -16,7 +19,6 @@ function VerifyCodeContent() {
const [error, setError] = useState<string | null>(null)
const [resendCooldown, setResendCooldown] = useState(120)
const [canResend, setCanResend] = useState(false)
const inputRefs = useRef<(HTMLInputElement | null)[]>([])
const checkResendStatus = useCallback(async () => {
try {
@@ -54,41 +56,6 @@ function VerifyCodeContent() {
return () => clearInterval(interval)
}, [mobile, router, checkResendStatus])
const handleCodeChange = (index: number, value: string) => {
if (!/^\d*$/.test(value)) return
const newCode = [...code]
newCode[index] = value.slice(-1)
setCode(newCode)
setError(null)
// Auto-focus next input
if (value && index < 3) {
inputRefs.current[index + 1]?.focus()
}
// Auto-submit when all fields are filled
if (newCode.every(c => c !== '') && newCode.join('').length === 4) {
handleVerify(newCode.join(''))
}
}
const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && !code[index] && index > 0) {
inputRefs.current[index - 1]?.focus()
}
}
const handlePaste = (e: React.ClipboardEvent) => {
e.preventDefault()
const pastedData = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 4)
if (pastedData.length === 4) {
const newCode = pastedData.split('')
setCode(newCode)
inputRefs.current[3]?.focus()
handleVerify(pastedData)
}
}
const handleVerify = async (codeToVerify?: string) => {
const codeValue = codeToVerify || code.join('')
@@ -137,15 +104,12 @@ function VerifyCodeContent() {
}
} else {
setError(result.message || 'کد وارد شده نادرست است')
// Clear code inputs
setCode(['', '', '', ''])
inputRefs.current[0]?.focus()
}
} catch (error: unknown) {
console.error('Error verifying code:', error)
setError(error instanceof Error ? error.message : 'خطا در ارتباط با سرور')
setCode(['', '', '', ''])
inputRefs.current[0]?.focus()
} finally {
setLoading(false)
}
@@ -164,9 +128,7 @@ function VerifyCodeContent() {
if (result.success) {
setResendCooldown(result.resendAfterSeconds || 120)
// Clear code inputs
setCode(['', '', '', ''])
inputRefs.current[0]?.focus()
} else {
setError(result.message || 'خطا در ارسال مجدد کد')
setCanResend(true)
@@ -215,29 +177,14 @@ function VerifyCodeContent() {
</div>
<form onSubmit={(e) => { e.preventDefault(); handleVerify(); }} className="space-y-5">
<div className="flex justify-center gap-3" style={{ direction: 'ltr' }}>
{code.map((digit, index) => (
<input
key={index}
ref={(el) => { inputRefs.current[index] = el }}
type="text"
inputMode="numeric"
maxLength={1}
value={digit}
onChange={(e) => handleCodeChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
onPaste={index === 0 ? handlePaste : undefined}
className="w-14 h-14 text-center text-2xl font-bold rounded-xl border-2 border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-200"
disabled={loading}
/>
))}
</div>
<CodeInput
value={code}
onChange={setCode}
onComplete={handleVerify}
disabled={loading}
/>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-xl text-sm text-center">
{error}
</div>
)}
{error && <ErrorMessage message={error} />}
<button
type="submit"
@@ -262,23 +209,12 @@ function VerifyCodeContent() {
</button>
<div className="pt-4 border-t border-gray-200 space-y-3">
<button
type="button"
onClick={handleResend}
disabled={!canResend || loading}
className={`w-full flex items-center justify-center gap-2 py-2.5 rounded-xl font-medium transition-all duration-200 ${
canResend && !loading
? 'bg-blue-500 hover:bg-blue-600 text-white shadow-md hover:shadow-lg'
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
>
<RotateCcw className="w-4 h-4" />
{canResend ? (
'ارسال مجدد کد'
) : (
`ارسال مجدد (${Math.floor(resendCooldown / 60)}:${String(resendCooldown % 60).padStart(2, '0')})`
)}
</button>
<ResendButton
canResend={canResend}
cooldown={resendCooldown}
onResend={handleResend}
loading={loading}
/>
<Link
href="/login"