Compare commits

...

5 Commits

Author SHA1 Message Date
2b8cc37374 remove live tab
Some checks failed
Deploy MyApp on Same Server / build-and-deploy (push) Has been cancelled
2026-06-03 01:07:00 +03:30
da180e76ab fix ui and add powerOutage list
Some checks failed
Deploy MyApp on Same Server / build-and-deploy (push) Has been cancelled
2026-06-03 00:46:37 +03:30
9ec7187e07 fix: prevent number input reset to 0 when clearing value in rule form
Some checks failed
Deploy MyApp on Same Server / build-and-deploy (push) Has been cancelled
Avoid converting empty string to 0 in value1 and value2 number inputs, which caused unexpected behavior in some browsers when users cleared the field.
2026-05-31 22:38:38 +03:30
9da69c96b1 refactor(alert-settings): integrate AppShell layout and update cache version
Some checks failed
Deploy MyApp on Same Server / build-and-deploy (push) Has been cancelled
- Replace custom page layout with AppShell component for consistent UI
- Remove unused PageHeader and UpdateAlertConditionDto imports
- Update service worker cache names and version.json to new timestamp
2026-05-11 22:19:30 +03:30
676f1373c1 fix loading issue and api error on empty response 2026-05-11 21:21:21 +03:30
65 changed files with 1768 additions and 426 deletions

View File

@@ -0,0 +1,282 @@
# Plan: Bottom Navigation Bar & Global Header Restructure
## Overview
Restructure the GreenHomeUI app to have an Android-style layout with:
- A **persistent Bottom Navigation Bar** (4 tabs: Profile, Alerts, Report, Weather)
- A **global Top Header** showing device title, page title with icon, and page info
- Applied to all authenticated pages (after device selection)
---
## Current Architecture
```
App Flow:
/ (login) → /verify-code → /devices → /daily-report?deviceId=X&date=Y
├── /alert-settings?deviceId=X
├── /calendar?deviceId=X
├── /day-details?deviceId=X&year=Y&month=Z
└── /device-settings?deviceId=X
```
Each page currently has its own `PageHeader` and manages its own layout independently. There is no shared shell/layout for authenticated pages.
---
## Target Architecture
```
Authenticated Shell (Layout)
┌──────────────────────────────────┐
│ Top Header │
│ [Device Name] [Page Icon+Title]│
│ [Page-specific info] │
├──────────────────────────────────┤
│ │
│ Page Content │
│ (children) │
│ │
├──────────────────────────────────┤
│ [Profile] [Alerts] [Report] [W] │
│ Bottom Navigation Bar │
└──────────────────────────────────┘
```
### Route Mapping
| Bottom Nav Tab | Route | Page Component | Icon |
|---|---|---|---|
| Profile | `/profile?deviceId=X` | New `src/app/profile/page.tsx` | `User` |
| Alerts | `/alert-settings?deviceId=X` | Existing (refactored) | `Bell` |
| Report | `/daily-report?deviceId=X&date=Y` | Existing (refactored) | `BarChart3` |
| Weather | `/weather?deviceId=X` | New `src/app/weather/page.tsx` | `CloudSun` |
### Pages OUTSIDE the shell (no bottom nav/header):
- `/` (login page)
- `/login`
- `/verify-code`
- `/devices` (device selection - this is the gateway before entering the shell)
### Pages INSIDE the shell (with bottom nav + header):
- `/daily-report` (Report tab)
- `/alert-settings` (Alerts tab)
- `/profile` (Profile tab - NEW)
- `/weather` (Weather tab - NEW)
- `/calendar` (calendar navigation)
- `/day-details` (day detail view)
- `/device-settings` (device configuration)
---
## Component Architecture
### 1. [`src/components/layout/AppShell.tsx`](src/components/layout/AppShell.tsx) — NEW
The main layout wrapper for authenticated pages. Provides:
- Top header bar
- Bottom navigation bar
- Main content area (children)
### 2. [`src/components/layout/TopHeader.tsx`](src/components/layout/TopHeader.tsx) — NEW
Global top header component showing:
- **Left side**: Device name (from context/query param)
- **Center/Right**: Page title with icon
- **Optional**: Page-specific info (e.g., selected date for daily-report)
Props:
```typescript
type TopHeaderProps = {
deviceName: string
pageTitle: string
pageIcon: LucideIcon
pageInfo?: string // e.g., "۱۴۰۴/۰۲/۲۱" for date
iconGradient?: string
}
```
### 3. [`src/components/layout/BottomNav.tsx`](src/components/layout/BottomNav.tsx) — NEW
Bottom navigation bar with 4 tabs:
- Uses `lucide-react` icons
- Highlights active tab based on current route
- Fixed at bottom, safe-area aware for notched devices
- Each tab navigates via `next/navigation` `useRouter`
### 4. [`src/components/layout/DeviceProvider.tsx`](src/components/layout/DeviceProvider.tsx) — NEW
React Context provider that stores the currently selected `deviceId` and `deviceName` so all pages within the shell can access them without passing through query params everywhere.
### 5. [`src/app/profile/page.tsx`](src/app/profile/page.tsx) — NEW
User profile page showing:
- User name, family, mobile
- Logout button
- Link to device settings
### 6. [`src/app/weather/page.tsx`](src/app/weather/page.tsx) — NEW
Dedicated weather page (extracted from the weather tab inside daily-report):
- Shows forecast weather data
- Reuses existing [`WeatherTab`](src/components/daily-report/WeatherTab.tsx) or creates a standalone version
---
## Data Flow
```
User logs in → /devices (selects device)
→ Stores deviceId + deviceName in DeviceProvider context
→ Redirects to /daily-report?deviceId=X&date=today
→ AppShell renders with TopHeader + BottomNav + page content
→ BottomNav tab changes update the route
→ DeviceProvider persists across tab switches
```
### DeviceProvider Context
```typescript
type DeviceContextType = {
deviceId: number
deviceName: string
setDevice: (id: number, name: string) => void
}
```
The provider wraps all authenticated pages. When a user selects a device on `/devices`, it sets the context. The `AppShell` reads from this context to display the device name in the header.
---
## File Changes Summary
### New Files to Create
| File | Purpose |
|---|---|
| [`src/components/layout/AppShell.tsx`](src/components/layout/AppShell.tsx) | Main layout shell with header + bottom nav |
| [`src/components/layout/TopHeader.tsx`](src/components/layout/TopHeader.tsx) | Global top header component |
| [`src/components/layout/BottomNav.tsx`](src/components/layout/BottomNav.tsx) | Bottom navigation bar |
| [`src/components/layout/DeviceProvider.tsx`](src/components/layout/DeviceProvider.tsx) | Device context provider |
| [`src/components/layout/index.ts`](src/components/layout/index.ts) | Barrel exports |
| [`src/app/profile/page.tsx`](src/app/profile/page.tsx) | Profile page |
| [`src/app/weather/page.tsx`](src/app/weather/page.tsx) | Weather page |
### Files to Modify
| File | Changes |
|---|---|
| [`src/app/layout.tsx`](src/app/layout.tsx) | Wrap children with `DeviceProvider` for authenticated routes |
| [`src/app/daily-report/page.tsx`](src/app/daily-report/page.tsx) | Remove standalone `PageHeader`, use `AppShell` instead. Remove `BackLink`. |
| [`src/app/alert-settings/page.tsx`](src/app/alert-settings/page.tsx) | Remove standalone `PageHeader`, use `AppShell`. Remove `BackLink`. |
| [`src/app/calendar/page.tsx`](src/app/calendar/page.tsx) | Remove standalone `PageHeader`, use `AppShell`. Remove `BackLink`. |
| [`src/app/day-details/page.tsx`](src/app/day-details/page.tsx) | Remove standalone `PageHeader`, use `AppShell`. Remove `BackLink`. |
| [`src/app/device-settings/page.tsx`](src/app/device-settings/page.tsx) | Remove standalone `PageHeader`, use `AppShell`. Remove `BackLink`. |
| [`src/app/devices/page.tsx`](src/app/devices/page.tsx) | After device selection, set `DeviceProvider` context before redirecting |
| [`src/app/globals.css`](src/app/globals.css) | Add styles for bottom nav (safe-area padding, fixed positioning) |
---
## Implementation Steps (Ordered)
### Step 1: Create Layout Components
- Create [`DeviceProvider`](src/components/layout/DeviceProvider.tsx) context
- Create [`TopHeader`](src/components/layout/TopHeader.tsx) component
- Create [`BottomNav`](src/components/layout/BottomNav.tsx) component
- Create [`AppShell`](src/components/layout/AppShell.tsx) that composes them
- Create barrel export [`index.ts`](src/components/layout/index.ts)
### Step 2: Update Root Layout
- Modify [`src/app/layout.tsx`](src/app/layout.tsx) to wrap with `DeviceProvider`
- Add bottom nav CSS to [`src/app/globals.css`](src/app/globals.css)
### Step 3: Create New Pages
- Create [`src/app/profile/page.tsx`](src/app/profile/page.tsx) — user info + logout
- Create [`src/app/weather/page.tsx`](src/app/weather/page.tsx) — dedicated weather page
### Step 4: Refactor Existing Pages to Use AppShell
- [`src/app/daily-report/page.tsx`](src/app/daily-report/page.tsx) — remove standalone PageHeader, wrap content in AppShell
- [`src/app/alert-settings/page.tsx`](src/app/alert-settings/page.tsx) — same
- [`src/app/calendar/page.tsx`](src/app/calendar/page.tsx) — same
- [`src/app/day-details/page.tsx`](src/app/day-details/page.tsx) — same
- [`src/app/device-settings/page.tsx`](src/app/device-settings/page.tsx) — same
### Step 5: Update Device Selection Flow
- Modify [`src/app/devices/page.tsx`](src/app/devices/page.tsx) to set device context on selection
- Ensure single-device auto-redirect also sets context
### Step 6: Polish & Testing
- Ensure bottom nav highlights correct tab based on route
- Ensure safe-area padding for notched phones
- Test navigation between all tabs
- Verify header shows correct device name and page info
---
## Mermaid Diagram: Component Tree
```mermaid
flowchart TD
RootLayout[RootLayout] --> DeviceProvider[DeviceProvider]
DeviceProvider --> AuthPages[Auth Pages Group]
subgraph AuthPages[Authenticated Pages]
AppShell[AppShell] --> TopHeader[TopHeader]
AppShell --> PageContent[Page Content]
AppShell --> BottomNav[BottomNav]
PageContent --> DailyReport[/daily-report]
PageContent --> AlertSettings[/alert-settings]
PageContent --> Profile[/profile]
PageContent --> Weather[/weather]
PageContent --> Calendar[/calendar]
PageContent --> DayDetails[/day-details]
PageContent --> DeviceSettings[/device-settings]
end
NonAuth[Non-Auth Pages] -.-> Login[/login]
NonAuth -.-> VerifyCode[/verify-code]
NonAuth -.-> Devices[/devices]
Devices -.->|select device| AppShell
```
## Mermaid Diagram: Navigation Flow
```mermaid
flowchart LR
Login[/login] --> Verify[/verify-code]
Verify --> Devices[/devices]
Devices -->|select device + set context| DailyReport[/daily-report<br/>Tab: Report]
DailyReport -->|BottomNav: Alerts| AlertSettings[/alert-settings<br/>Tab: Alerts]
DailyReport -->|BottomNav: Profile| Profile[/profile<br/>Tab: Profile]
DailyReport -->|BottomNav: Weather| Weather[/weather<br/>Tab: Weather]
AlertSettings -->|BottomNav: Report| DailyReport
AlertSettings -->|BottomNav: Profile| Profile
AlertSettings -->|BottomNav: Weather| Weather
Profile -->|BottomNav: Report| DailyReport
Profile -->|BottomNav: Alerts| AlertSettings
Profile -->|BottomNav: Weather| Weather
Weather -->|BottomNav: Report| DailyReport
Weather -->|BottomNav: Alerts| AlertSettings
Weather -->|BottomNav: Profile| Profile
DailyReport -->|internal link| Calendar[/calendar]
Calendar -->|select day| DayDetails[/day-details]
DayDetails -->|select date| DailyReport
DailyReport -->|settings icon| DeviceSettings[/device-settings]
Profile -->|settings link| DeviceSettings
```
---
## Key Design Decisions
1. **DeviceProvider Context**: Rather than passing `deviceId` as a query param on every navigation, we use React Context. This simplifies all page components and makes the header always aware of the current device.
2. **AppShell as a wrapper**: Each page inside the shell imports `AppShell` and wraps its content. The shell provides the header and bottom nav. This keeps pages clean and focused on their content.
3. **BottomNav uses route matching**: The active tab is determined by matching `usePathname()` against known routes. This is simpler than managing state across pages.
4. **Safe-area handling**: The bottom nav uses `env(safe-area-inset-bottom)` CSS to avoid overlapping with system navigation bars on notched devices.
5. **Existing PageHeader component**: The existing `PageHeader` component can still be used inside page content if needed (e.g., for sub-headers within a page), but the main header is now provided by `AppShell`.

View File

@@ -1,5 +1,5 @@
const CACHE_NAME = 'greenhome-1766182760520';
const STATIC_CACHE_NAME = 'greenhome-static-1766182760520';
const CACHE_NAME = 'greenhome-1780331120215';
const STATIC_CACHE_NAME = 'greenhome-static-1780331120215';
// Static assets to cache on install
const STATIC_FILES_TO_CACHE = [

View File

@@ -1,3 +1,3 @@
{
"version": "1766182760520"
"version": "1780331120215"
}

View File

@@ -1,13 +1,14 @@
"use client"
import { useEffect, useState, useCallback, Suspense } from 'react'
import { useSearchParams } from 'next/navigation'
import { api, AlertConditionDto, UpdateAlertConditionDto, CreateAlertConditionDto, CreateAlertRuleRequest } from '@/lib/api'
import { api, AlertConditionDto, CreateAlertConditionDto, CreateAlertRuleRequest } from '@/lib/api'
import { Bell, Plus, AlertTriangle } from 'lucide-react'
import Loading from '@/components/Loading'
import { PageHeader, EmptyState } from '@/components/common'
import { EmptyState } from '@/components/common'
import { confirmDialog } from '@/components/utils'
import { AlertListItem } from '@/components/alert-settings/AlertListItem'
import { AlertFormModal } from '@/components/alert-settings/AlertFormModal'
import { AppShell } from '@/components/layout'
function AlertSettingsContent() {
const searchParams = useSearchParams()
@@ -102,39 +103,36 @@ function AlertSettingsContent() {
if (loading) return <Loading message="در حال بارگذاری تنظیمات هشدار..." />
return (
<div className="min-h-screen p-4 md:p-6 bg-gray-50">
<div className="max-w-7xl mx-auto space-y-6">
<PageHeader
icon={Bell}
title="تنظیمات هشدارها"
subtitle="مدیریت شرایط و هشدارهای دستگاه"
iconGradient="from-orange-500 to-red-600"
action={
<button onClick={openCreate}
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">
<Plus className="w-5 h-5" /> افزودن هشدار
</button>
}
/>
<div className="bg-white rounded-2xl shadow-md border border-gray-200 overflow-hidden">
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">لیست هشدارها ({alerts.length})</h2>
</div>
{alerts.length === 0 ? (
<EmptyState icon={AlertTriangle} message="هیچ هشداری ثبت نشده است"
action={<button onClick={openCreate} 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 => (
<AlertListItem key={alert.id} alert={alert} onEdit={openEdit} onDelete={handleDelete} onToggleActive={toggleActive} />
))}
<>
<AppShell
pageTitle="تنظیمات هشدارها"
pageIcon={Bell}
iconGradient="from-orange-500 to-red-600"
>
<div className="space-y-6">
<div className="bg-white rounded-2xl shadow-md border border-gray-200 overflow-hidden">
<div className="p-6 border-b border-gray-200 flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-900">لیست هشدارها ({alerts.length})</h2>
<button onClick={openCreate}
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">
<Plus className="w-5 h-5" /> افزودن هشدار
</button>
</div>
)}
{alerts.length === 0 ? (
<EmptyState icon={AlertTriangle} message="هیچ هشداری ثبت نشده است"
action={<button onClick={openCreate} 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 => (
<AlertListItem key={alert.id} alert={alert} onEdit={openEdit} onDelete={handleDelete} onToggleActive={toggleActive} />
))}
</div>
)}
</div>
</div>
</div>
</AppShell>
<AlertFormModal
isOpen={modal.open}
@@ -148,7 +146,7 @@ function AlertSettingsContent() {
onRemoveRule={removeRule}
onUpdateRule={updateRule}
/>
</div>
</>
)
}

View File

@@ -1,33 +1,22 @@
"use client"
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Suspense, useEffect, useMemo, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { api } from '@/lib/api'
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 { Card } from '@/components/common'
import { YearSelector } from '@/components/calendar'
import { MonthCard } from '@/components/cards'
import { StatsCard } from '@/components/cards'
import { AppShell } from '@/components/layout'
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
function useQueryParam(name: string) {
const [value, setValue] = useState<string | null>(null)
useEffect(() => {
if (typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search)
setValue(params.get(name))
}
}, [name])
return value
}
export default function CalendarPage() {
function CalendarContent() {
const router = useRouter()
const deviceIdParam = useQueryParam('deviceId')
const searchParams = useSearchParams()
const deviceIdParam = searchParams.get('deviceId')
const [deviceId, setDeviceId] = useState<number>(1)
const [year, setYear] = useState<number>(getCurrentPersianYear())
const [activeMonths, setActiveMonths] = useState<number[]>([])
@@ -75,16 +64,12 @@ export default function CalendarPage() {
}
return (
<div className="min-h-screen p-4 md:p-6">
<AppShell
pageTitle="انتخاب سال و ماه"
pageIcon={CalendarIcon}
iconGradient="from-green-500 to-green-600"
>
<div className="max-w-6xl mx-auto">
{/* Header */}
<BackLink href="/" label="بازگشت به صفحه اصلی" />
<PageHeader
icon={CalendarIcon}
title="انتخاب سال و ماه"
iconGradient="from-green-500 to-green-600"
/>
{/* Main Card */}
<Card padding="lg">
{/* Year Selector */}
@@ -130,6 +115,14 @@ export default function CalendarPage() {
</div>
</Card>
</div>
</div>
</AppShell>
)
}
export default function CalendarPage() {
return (
<Suspense fallback={<Loading message="در حال بارگذاری تقویم..." />}>
<CalendarContent />
</Suspense>
)
}

View File

@@ -1,19 +1,19 @@
"use client"
import { useEffect, useMemo, useState, useCallback, Suspense, lazy } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { api, TelemetryDto } from '@/lib/api'
import { api, TelemetryDto, PowerOutageSummaryDto } 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 { BarChart3, CalendarIcon, ChevronRight } from 'lucide-react'
import Loading from '@/components/Loading'
import { SummaryTab } from '@/components/daily-report'
import { fetchForecastWeather, isToday as checkIsToday, WeatherData } from '@/features/weather'
import { Tabs, PageHeader, Button } from '@/components/common'
import { Tabs, Button } from '@/components/common'
import { DateNavigation } from '@/components/navigation'
import { usePullToRefresh } from '@/hooks/usePullToRefresh'
import { AppShell } from '@/components/layout'
// Lazy load heavy components
const ChartsTab = lazy(() => import('@/components/daily-report/ChartsTab').then(m => ({ default: m.ChartsTab })))
@@ -29,6 +29,8 @@ function DailyReportContent() {
const [forecastWeather, setForecastWeather] = useState<WeatherData | null>(null)
const [forecastWeatherLoading, setForecastWeatherLoading] = useState(false)
const [visibleParams, setVisibleParams] = useState<string[] | null>(null)
const [powerOutageSummary, setPowerOutageSummary] = useState<PowerOutageSummaryDto | null>(null)
const [powerOutageLoading, setPowerOutageLoading] = useState(false)
// Map summary param to chart key
const paramToChartKey = useCallback((param: string): string => {
@@ -131,6 +133,38 @@ function DailyReportContent() {
}
}, [deviceId, selectedDate])
// Load power outage summary
useEffect(() => {
if (!selectedDate) {
setPowerOutageSummary(null)
return
}
const loadPowerOutage = async () => {
setPowerOutageLoading(true)
try {
const [year, month, day] = selectedDate.split('/').map(Number)
const startDate = persianToGregorian(year, month, day)
startDate.setHours(0, 0, 0, 0)
const endDate = new Date(startDate)
endDate.setHours(23, 59, 59, 999)
const startUtc = startDate.toISOString()
const endUtc = endDate.toISOString()
const summary = await api.getPowerOutageSummary(deviceId, startUtc, endUtc)
setPowerOutageSummary(summary)
} catch (error) {
console.error('Error loading power outage summary:', error)
setPowerOutageSummary(null)
} finally {
setPowerOutageLoading(false)
}
}
loadPowerOutage()
}, [deviceId, selectedDate])
// Pull-to-refresh for mobile
usePullToRefresh(loadData)
@@ -189,6 +223,27 @@ function DailyReportContent() {
const gas = useMemo(() => sortedTelemetry.map(t => Number(t.gasPPM ?? 0)), [sortedTelemetry])
const lux = useMemo(() => sortedTelemetry.map(t => Number(t.lux ?? 0)), [sortedTelemetry])
// Latest values for live summary
const latestValues = useMemo(() => ({
temperature: temp.length > 0 ? temp.at(-1) ?? 0 : 0,
humidity: hum.length > 0 ? hum.at(-1) ?? 0 : 0,
soil: soil.length > 0 ? soil.at(-1) ?? 0 : 0,
gas: gas.length > 0 ? gas.at(-1) ?? 0 : 0,
lux: lux.length > 0 ? lux.at(-1) ?? 0 : 0,
}), [temp, hum, soil, gas, lux])
// Last updated time from the most recent telemetry entry
const lastUpdated = useMemo(() => {
if (sortedTelemetry.length === 0) return undefined
const lastTimestamp = sortedTelemetry.at(-1)?.serverTimestampUtc || sortedTelemetry.at(-1)?.timestampUtc
if (!lastTimestamp) return undefined
const date = new Date(lastTimestamp)
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${hours}:${minutes}:${seconds}`
}, [sortedTelemetry])
// Detect data gaps in the full day data
const dataGaps = useMemo(() => {
const timestamps = sortedTelemetry.map(t => t.serverTimestampUtc || t.timestampUtc)
@@ -201,50 +256,43 @@ function DailyReportContent() {
if (!selectedDate) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<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
onClick={goToCalendar}
variant="outline"
icon={ChevronRight}
>
بازگشت به تقویم
</Button>
<AppShell
pageTitle="گزارش روزانه"
pageIcon={BarChart3}
iconGradient="from-indigo-500 to-purple-600"
>
<div className="flex items-center justify-center p-4">
<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
onClick={goToCalendar}
variant="outline"
icon={ChevronRight}
>
بازگشت به تقویم
</Button>
</div>
</div>
</div>
</AppShell>
)
}
return (
<div className="min-h-screen p-4 md:p-6">
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<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"
>
<Bell className="w-5 h-5" />
<span className="hidden sm:inline">تنظیمات هشدار</span>
</Link>
}
/>
<AppShell
pageTitle="گزارش روزانه"
pageIcon={BarChart3}
pageInfo={selectedDate}
iconGradient="from-indigo-500 to-purple-600"
>
<div className="space-y-6">
{/* Date Navigation Buttons */}
{selectedDate && (
<DateNavigation
selectedDate={selectedDate}
onPrevious={goToPreviousDay}
onNext={goToNextDay}
onCalendar={goToCalendar}
/>
)}
<DateNavigation
selectedDate={selectedDate}
onPrevious={goToPreviousDay}
onNext={goToNextDay}
onCalendar={goToCalendar}
/>
{/* Tabs */}
<Tabs
@@ -254,7 +302,7 @@ function DailyReportContent() {
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} onCardClick={handleCardClick} visibleParams={visibleParams} />,
summary: <SummaryTab temperature={temp} humidity={hum} soil={soil} gas={gas} lux={lux} forecastWeather={forecastWeather} forecastWeatherLoading={forecastWeatherLoading} onCardClick={handleCardClick} visibleParams={visibleParams} powerOutageSummary={powerOutageSummary} powerOutageLoading={powerOutageLoading} />,
charts: (
<Suspense fallback={<Loading message="در حال بارگذاری نمودارها..." />}>
<ChartsTab sortedTelemetry={sortedTelemetry} dataGaps={dataGaps} visibleParams={visibleParams} />
@@ -273,7 +321,7 @@ function DailyReportContent() {
}}
</Tabs>
</div>
</div>
</AppShell>
)
}

View File

@@ -5,9 +5,10 @@ import { api } from '@/lib/api'
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 { Card } from '@/components/common'
import { WeekdayHeaders } from '@/components/calendar'
import { CalendarDayCell, StatsCard } from '@/components/cards'
import { AppShell } from '@/components/layout'
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
@@ -71,16 +72,12 @@ function DayDetailsContent() {
}
return (
<div className="min-h-screen p-4 md:p-6">
<AppShell
pageTitle={`${monthNames[month - 1]} ${year}`}
pageIcon={CalendarIcon}
iconGradient="from-green-500 to-green-600"
>
<div className="max-w-6xl mx-auto">
{/* Header */}
<BackLink href={`/calendar?deviceId=${deviceId}`} label="بازگشت به تقویم" />
<PageHeader
icon={CalendarIcon}
title={`${monthNames[month - 1]} ${year}`}
iconGradient="from-green-500 to-green-600"
/>
{/* Calendar Grid */}
<Card className="overflow-hidden" padding="none">
{/* Weekday Headers */}
@@ -138,7 +135,7 @@ function DayDetailsContent() {
/>
</div>
</div>
</div>
</AppShell>
)
}

View File

@@ -1,11 +1,11 @@
"use client"
import { useEffect, useState, useCallback } from 'react'
import { api, DeviceSettingsDto } from '@/lib/api'
import { Settings, Thermometer, Wind, Sun, Droplets, Save, Loader2, AlertCircle, Bell } from 'lucide-react'
import Link from 'next/link'
import { Settings, Thermometer, Wind, Sun, Droplets, Save, Loader2, AlertCircle } from 'lucide-react'
import Loading from '@/components/Loading'
import { PageHeader, BackLink, ErrorMessage, SuccessMessage, EmptyState, Card } from '@/components/common'
import { ErrorMessage, SuccessMessage, EmptyState, Card } from '@/components/common'
import { SettingsSection, SettingsInputGroup } from '@/components/settings'
import { AppShell } from '@/components/layout'
function useQueryParam(name: string) {
if (typeof window === 'undefined') return null as string | null
@@ -95,36 +95,25 @@ export default function DeviceSettingsPage() {
if (!deviceId) {
return (
<ErrorMessage
message="شناسه دستگاه مشخص نشده است"
fullPage
action={
<BackLink href="/devices" label="بازگشت به انتخاب دستگاه" />
}
/>
<AppShell
pageTitle="تنظیمات دستگاه"
pageIcon={Settings}
iconGradient="from-blue-500 to-blue-600"
>
<ErrorMessage
message="شناسه دستگاه مشخص نشده است"
/>
</AppShell>
)
}
return (
<div className="min-h-screen p-4 md:p-6">
<AppShell
pageTitle={`تنظیمات ${deviceName}`}
pageIcon={Settings}
iconGradient="from-blue-500 to-blue-600"
>
<div className="max-w-5xl mx-auto">
{/* Header */}
<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"
>
<Bell className="w-5 h-5" />
<span className="hidden sm:inline">تنظیمات هشدار</span>
</Link>
}
/>
{/* Messages */}
{error && <ErrorMessage message={error} className="mb-6" />}
{success && <SuccessMessage message={success} className="mb-6" />}
@@ -252,7 +241,7 @@ export default function DeviceSettingsPage() {
</Card>
)}
</div>
</div>
</AppShell>
)
}

View File

@@ -5,13 +5,15 @@ import { api, DeviceDto, PagedResult } from '@/lib/api'
import { Settings, LogOut } from 'lucide-react'
import Loading from '@/components/Loading'
import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/date/persian-date'
import { PageHeader, ErrorMessage, EmptyState, BackLink } from '@/components/common'
import { ErrorMessage, EmptyState } from '@/components/common'
import { SearchInput } from '@/components/forms'
import { DeviceCard } from '@/components/cards'
import { Pagination } from '@/components/navigation'
import { useDevice } from '@/components/layout'
export default function DevicesPage() {
const router = useRouter()
const { setDevice } = useDevice()
const [pagedResult, setPagedResult] = useState<PagedResult<DeviceDto> | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -22,6 +24,12 @@ export default function DevicesPage() {
const pageSize = 10
const handleDeviceSelect = useCallback((device: DeviceDto) => {
setDevice(device.id, device.deviceName)
const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}`
router.push(`/daily-report?deviceId=${device.id}&date=${encodeURIComponent(today)}`)
}, [setDevice, router])
const fetchDevices = useCallback(async (page: number, search?: string) => {
if (!user) return
@@ -39,9 +47,8 @@ export default function DevicesPage() {
if (result.items.length === 0 && page === 1) {
setError('شما هیچ دستگاهی ندارید. لطفاً با پشتیبانی تماس بگیرید.')
} else if (result.items.length === 1 && result.totalCount === 1 && !search) {
// Single device - redirect to today's daily report
const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}`
router.push(`/daily-report?deviceId=${result.items[0].id}&date=${encodeURIComponent(today)}`)
// Single device - set context and redirect to today's daily report
handleDeviceSelect(result.items[0])
return
} else {
setPagedResult(result)
@@ -52,7 +59,7 @@ export default function DevicesPage() {
} finally {
setLoading(false)
}
}, [user, router])
}, [user, router, handleDeviceSelect])
useEffect(() => {
// Check authentication
@@ -120,21 +127,34 @@ export default function DevicesPage() {
<div className="min-h-screen p-4 md:p-8">
<div className="max-w-6xl mx-auto">
{/* Header */}
<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"
/>
<div className="mb-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<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">
<Settings className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
انتخاب دستگاه
</h1>
{user && (
<p className="text-sm text-gray-500 mt-1">
{user.name} {user.family} ({user.mobile})
</p>
)}
</div>
</div>
<div className="flex-shrink-0">
<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>
</div>
</div>
{/* Search */}
<div className="mb-6">
@@ -174,16 +194,13 @@ export default function DevicesPage() {
{pagedResult && pagedResult.items.length > 0 ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
{pagedResult.items.map((device) => {
const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}`
return (
<DeviceCard
key={device.id}
device={device}
href={`/daily-report?deviceId=${device.id}&date=${encodeURIComponent(today)}`}
/>
)
})}
{pagedResult.items.map((device) => (
<DeviceCard
key={device.id}
device={device}
onClick={() => handleDeviceSelect(device)}
/>
))}
</div>
{/* Pagination */}
@@ -203,11 +220,6 @@ export default function DevicesPage() {
/>
)
)}
{/* Back to Home */}
<div className="mt-6 text-center">
<BackLink href="/" label="بازگشت به صفحه اصلی" />
</div>
</div>
</div>
)

View File

@@ -1,12 +1,10 @@
@import "tailwindcss";
:root {
/* --background: #ffffff; */
--foreground: #171717;
}
@theme inline {
/* --color-background: var(--background); */
--color-foreground: var(--foreground);
--font-sans: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
--font-mono: 'Vazirmatn', monospace;
@@ -14,7 +12,6 @@
@media (prefers-color-scheme: dark) {
:root {
/* --background: #0a0a0a; */
--foreground: #ededed;
}
}
@@ -31,12 +28,11 @@ html {
}
body {
/* background: var(--background); */
background: linear-gradient(180deg, #eef7ff, #f6fbff);
background: #f8fafc;
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 */
overscroll-behavior-y: contain;
-webkit-overflow-scrolling: touch;
}
@@ -44,7 +40,7 @@ body {
button, a, [role="button"] {
min-height: 44px;
min-width: 44px;
touch-action: manipulation; /* Disable double-tap zoom */
touch-action: manipulation;
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
@@ -56,7 +52,7 @@ button, a, [role="button"] {
.transition-transform,
.transition-opacity {
will-change: transform, opacity;
transform: translateZ(0); /* Force GPU acceleration */
transform: translateZ(0);
}
.persian-number {
@@ -157,3 +153,23 @@ button, a, [role="button"] {
background-size: 2000px 100%;
animation: shimmer 2s infinite;
}
/* Bottom Navigation Safe Area */
@supports (padding-bottom: env(safe-area-inset-bottom)) {
.pb-safe {
padding-bottom: env(safe-area-inset-bottom);
}
.h-safe-bottom {
height: env(safe-area-inset-bottom);
}
}
/* Prevent content from being hidden behind bottom nav */
.has-bottom-nav {
padding-bottom: 5rem;
}
@media (max-width: 768px) {
.has-bottom-nav {
padding-bottom: 4.5rem;
}
}

View File

@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
import './globals.css'
import ServiceWorkerRegistration from '@/components/ServiceWorkerRegistration'
import UpdateNotification from '@/components/UpdateNotification'
import { DeviceProvider } from '@/components/layout'
export const metadata: Metadata = {
title: 'GreenHome',
@@ -42,7 +43,9 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
<body className='persian-number'>
<ServiceWorkerRegistration />
<UpdateNotification />
{children}
<DeviceProvider>
{children}
</DeviceProvider>
</body>
</html>
)

View File

@@ -46,6 +46,7 @@ export default function LoginPage() {
if (result.success) {
// Redirect to verify page with mobile number
router.push(`/verify-code?mobile=${encodeURIComponent(normalizedMobile)}`)
return // Don't set loading to false — component will unmount
} else {
setError(result.message || 'خطا در ارسال کد')
}

View File

@@ -6,6 +6,7 @@ import { Smartphone, ArrowLeft } from 'lucide-react'
import Loading from '@/components/Loading'
import { MobileInput } from '@/components/forms'
import { ErrorMessage } from '@/components/common'
import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/date/persian-date'
export default function Home() {
const router = useRouter()
@@ -20,9 +21,35 @@ export default function Home() {
if (token && userStr) {
try {
JSON.parse(userStr)
// Redirect to devices check
router.push('/devices')
const user = JSON.parse(userStr)
// Try to use the saved device from localStorage first
const savedDeviceId = localStorage.getItem('selectedDeviceId')
if (savedDeviceId) {
const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}`
router.push(`/daily-report?deviceId=${savedDeviceId}&date=${encodeURIComponent(today)}`)
return
}
// No saved device — fetch user devices and auto-select the first one
api.getUserDevices(user.id)
.then((devices) => {
if (devices.length > 0) {
const firstDevice = devices[0]
// Save to localStorage
localStorage.setItem('selectedDeviceId', String(firstDevice.id))
localStorage.setItem('selectedDeviceName', firstDevice.deviceName)
const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}`
router.push(`/daily-report?deviceId=${firstDevice.id}&date=${encodeURIComponent(today)}`)
} else {
// No devices — go to devices page to show the error
router.push('/devices')
}
})
.catch(() => {
// On error, fall back to devices page
router.push('/devices')
})
} catch {
// Invalid user data, go to login
}
@@ -62,6 +89,7 @@ export default function Home() {
if (result.success) {
// Redirect to verify page with mobile number
router.push(`/verify-code?mobile=${encodeURIComponent(normalizedMobile)}`)
return // Don't set loading to false — component will unmount
} else {
setError(result.message || 'خطا در ارسال کد')
}

138
src/app/profile/page.tsx Normal file
View File

@@ -0,0 +1,138 @@
"use client"
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { User, LogOut, Settings, ChevronLeft, Smartphone, Shield, Monitor } from 'lucide-react'
import { AppShell, useDevice } from '@/components/layout'
import { Card } from '@/components/common'
type UserData = {
id: number
mobile: string
name: string
family: string
role: number
}
export default function ProfilePage() {
const router = useRouter()
const { deviceId } = useDevice()
const [user, setUser] = useState<UserData | null>(null)
useEffect(() => {
const userStr = localStorage.getItem('user')
if (userStr) {
try {
setUser(JSON.parse(userStr))
} catch {
// Invalid user data
}
}
}, [])
const handleLogout = () => {
localStorage.removeItem('authToken')
localStorage.removeItem('user')
router.push('/')
}
const handleDeviceSettings = () => {
router.push(`/device-settings?deviceId=${deviceId}`)
}
const handleSelectDevice = () => {
router.push('/devices')
}
return (
<AppShell
pageTitle="پروفایل"
pageIcon={User}
iconGradient="from-green-500 to-emerald-600"
>
<div className="max-w-2xl mx-auto space-y-6">
{/* User Info Card */}
<Card padding="lg">
<div className="flex items-center gap-4 mb-6">
<div className="w-16 h-16 bg-gradient-to-br from-green-500 to-emerald-600 rounded-2xl flex items-center justify-center shadow-md">
<User className="w-8 h-8 text-white" />
</div>
<div>
<h2 className="text-xl font-bold text-gray-900">
{user ? `${user.name} ${user.family}` : 'کاربر'}
</h2>
<p className="text-sm text-gray-500 mt-1 flex items-center gap-1.5">
<Smartphone className="w-4 h-4" />
{user?.mobile || '---'}
</p>
</div>
</div>
<div className="border-t border-gray-100 pt-4 space-y-3">
<div className="flex items-center justify-between py-2">
<span className="text-sm text-gray-600">نقش کاربری</span>
<span className="text-sm font-medium text-gray-900 flex items-center gap-1.5">
<Shield className="w-4 h-4 text-green-600" />
{user?.role === 1 ? 'مدیر' : 'کاربر'}
</span>
</div>
</div>
</Card>
{/* Actions */}
<Card padding="lg">
<div className="space-y-2">
{/* Select Device — always visible so user can change device */}
<button
onClick={handleSelectDevice}
className="w-full flex items-center justify-between p-4 rounded-xl hover:bg-gray-50 transition-colors group"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center group-hover:scale-110 transition-transform">
<Monitor className="w-5 h-5 text-purple-600" />
</div>
<div className="text-right">
<p className="font-medium text-gray-900">انتخاب دستگاه</p>
<p className="text-xs text-gray-500">تغییر دستگاه فعال</p>
</div>
</div>
<ChevronLeft className="w-5 h-5 text-gray-400 group-hover:-translate-x-1 transition-transform" />
</button>
<button
onClick={handleDeviceSettings}
className="w-full flex items-center justify-between p-4 rounded-xl hover:bg-gray-50 transition-colors group"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center group-hover:scale-110 transition-transform">
<Settings className="w-5 h-5 text-blue-600" />
</div>
<div className="text-right">
<p className="font-medium text-gray-900">تنظیمات دستگاه</p>
<p className="text-xs text-gray-500">مدیریت محدوده‌های مجاز سنسورها</p>
</div>
</div>
<ChevronLeft className="w-5 h-5 text-gray-400 group-hover:-translate-x-1 transition-transform" />
</button>
<button
onClick={handleLogout}
className="w-full flex items-center justify-between p-4 rounded-xl hover:bg-red-50 transition-colors group"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center group-hover:scale-110 transition-transform">
<LogOut className="w-5 h-5 text-red-600" />
</div>
<div className="text-right">
<p className="font-medium text-red-600">خروج از حساب</p>
<p className="text-xs text-gray-500">خروج از حساب کاربری</p>
</div>
</div>
<ChevronLeft className="w-5 h-5 text-gray-400 group-hover:-translate-x-1 transition-transform" />
</button>
</div>
</Card>
</div>
</AppShell>
)
}

View File

@@ -93,14 +93,17 @@ function VerifyCodeContent() {
} else if (devicesResult.totalCount === 1) {
// Single device - redirect to calendar
router.push(`/calendar?deviceId=${devicesResult.items[0].id}`)
return // Don't set loading to false — component will unmount
} else {
// Multiple devices - redirect to device list
router.push('/devices')
return // Don't set loading to false — component will unmount
}
} catch (deviceError) {
console.error('Error fetching devices:', deviceError)
// Still redirect to devices page to handle error there
router.push('/devices')
return // Don't set loading to false — component will unmount
}
} else {
setError(result.message || 'کد وارد شده نادرست است')

104
src/app/weather/page.tsx Normal file
View File

@@ -0,0 +1,104 @@
"use client"
import { useState, useEffect, useCallback } from 'react'
import { CloudSun, RefreshCw, MapPin } from 'lucide-react'
import { AppShell, useDevice } from '@/components/layout'
import { WeatherData, fetchForecastWeather, fetchLocationName } from '@/features/weather'
import { QOM_LAT, QOM_LON } from '@/features/weather/helpers'
import { TodayWeather } from '@/components/daily-report/weather/TodayWeather'
import Loading from '@/components/Loading'
import { ErrorMessage } from '@/components/common'
import { Button } from '@/components/common/Button'
export default function WeatherPage() {
const { deviceName } = useDevice()
const [weatherData, setWeatherData] = useState<WeatherData | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [expandedDayIndex, setExpandedDayIndex] = useState<number | null>(null)
const [locationName, setLocationName] = useState<string>('در حال دریافت...')
const loadWeather = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [weather, location] = await Promise.all([
fetchForecastWeather(),
fetchLocationName(QOM_LAT, QOM_LON)
])
setWeatherData(weather)
setLocationName(location)
} catch (error) {
console.error('Error loading weather:', error)
setError('خطا در دریافت اطلاعات آب و هوا. لطفاً دوباره تلاش کنید.')
setLocationName('کهک قم، ایران')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadWeather()
}, [loadWeather])
const handleRefresh = () => {
setWeatherData(null)
loadWeather()
}
return (
<AppShell
pageTitle="آب و هوا"
pageIcon={CloudSun}
pageInfo={deviceName || undefined}
iconGradient="from-sky-500 to-cyan-600"
>
<div className="max-w-4xl mx-auto space-y-6">
{/* Location Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-gray-600">
<MapPin className="w-5 h-5 text-sky-500" />
<span className="font-medium">{locationName}</span>
</div>
<Button
onClick={handleRefresh}
variant="outline"
icon={RefreshCw}
disabled={loading}
>
بروزرسانی
</Button>
</div>
{loading && (
<Loading message="در حال دریافت اطلاعات آب و هوا..." fullScreen={false} />
)}
{error && (
<ErrorMessage
message={error}
action={
<Button
onClick={handleRefresh}
variant="primary"
icon={RefreshCw}
>
تلاش مجدد
</Button>
}
/>
)}
{weatherData && !loading && (
<TodayWeather
weatherData={weatherData}
expandedDayIndex={expandedDayIndex}
onDayToggle={setExpandedDayIndex}
/>
)}
</div>
</AppShell>
)
}

View File

@@ -64,7 +64,7 @@ type Props = {
export function Panel({ title, children, id }: { title: string; children: React.ReactNode; id?: string }) {
return (
<div id={id} className="bg-white rounded-xl border border-gray-200 shadow-md hover:shadow-lg transition-shadow duration-300 overflow-hidden">
<div id={id} className="bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-md transition-shadow duration-300 overflow-hidden">
<div className="bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200 px-5 py-3">
<h3 className="text-base font-semibold text-gray-900">{title}</h3>
</div>

View File

@@ -77,19 +77,19 @@ export function Card({ title, value, subtitle, icon, color }: CardProps) {
const hasStats = subtitle?.includes('حداکثر') && subtitle?.includes('حداقل')
return (
<div className={`group relative bg-white rounded-xl border-2 ${colors.border} shadow-md hover:shadow-lg transition-all duration-300 overflow-hidden`}>
<div className="p-5">
<div className="flex items-center justify-between mb-3">
<div className="text-xs font-medium text-gray-500 uppercase tracking-wide">
<div className={`bg-white rounded-lg border border-gray-200 hover:border-gray-300 transition-all duration-150 overflow-hidden`}>
<div className="p-4">
<div className="flex items-center justify-between mb-2">
<div className="text-xs font-medium text-gray-500">
{title.replace(/[💡🫁🌱💧🌡️📊]/g, '').trim()}
</div>
{IconComponent && (
<div className={`w-8 h-8 bg-gradient-to-br ${colors.iconBg} rounded-lg flex items-center justify-center shadow-sm`}>
<IconComponent className="w-4 h-4 text-white" />
<div className={`w-7 h-7 rounded-md ${colors.iconBg} flex items-center justify-center`}>
<IconComponent className="w-3.5 h-3.5 text-white" />
</div>
)}
</div>
<div className="text-3xl font-bold text-gray-900 mb-2">{value}</div>
<div className="text-2xl font-bold text-gray-900 mb-1">{value}</div>
{subtitle && (
<div className="text-xs text-gray-500 flex items-center gap-2 flex-wrap">
{hasStats ? (
@@ -109,7 +109,6 @@ export function Card({ title, value, subtitle, icon, color }: CardProps) {
</div>
)}
</div>
<div className={`absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r ${colors.gradient} transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300`} />
</div>
)
}

View File

@@ -53,7 +53,7 @@ export function Skeleton({ className, width, height, rounded = true }: SkeletonP
// Card skeleton for loading states
export function CardSkeleton() {
return (
<div className="bg-white rounded-xl border border-gray-200 shadow-md p-6 space-y-4">
<div className="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
<Skeleton height="1.5rem" width="60%" />
<Skeleton height="1rem" />
<Skeleton height="1rem" width="80%" />

View File

@@ -98,7 +98,7 @@ export default function UpdateNotification() {
return (
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-96 z-50 animate-in slide-in-from-bottom-5">
<div className="bg-white rounded-xl shadow-lg border-2 border-indigo-500 p-4 flex items-center gap-3">
<div className="bg-white rounded-lg shadow-md border border-indigo-500 p-4 flex items-center gap-3">
<div className="flex-shrink-0 w-10 h-10 bg-indigo-100 rounded-full flex items-center justify-center">
<RefreshCw className="w-5 h-5 text-indigo-600" />
</div>

View File

@@ -125,7 +125,7 @@ export function AlertFormModal({
</button>
<button
type="submit"
className="px-4 py-1.5 text-xs 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-sm hover:shadow disabled:opacity-50 disabled:cursor-not-allowed"
className="px-4 py-1.5 text-xs bg-orange-600 hover:bg-orange-700 text-white rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
disabled={saving}
>
{saving ? 'در حال ذخیره...' : editingAlert ? 'ذخیره تغییرات' : 'افزودن هشدار'}

View File

@@ -34,7 +34,7 @@ export function AlertListItem({ alert, onEdit, onDelete, onToggleActive }: Alert
</Badge>
<button
onClick={() => onToggleActive(alert)}
className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm font-medium transition-colors ${
className={`inline-flex items-center gap-1 px-3 py-1 rounded-md text-sm font-medium transition-colors ${
alert.isEnabled
? 'bg-green-100 text-green-800 hover:bg-green-200'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'

View File

@@ -1,6 +1,5 @@
'use client'
import { Bell } from 'lucide-react'
import { SensorType, ComparisonType, NotificationType, TimeType } from './constants'
import { generatePreviewText } from './utils'
@@ -12,14 +11,10 @@ type AlertPreviewProps = {
export function AlertPreview({ rules, notificationType, timeType }: AlertPreviewProps) {
return (
<div className="sticky top-[45px] z-10 bg-gradient-to-r from-blue-500 to-indigo-600 shadow-lg -mx-6 -mt-6">
<div className="sticky top-[45px] z-10 bg-blue-600 -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(rules, notificationType, timeType)}
</div>

View File

@@ -22,9 +22,9 @@ export function NotificationTypeSelector({ value, onChange }: NotificationTypeSe
key={notif.value}
type="button"
onClick={() => onChange(notif.value)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border transition-all text-xs ${
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border transition-all duration-150 text-xs ${
isSelected
? 'border-orange-500 bg-orange-50 text-orange-700 shadow-sm'
? 'border-orange-500 bg-orange-50 text-orange-700'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50'
}`}
>

View File

@@ -1,5 +1,6 @@
'use client'
import { useState, useCallback, useRef } from 'react'
import { Trash2 } from 'lucide-react'
import { SENSOR_TYPES, COMPARISON_TYPES, SensorType, ComparisonType } from './constants'
import { getSensorIcon, getSensorLabel, getSensorUnit, getComparisonLabel } from './utils'
@@ -13,16 +14,71 @@ type RuleFormCardProps = {
onRemove: (index: number) => void
}
/** Validate and sanitize a decimal number string with max 1 decimal place */
function sanitizeDecimal(input: string): string {
// Remove any non-digit, non-dot, non-minus characters
let cleaned = input.replace(/[^0-9.\-]/g, '')
// Allow only one minus sign at the start
const minusCount = (cleaned.match(/-/g) || []).length
if (minusCount > 1) {
cleaned = cleaned.replace(/-/g, '')
if (input.startsWith('-')) cleaned = '-' + cleaned
} else if (minusCount === 1 && !cleaned.startsWith('-')) {
cleaned = cleaned.replace(/-/g, '')
}
// Allow only one decimal point
const dotIndex = cleaned.indexOf('.')
if (dotIndex !== -1) {
const beforeDot = cleaned.slice(0, dotIndex + 1)
const afterDot = cleaned.slice(dotIndex + 1).replace(/\./g, '')
// Max 1 decimal place
cleaned = beforeDot + afterDot.slice(0, 1)
}
return cleaned
}
export function RuleFormCard({ rule, index, totalRules, onUpdate, onRemove }: RuleFormCardProps) {
const needsMax = COMPARISON_TYPES.find(c => c.value === rule.comparisonType)?.needsMax
const SensorIcon = getSensorIcon(rule.sensorType)
// Helper to safely convert a number to string, handling null/undefined
const toStr = (v: number | null | undefined): string => v != null ? String(v) : ''
// Local string state for each input to allow decimal point typing
// without losing the dot on every keystroke (Number("5.") → 5 strips the dot)
const [rawValue1, setRawValue1] = useState<string>(toStr(rule.value1))
const [rawValue2, setRawValue2] = useState<string>(toStr(rule.value2))
// Track if each input is currently focused (being edited)
const isFocused1 = useRef(false)
const isFocused2 = useRef(false)
// Use the prop value when input is NOT focused (external updates)
const displayValue1 = isFocused1.current ? rawValue1 : toStr(rule.value1)
const displayValue2 = isFocused2.current ? rawValue2 : toStr(rule.value2)
const commitValue1 = useCallback((raw: string) => {
const sanitized = sanitizeDecimal(raw)
const num = sanitized === '' || sanitized === '-' || sanitized === '.' ? 0 : Number(sanitized)
if (!isNaN(num)) {
onUpdate(index, { value1: num })
}
}, [index, onUpdate])
const commitValue2 = useCallback((raw: string) => {
const sanitized = sanitizeDecimal(raw)
const num = sanitized === '' || sanitized === '-' || sanitized === '.' ? 0 : Number(sanitized)
if (!isNaN(num)) {
onUpdate(index, { value2: num })
}
}, [index, onUpdate])
return (
<div className="bg-gradient-to-br from-orange-50 to-red-50 border border-orange-200 rounded-xl p-3 relative shadow-sm">
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3">
{/* Header - Compact */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-orange-700 bg-white/80 px-2 py-0.5 rounded-full shadow-sm">
<span className="text-xs font-bold text-orange-700 bg-white px-2 py-0.5 rounded-md">
{totalRules === 1 ? '⚡ شرط' : `⚡ شرط ${index + 1}`}
</span>
{/* Compact summary of current selection */}
@@ -39,7 +95,7 @@ export function RuleFormCard({ rule, index, totalRules, onUpdate, onRemove }: Ru
<button
type="button"
onClick={() => onRemove(index)}
className="p-1.5 text-red-500 hover:bg-red-100 rounded-lg transition-colors"
className="p-1.5 text-red-500 hover:bg-red-100 rounded-md transition-colors"
title="حذف این شرط"
>
<Trash2 className="w-3.5 h-3.5" />
@@ -95,10 +151,19 @@ export function RuleFormCard({ rule, index, totalRules, onUpdate, onRemove }: Ru
<div className="flex gap-1.5 items-center flex-1">
<div className="relative flex-1 min-w-0">
<input
type="number"
step="0.01"
value={rule.value1}
onChange={(e) => onUpdate(index, { value1: Number(e.target.value) })}
type="text"
inputMode="decimal"
dir="ltr"
value={displayValue1}
onChange={(e) => {
const sanitized = sanitizeDecimal(e.target.value)
setRawValue1(sanitized)
}}
onFocus={() => { isFocused1.current = true }}
onBlur={() => {
isFocused1.current = false
commitValue1(rawValue1)
}}
className="w-full px-3 py-2 pl-10 bg-white border border-orange-200 rounded-lg text-sm font-medium focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
placeholder={needsMax ? 'از' : 'مقدار'}
required
@@ -112,10 +177,19 @@ export function RuleFormCard({ rule, index, totalRules, onUpdate, onRemove }: Ru
<span className="text-gray-400 text-sm flex-shrink-0">-</span>
<div className="relative flex-1 min-w-0">
<input
type="number"
step="0.01"
value={rule.value2 ?? ''}
onChange={(e) => onUpdate(index, { value2: Number(e.target.value) })}
type="text"
inputMode="decimal"
dir="ltr"
value={displayValue2}
onChange={(e) => {
const sanitized = sanitizeDecimal(e.target.value)
setRawValue2(sanitized)
}}
onFocus={() => { isFocused2.current = true }}
onBlur={() => {
isFocused2.current = false
commitValue2(rawValue2)
}}
className="w-full px-3 py-2 pl-10 bg-white border border-orange-200 rounded-lg text-sm font-medium focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
placeholder="تا"
required

View File

@@ -23,9 +23,9 @@ export function TimeTypeSelector({ value, onChange }: TimeTypeSelectorProps) {
type="button"
onClick={() => onChange(time.value)}
title={time.description}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border transition-all text-xs ${
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border transition-all duration-150 text-xs ${
isSelected
? 'border-blue-500 bg-blue-50 text-blue-700 shadow-sm'
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50'
}`}
>

View File

@@ -16,22 +16,22 @@ export function WeatherAlertBanner({
<div className={className || 'mb-6'}>
<button
onClick={onClick}
className="w-full p-4 bg-gradient-to-r from-amber-50 to-orange-50 border-2 border-amber-200 hover:border-amber-400 rounded-xl transition-all duration-200 hover:shadow-md text-right flex items-center justify-between group"
className="w-full p-4 bg-amber-50 border border-amber-200 hover:border-amber-300 rounded-lg transition-all duration-150 text-right flex items-center justify-between group"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-500 rounded-lg flex items-center justify-center group-hover:scale-110 transition-transform">
<AlertTriangle className="w-6 h-6 text-white" />
<div className="w-9 h-9 bg-amber-500 rounded-md flex items-center justify-center group-hover:scale-105 transition-transform">
<AlertTriangle className="w-5 h-5 text-white" />
</div>
<div>
<p className="font-semibold text-gray-800">
<p className="font-semibold text-gray-800 text-sm">
شما {toPersianDigits(alertsCount)} هشدار آب و هوایی برای روزهای آینده دارید
</p>
<p className="text-sm text-gray-600 mt-1">
<p className="text-xs text-gray-500 mt-0.5">
برای مشاهده جزئیات و توصیه‌های مدیریت گلخانه کلیک کنید
</p>
</div>
</div>
<ChevronLeft className="w-5 h-5 text-amber-600 group-hover:translate-x-[-4px] transition-transform" />
<ChevronLeft className="w-4 h-4 text-amber-600 group-hover:translate-x-[-4px] transition-transform" />
</button>
</div>
)

View File

@@ -13,7 +13,7 @@ export function WeekdayHeaders({
}: WeekdayHeadersProps) {
return (
<div className={cn(
'grid grid-cols-7 text-center bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200',
'grid grid-cols-7 text-center bg-gray-50 border-b border-gray-200',
className
)}>
{weekdays.map((day) => (

View File

@@ -21,7 +21,7 @@ export function YearSelector({
انتخاب سال:
</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"
className="px-4 py-2 border border-gray-200 rounded-lg focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-150 bg-white font-medium"
value={selectedYear}
onChange={(e) => onYearChange(Number(e.target.value))}
>

View File

@@ -21,12 +21,12 @@ export function CalendarDayCell({
<button
onClick={onClick}
className={cn(
'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',
'group min-h-[90px] md:min-h-[100px] bg-white border border-green-200 hover:border-green-400 transition-all duration-150 cursor-pointer rounded-lg p-2 md:p-3 flex flex-col items-center justify-center',
className
)}
>
<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">
<div className="flex items-center gap-1 bg-green-600 text-white text-xs rounded-md px-2.5 py-1 font-medium">
<Database className="w-3 h-3" />
{recordCount}
</div>

View File

@@ -4,22 +4,55 @@ import { DeviceDto } from '@/lib/api'
type DeviceCardProps = {
device: DeviceDto
href: string
href?: string
onClick?: () => void
className?: string
}
export function DeviceCard({ device, href, className }: DeviceCardProps) {
export function DeviceCard({ device, href, onClick, className }: DeviceCardProps) {
if (onClick) {
return (
<div
onClick={onClick}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onClick(); }}
className={`group bg-white rounded-lg border border-gray-200 hover:border-green-300 transition-all duration-150 overflow-hidden p-5 relative cursor-pointer ${className || ''}`}
>
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-green-600 flex items-center justify-center group-hover:scale-105 transition-transform duration-150">
<Settings className="w-6 h-6 text-white" />
</div>
<div className="flex-1">
<h3 className="text-base 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>
)
}
return (
<Link
href={href}
className={`group bg-white rounded-2xl shadow-md hover:shadow-xl transition-all duration-300 overflow-hidden border border-gray-100 p-6 relative ${className || ''}`}
href={href || '#'}
className={`group bg-white rounded-lg border border-gray-200 hover:border-green-300 transition-all duration-150 overflow-hidden p-5 relative ${className || ''}`}
>
<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 className="flex-shrink-0 w-12 h-12 rounded-lg bg-green-600 flex items-center justify-center group-hover:scale-105 transition-transform duration-150">
<Settings className="w-6 h-6 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">
<h3 className="text-base 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">
@@ -33,7 +66,6 @@ export function DeviceCard({ device, href, className }: DeviceCardProps) {
<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>
)
}

View File

@@ -21,32 +21,29 @@ export function MonthCard({
onClick={onClick}
disabled={!isActive}
className={cn(
'group relative rounded-xl border-2 p-5 text-center transition-all duration-300',
'group relative rounded-lg border p-5 text-center transition-all duration-150',
isActive
? 'bg-white border-green-200 hover:border-green-400 hover:shadow-lg hover:-translate-y-1'
? 'bg-white border-green-200 hover:border-green-400'
: 'bg-gray-50 border-gray-200 opacity-50 cursor-not-allowed',
className
)}
>
<div className={cn('text-lg font-semibold mb-2', isActive ? 'text-gray-900' : 'text-gray-400')}>
<div className={cn('text-base 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">
<div className="inline-flex items-center gap-1 bg-green-600 text-white text-xs rounded-md 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">
<div className="text-xs text-gray-500 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>
)
}

View File

@@ -18,9 +18,9 @@ type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
}
const variantStyles: Record<ButtonVariant, string> = {
default: 'bg-white border-2 border-gray-200 hover:border-indigo-500 hover:bg-indigo-50 text-gray-700 hover:text-indigo-600',
primary: 'bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white shadow-md hover:shadow-lg',
secondary: 'bg-white border-2 border-orange-300 hover:border-orange-500 hover:bg-orange-50 text-orange-700 hover:text-orange-800',
default: 'bg-white border border-gray-300 hover:border-gray-400 hover:bg-gray-50 text-gray-700',
primary: 'bg-indigo-600 hover:bg-indigo-700 text-white shadow-sm',
secondary: 'bg-white border border-orange-300 hover:border-orange-400 hover:bg-orange-50 text-orange-700',
outline: 'border border-gray-300 hover:bg-gray-50 text-gray-700',
ghost: 'hover:bg-gray-100 text-gray-700',
}
@@ -59,12 +59,11 @@ export function Button({
const buttonClasses = cn(
'inline-flex items-center justify-center gap-1 sm:gap-2',
'rounded-xl transition-all duration-200 font-medium',
'rounded-lg transition-all duration-150 font-medium',
'whitespace-nowrap',
'shadow-sm hover:shadow-md active:shadow-sm',
'active:scale-[0.98]', // Native-like press effect
'touch-manipulation', // Better touch response
'select-none', // Prevent text selection
'active:scale-[0.98]',
'touch-manipulation',
'select-none',
sizeStyle.padding,
sizeStyle.text,
variantStyle,

View File

@@ -23,9 +23,9 @@ export function Card({
return (
<div className={cn(
'bg-white rounded-2xl shadow-md border border-gray-100',
'bg-white rounded-lg border border-gray-200',
paddingClasses[padding],
hover && 'hover:shadow-xl transition-all duration-300',
hover && 'hover:border-gray-300 hover:shadow-sm transition-all duration-200',
className
)}>
{children}

View File

@@ -48,13 +48,13 @@ export function Dialog({ isOpen, onClose, title, children }: DialogProps) {
/>
{/* Dialog */}
<div className="relative bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col z-10">
<div className="relative bg-white rounded-lg shadow-lg max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col z-10">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">{title}</h2>
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
className="p-2 hover:bg-gray-100 rounded-md transition-colors"
aria-label="بستن"
>
<X className="w-5 h-5 text-gray-500" />

View File

@@ -19,18 +19,18 @@ export function EmptyState({
}: EmptyStateProps) {
return (
<div className={cn(
'bg-white rounded-2xl shadow-md border border-gray-100 p-8 md:p-12 text-center',
'bg-white rounded-lg border border-gray-200 p-8 md:p-12 text-center',
className
)}>
{Icon && (
<div className="text-gray-300 mb-4">
<Icon className="w-16 h-16 mx-auto" />
<Icon className="w-14 h-14 mx-auto" />
</div>
)}
{title && (
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
<h3 className="text-base font-semibold text-gray-900 mb-2">{title}</h3>
)}
<p className="text-gray-600 mb-6">{message}</p>
<p className="text-sm text-gray-500 mb-6">{message}</p>
{action && (
<div className="flex justify-center">
{action}

View File

@@ -20,11 +20,11 @@ export function ErrorMessage({
return (
<div className={cn('min-h-screen flex items-center justify-center p-4', className)}>
<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="bg-white rounded-lg border border-gray-200 p-8 text-center">
<div className="text-red-600 mb-4">
<AlertCircle className="w-16 h-16 mx-auto" />
</div>
<h2 className="text-xl font-bold text-gray-900 mb-2">خطا</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-2">خطا</h2>
<p className="text-gray-600 mb-6">{message}</p>
{action}
</div>
@@ -35,7 +35,7 @@ export function ErrorMessage({
return (
<div className={cn(
'p-4 bg-red-50 border border-red-200 rounded-xl',
'p-4 bg-red-50 border border-red-200 rounded-lg',
className
)}>
<div className="flex items-center gap-2 text-red-700 mb-3">
@@ -44,7 +44,7 @@ export function ErrorMessage({
{onClose && (
<button
onClick={onClose}
className="flex-shrink-0 p-1 hover:bg-red-100 rounded transition-colors"
className="flex-shrink-0 p-1 hover:bg-red-100 rounded-md transition-colors"
>
<X className="w-4 h-4" />
</button>

View File

@@ -37,7 +37,7 @@ export function IconButton({
return (
<button
className={cn(
'rounded-lg transition-colors',
'rounded-md transition-colors',
variantStyles[variant],
sizeStyles[size],
className

View File

@@ -49,7 +49,7 @@ export function Modal({
>
<div
className={cn(
'bg-white rounded-2xl shadow-2xl w-full max-h-[90vh] overflow-y-auto',
'bg-white rounded-lg shadow-lg w-full max-h-[90vh] overflow-y-auto',
sizeStyles[size],
className
)}
@@ -57,18 +57,18 @@ export function Modal({
>
{title && (
<div className="flex items-center justify-between px-6 border-b border-gray-200 sticky top-0 bg-white z-10">
<h2 className="text-xl font-bold text-gray-900">{title}</h2>
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
{showCloseButton && (
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-md transition-colors"
>
<X className="w-5 h-5" />
</button>
)}
</div>
)}
<div className={cn(title ? 'p-6' : 'p-6', className)}>
<div className={cn(title ? 'p-0' : 'p-0', className)}>
{children}
</div>
</div>

View File

@@ -24,17 +24,17 @@ export function PageHeader({
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-3">
<div className={cn(
'inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br rounded-xl shadow-md',
'inline-flex items-center justify-center w-10 h-10 rounded-lg',
iconGradient
)}>
<Icon className="w-6 h-6 text-white" />
<Icon className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
<h1 className="text-xl md:text-2xl font-bold text-gray-900">
{title}
</h1>
{subtitle && (
<p className="text-sm text-gray-500 mt-1">
<p className="text-sm text-gray-500 mt-0.5">
{subtitle}
</p>
)}

View File

@@ -7,12 +7,12 @@ type SegmentTabProps<T extends string> = {
export function SegmentTab<T extends string>({ tabs, activeTab, className, setActiveTab }: SegmentTabProps<T>) {
return (
<div className={`bg-gray-100 rounded-xl p-1 flex ${className}`}>
<div className={`bg-gray-100 rounded-lg p-1 flex ${className}`}>
{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 ${
className={`flex-1 px-2 py-2.5 text-xs font-medium rounded-md transition-all duration-150 ${
activeTab === tab.value
? 'bg-white text-indigo-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'

View File

@@ -14,7 +14,7 @@ export function SuccessMessage({
}: SuccessMessageProps) {
return (
<div className={cn(
'p-4 bg-green-50 border border-green-200 rounded-xl text-green-700 flex items-center gap-2',
'p-4 bg-green-50 border border-green-200 rounded-lg text-green-700 flex items-center gap-2',
className
)}>
<CheckCircle2 className="w-5 h-5 flex-shrink-0" />
@@ -22,7 +22,7 @@ export function SuccessMessage({
{onClose && (
<button
onClick={onClose}
className="flex-shrink-0 p-1 hover:bg-green-100 rounded transition-colors"
className="flex-shrink-0 p-1 hover:bg-green-100 rounded-md transition-colors"
>
<X className="w-4 h-4" />
</button>

View File

@@ -22,7 +22,7 @@ export function Tabs<T extends string>({
className
}: TabsProps<T>) {
return (
<div className={`bg-white rounded-2xl shadow-md border border-gray-200 overflow-hidden ${className || ''}`}>
<div className={`bg-white rounded-lg border border-gray-200 overflow-hidden ${className || ''}`}>
{/* Segmented Control for Mobile */}
<div className="p-3 md:p-6 md:pb-0">
<SegmentTab
@@ -38,10 +38,10 @@ export function Tabs<T extends string>({
<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 ${
className={`flex-1 px-6 py-4 text-sm font-medium transition-all duration-150 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'
? 'text-indigo-600 border-b-2 border-indigo-600'
: 'text-gray-500 hover:text-gray-800'
}`}
>
{tab.label}

View File

@@ -0,0 +1,142 @@
'use client'
import { Thermometer, Droplets, Sprout, Wind, Sun, Activity } from 'lucide-react'
import { toPersianDigits } from '@/lib/format/persian-digits'
import { paramConfig } from '@/features/daily-report/utils'
type LiveSummaryCardsProps = {
temperature: number
humidity: number
soil: number
gas: number
lux: number
lastUpdated?: string
onCardClick?: (param: string) => void
}
const cardMeta = {
temperature: {
icon: Thermometer,
color: 'text-rose-600',
bgColor: 'bg-rose-50',
},
humidity: {
icon: Droplets,
color: 'text-sky-600',
bgColor: 'bg-sky-50',
},
soil: {
icon: Sprout,
color: 'text-emerald-600',
bgColor: 'bg-emerald-50',
},
gas: {
icon: Wind,
color: 'text-amber-600',
bgColor: 'bg-amber-50',
},
lux: {
icon: Sun,
color: 'text-yellow-600',
bgColor: 'bg-yellow-50',
},
} as const
type ParamKey = keyof typeof cardMeta
export function LiveSummaryCards({
temperature,
humidity,
soil,
gas,
lux,
lastUpdated,
onCardClick,
}: LiveSummaryCardsProps) {
const values: Record<ParamKey, number> = {
temperature,
humidity,
soil,
gas,
lux,
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-md bg-indigo-100 flex items-center justify-center">
<Activity className="w-4 h-4 text-indigo-600" />
</div>
<h2 className="text-sm font-semibold text-gray-800">خلاصه لحظه‌ای</h2>
</div>
{lastUpdated && (
<span className="text-[11px] text-gray-400 bg-gray-50 px-2.5 py-1 rounded-md border border-gray-200">
آخرین به‌روزرسانی: {toPersianDigits(lastUpdated)}
</span>
)}
</div>
{/* Cards Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2.5">
{(Object.keys(cardMeta) as ParamKey[]).map((key) => {
const meta = cardMeta[key]
const config = paramConfig[key]
const Icon = meta.icon
const value = values[key]
return (
<button
key={key}
onClick={() => onCardClick?.(key)}
className={`
bg-white border border-gray-200
rounded-lg p-3.5
flex flex-col items-center gap-2
transition-all duration-150
hover:border-gray-300 hover:shadow-sm
active:scale-[0.98]
group
text-right
w-full
`}
style={{ minHeight: 'auto', minWidth: 0 }}
>
{/* Icon */}
<div className={`
w-9 h-9 rounded-lg
${meta.bgColor}
flex items-center justify-center
group-hover:scale-105
transition-transform duration-150
`}>
<Icon className={`w-4.5 h-4.5 ${meta.color}`} />
</div>
{/* Value */}
<div className="text-center">
<div className="text-xl font-bold text-gray-900"
style={{ color: config?.chartColor }}>
{toPersianDigits(
key === 'temperature'
? value.toFixed(1)
: Math.round(value).toString()
)}
</div>
<div className="text-[10px] text-gray-400 mt-0.5">
{config?.unit}
</div>
</div>
{/* Label */}
<span className="text-[10px] font-medium text-gray-500">
{config?.title}
</span>
</button>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,171 @@
'use client'
import { useMemo } from 'react'
import { Zap, Clock, PowerOff, Power, AlertTriangle } from 'lucide-react'
import { toPersianDigits } from '@/lib/format/persian-digits'
import type { PowerOutageSummaryDto } from '@/lib/api'
type PowerOutageCardProps = {
summary: PowerOutageSummaryDto | null
loading: boolean
}
function formatDuration(seconds: number | null): string {
if (seconds === null) return 'ادامه دارد'
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours > 0) {
return `${toPersianDigits(String(hours))} ساعت و ${toPersianDigits(String(minutes))} دقیقه`
}
return `${toPersianDigits(String(minutes))} دقیقه`
}
function formatTime(utcString: string): string {
const date = new Date(utcString)
// Convert to Iran time (UTC+3:30)
const iranOffset = 3.5 * 60 * 60 * 1000
const iranTime = new Date(date.getTime() + iranOffset)
const hours = String(iranTime.getUTCHours()).padStart(2, '0')
const minutes = String(iranTime.getUTCMinutes()).padStart(2, '0')
return `${toPersianDigits(hours)}:${toPersianDigits(minutes)}`
}
export function PowerOutageCard({ summary, loading }: PowerOutageCardProps) {
const totalDowntimeFormatted = useMemo(() => {
if (!summary) return '—'
return formatDuration(summary.totalDowntimeSeconds)
}, [summary])
if (loading) {
return (
<div className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden">
<div className="p-4 space-y-3">
{/* Header skeleton */}
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-md bg-amber-200/50 animate-pulse" />
<div className="h-4 w-24 bg-amber-200/50 rounded animate-pulse" />
</div>
{/* Stats skeleton */}
<div className="flex gap-3">
<div className="flex-1 h-14 bg-white/50 rounded-lg animate-pulse" />
<div className="flex-1 h-14 bg-white/50 rounded-lg animate-pulse" />
</div>
</div>
</div>
)
}
if (!summary || summary.totalOutageCount === 0) {
return (
<div className="bg-emerald-50 border border-emerald-200 rounded-lg overflow-hidden">
<div className="p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-8 h-8 rounded-md bg-emerald-100 flex items-center justify-center">
<Zap className="w-4 h-4 text-emerald-600" />
</div>
<h3 className="text-sm font-semibold text-gray-800">وضعیت برق</h3>
</div>
<div className="flex items-center gap-2.5 bg-white rounded-lg px-3.5 py-3 border border-emerald-100">
<div className="w-7 h-7 rounded-md bg-emerald-100 flex items-center justify-center">
<Power className="w-3.5 h-3.5 text-emerald-600" />
</div>
<div>
<p className="text-sm font-semibold text-emerald-700">برق وصل است</p>
<p className="text-[11px] text-gray-500 mt-0.5">هیچ قطعی برقی در این روز ثبت نشده</p>
</div>
</div>
</div>
</div>
)
}
return (
<div className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden">
<div className="p-4">
{/* Header */}
<div className="flex items-center gap-2 mb-3">
<div className="w-8 h-8 rounded-md bg-amber-100 flex items-center justify-center">
<Zap className="w-4 h-4 text-amber-600" />
</div>
<h3 className="text-sm font-semibold text-gray-800">قطعی برق</h3>
{summary.totalOutageCount > 0 && (
<span className="mr-auto bg-red-500 text-white text-[10px] font-bold px-2 py-0.5 rounded-full">
{toPersianDigits(String(summary.totalOutageCount))} قطعی
</span>
)}
</div>
{/* Summary Stats */}
<div className="flex gap-2 mb-3">
<div className="flex-1 bg-white rounded-lg px-3 py-2.5 border border-amber-100">
<div className="flex items-center gap-1.5 mb-1">
<Clock className="w-3.5 h-3.5 text-amber-600" />
<span className="text-[10px] text-gray-500">مدت قطعی کل</span>
</div>
<p className="text-sm font-bold text-gray-800">
{totalDowntimeFormatted}
</p>
</div>
<div className="flex-1 bg-white rounded-lg px-3 py-2.5 border border-amber-100">
<div className="flex items-center gap-1.5 mb-1">
<AlertTriangle className="w-3.5 h-3.5 text-amber-600" />
<span className="text-[10px] text-gray-500">تعداد قطعی</span>
</div>
<p className="text-sm font-bold text-gray-800">
{toPersianDigits(String(summary.totalOutageCount))} بار
</p>
</div>
</div>
{/* Outage List */}
{summary.outages.length > 0 && (
<div className="space-y-2">
<p className="text-[11px] font-medium text-gray-500 mr-1">زمان‌بندی قطعی‌ها</p>
<div className="space-y-1.5">
{summary.outages.map((outage, index) => (
<div
key={index}
className="bg-white rounded-lg px-3 py-2.5 border border-amber-100 flex items-center justify-between"
>
<div className="flex items-center gap-2.5">
<div className={`w-7 h-7 rounded-md flex items-center justify-center ${
outage.connectedAt
? 'bg-red-100'
: 'bg-red-200'
}`}>
<PowerOff className={`w-3.5 h-3.5 ${
outage.connectedAt
? 'text-red-500'
: 'text-red-600'
}`} />
</div>
<div>
<p className="text-xs font-semibold text-gray-800">
از ساعت {formatTime(outage.disconnectedAt)}
</p>
<p className="text-[10px] text-gray-500 mt-0.5">
{outage.connectedAt
? `تا ساعت ${formatTime(outage.connectedAt)}`
: 'هنوز وصل نشده'}
</p>
</div>
</div>
<div className="text-left">
<span className={`text-[11px] font-bold ${
outage.durationSeconds !== null && outage.durationSeconds > 3600
? 'text-red-600'
: 'text-amber-700'
}`}>
{formatDuration(outage.durationSeconds)}
</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -37,12 +37,12 @@ export function SummaryCard({ param, currentValue, minValue, maxValue, data, onC
return (
<div
className={`${config.bgColor} rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden ${onClick ? 'cursor-pointer' : ''}`}
className={`${config.bgColor} rounded-lg border border-gray-200 hover:border-gray-300 transition-all duration-150 overflow-hidden ${onClick ? 'cursor-pointer hover:shadow-sm' : ''}`}
onClick={onClick}
>
{/* Header */}
<div className="px-4 pt-3 pb-1">
<h3 className="text-sm font-bold text-gray-800">{config.title}</h3>
<h3 className="text-sm font-semibold text-gray-800">{config.title}</h3>
</div>
{/* Main Content - Gauge on left, Stats on right */}
@@ -55,7 +55,7 @@ export function SummaryCard({ param, currentValue, minValue, maxValue, data, onC
{/* Stats on right - stacked */}
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-1.5 bg-white/60 rounded-md px-2 py-1.5">
<div className="flex items-center gap-1.5 bg-white rounded-md px-2 py-1.5 border border-gray-100">
<TrendingUp className="w-3.5 h-3.5 text-green-600 flex-shrink-0" />
<div className="flex flex-col">
<span className="text-[10px] text-gray-500 leading-tight">حداکثر</span>
@@ -64,7 +64,7 @@ export function SummaryCard({ param, currentValue, minValue, maxValue, data, onC
</span>
</div>
</div>
<div className="flex items-center gap-1.5 bg-white/60 rounded-md px-2 py-1.5">
<div className="flex items-center gap-1.5 bg-white rounded-md px-2 py-1.5 border border-gray-100">
<TrendingDown className="w-3.5 h-3.5 text-red-600 flex-shrink-0" />
<div className="flex flex-col">
<span className="text-[10px] text-gray-500 leading-tight">حداقل</span>

View File

@@ -1,10 +1,12 @@
import { useMemo, useState } from 'react'
import { SummaryCard } from './SummaryCard'
import { PowerOutageCard } from './PowerOutageCard'
import { WeatherData } from '@/features/weather'
import { GreenhouseForecastAlerts, getForecastAlertsCount } from './GreenhouseForecastAlerts'
import { Dialog } from '@/components/common/Dialog'
import { WeatherAlertBanner } from '@/components/alerts'
import { Loader2 } from 'lucide-react'
import type { PowerOutageSummaryDto } from '@/lib/api'
type SummaryTabProps = {
temperature: number[]
@@ -16,9 +18,11 @@ type SummaryTabProps = {
forecastWeatherLoading?: boolean
onCardClick?: (param: string) => void
visibleParams?: string[] | null
powerOutageSummary?: PowerOutageSummaryDto | null
powerOutageLoading?: boolean
}
export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeather, forecastWeatherLoading = false, onCardClick, visibleParams }: SummaryTabProps) {
export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeather, forecastWeatherLoading = false, onCardClick, visibleParams, powerOutageSummary = null, powerOutageLoading = false }: SummaryTabProps) {
const [isAlertsDialogOpen, setIsAlertsDialogOpen] = useState(false)
const alertsCount = useMemo(() => {
@@ -75,16 +79,16 @@ export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeat
{/* Greenhouse Forecast Alerts Section */}
{forecastWeatherLoading ? (
<div className="mb-6">
<div className="w-full p-4 bg-gradient-to-r from-amber-50 to-orange-50 border-2 border-amber-200 rounded-xl text-right flex items-center justify-between">
<div className="w-full p-4 bg-amber-50 border border-amber-200 rounded-lg text-right flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-500 rounded-lg flex items-center justify-center">
<Loader2 className="w-6 h-6 text-white animate-spin" />
<div className="w-9 h-9 bg-amber-100 rounded-md flex items-center justify-center">
<Loader2 className="w-5 h-5 text-amber-600 animate-spin" />
</div>
<div>
<p className="font-semibold text-gray-800">
در حال بارگذاری هشدارهای آب و هوایی...
</p>
<p className="text-sm text-gray-600 mt-1">
<p className="text-sm text-gray-500 mt-0.5">
لطفاً صبر کنید
</p>
</div>
@@ -99,7 +103,7 @@ export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeat
) : null}
{/* Summary Cards Grid */}
<div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
{(!visibleParams || visibleParams.length === 0 || visibleParams.includes('temperature')) && (
<SummaryCard
param="temperature"
@@ -156,6 +160,14 @@ export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeat
)}
</div>
{/* Power Outage Card */}
<div className="mt-6">
<PowerOutageCard
summary={powerOutageSummary}
loading={powerOutageLoading}
/>
</div>
{/* Alerts Dialog */}
{forecastWeather && (
<Dialog

View File

@@ -1,9 +1,11 @@
// Daily report UI components only
export { SummaryCard } from './SummaryCard'
export { SummaryTab } from './SummaryTab'
export { LiveSummaryCards } from './LiveSummaryCards'
export { TimeRangeSelector } from './TimeRangeSelector'
export { ChartsTab } from './ChartsTab'
export { WeatherTab } from './WeatherTab'
export { AnalysisTab } from './AnalysisTab'
export { GreenhouseForecastAlerts } from './GreenhouseForecastAlerts'
export { PowerOutageCard } from './PowerOutageCard'

View File

@@ -10,16 +10,16 @@ export function HistoricalWeather({ weatherData, selectedDate }: HistoricalWeath
return (
<div className="space-y-6">
{/* Past Date Header */}
<div className="bg-white rounded-2xl shadow-lg border-2 border-gray-100 overflow-hidden">
<div className="bg-gradient-to-r from-blue-500 to-indigo-500 p-6">
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="bg-blue-600 p-5">
<div className="text-center text-white">
<p className="text-lg opacity-90 mb-1">📅 وضعیت آب و هوای روز</p>
<p className="text-2xl font-bold">{selectedDate}</p>
<p className="text-sm opacity-90 mb-1">📅 وضعیت آب و هوای روز</p>
<p className="text-xl font-bold">{selectedDate}</p>
</div>
</div>
{/* Status Grid */}
<div className="p-4 grid grid-cols-2 gap-4">
<div className="p-4 grid grid-cols-2 gap-3">
<TemperatureCard weatherData={weatherData} isToday={false} />
<PrecipitationHistoricalCard weatherData={weatherData} />
<SunlightCard weatherData={weatherData} />
@@ -29,4 +29,3 @@ export function HistoricalWeather({ weatherData, selectedDate }: HistoricalWeath
</div>
)
}

View File

@@ -25,7 +25,7 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
return (
<div
key={index}
className={`p-4 rounded-xl border-r-4 ${
className={`p-4 rounded-lg border-r-4 ${
alert.type === 'danger' ? 'bg-red-50 border-red-500' :
alert.type === 'warning' ? 'bg-amber-50 border-amber-500' :
alert.type === 'info' ? 'bg-blue-50 border-blue-500' :
@@ -56,30 +56,30 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
)}
{/* Current Weather Header */}
<div className="bg-white rounded-2xl shadow-lg border-2 border-gray-100 overflow-hidden">
<div className="bg-gradient-to-r from-emerald-500 to-teal-500 p-6">
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="bg-emerald-600 p-5">
<div className="flex items-center justify-between">
<div className="text-white">
<p className="text-lg opacity-90 mb-1">🌡️ الان</p>
<p className="text-sm opacity-90 mb-1">🌡️ الان</p>
<div className="flex items-baseline gap-2">
<span className="text-6xl font-bold">
<span className="text-5xl font-bold">
{toPersianDigits(Math.round(weatherData.current.temperature))}
</span>
<span className="text-3xl">درجه</span>
<span className="text-2xl">درجه</span>
</div>
</div>
<div className="text-center text-white">
{(() => {
const IconComponent = getWeatherInfo(weatherData.current.weatherCode).icon
return <IconComponent className="w-20 h-20" />
return <IconComponent className="w-16 h-16" />
})()}
<p className="text-lg mt-1">{getWeatherInfo(weatherData.current.weatherCode).description}</p>
<p className="text-sm mt-1">{getWeatherInfo(weatherData.current.weatherCode).description}</p>
</div>
</div>
</div>
{/* Status Grid */}
<div className="p-4 grid grid-cols-2 gap-4">
<div className="p-4 grid grid-cols-2 gap-3">
<TemperatureCard weatherData={weatherData} isToday={true} />
<PrecipitationForecastCard weatherData={weatherData} />
<SunlightCard weatherData={weatherData} />
@@ -88,9 +88,9 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
</div>
{/* Hourly Forecast */}
<div className="bg-white rounded-2xl shadow-lg border-2 border-gray-100 overflow-hidden">
<div className="bg-gradient-to-r from-indigo-500 to-purple-500 p-4">
<h4 className="text-lg font-bold text-white flex items-center gap-2">
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="bg-indigo-600 p-4">
<h4 className="text-sm font-bold text-white flex items-center gap-2">
🕐 وضعیت ساعت به ساعت امروز
</h4>
</div>
@@ -108,9 +108,9 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
return (
<div
key={hour.time}
className={`flex flex-col items-center p-3 rounded-xl min-w-[85px] transition-all ${
className={`flex flex-col items-center p-3 rounded-lg min-w-[80px] transition-all ${
isNow
? 'bg-gradient-to-b from-emerald-400 to-emerald-500 text-white shadow-lg scale-105'
? 'bg-emerald-500 text-white shadow-sm scale-105'
: isHot
? 'bg-red-50 border border-red-200'
: isCold
@@ -122,8 +122,8 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
{isNow ? '⏰ الان' : `${toPersianDigits(hourNum)}:۰۰`}
</p>
<div className={`my-2 p-2 rounded-full ${isNow ? 'bg-white/20' : 'bg-white'}`}>
<IconComponent className={`w-6 h-6 ${
<div className={`my-2 p-1.5 rounded-full ${isNow ? 'bg-white/20' : 'bg-white'}`}>
<IconComponent className={`w-5 h-5 ${
isNow ? 'text-white' :
isHot ? 'text-red-500' :
isCold ? 'text-blue-500' :
@@ -131,7 +131,7 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
}`} />
</div>
<p className={`text-2xl font-bold ${
<p className={`text-xl font-bold ${
isNow ? 'text-white' :
isHot ? 'text-red-600' :
isCold ? 'text-blue-600' :
@@ -160,8 +160,8 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
{/* 7-Day Forecast */}
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
<CalendarIcon className="w-5 h-5 text-emerald-500" />
<h3 className="text-base font-semibold text-gray-800 mb-4 flex items-center gap-2">
<CalendarIcon className="w-4 h-4 text-emerald-500" />
پیش‌بینی ۷ روز آینده
</h3>
<div className="space-y-2">
@@ -174,10 +174,10 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
const isExpanded = expandedDayIndex === index
return (
<div key={day.date} className="overflow-hidden rounded-xl">
<div key={day.date} className="overflow-hidden rounded-lg border border-gray-200">
<button
onClick={() => onDayToggle(isExpanded ? null : index)}
className={`w-full flex items-center justify-between p-4 transition-all duration-200 ${
className={`w-full flex items-center justify-between p-4 transition-all duration-150 ${
isExpanded
? 'bg-emerald-500 text-white'
: isToday
@@ -190,10 +190,10 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
}`}
>
<div className="flex items-center gap-4 flex-1">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
isExpanded ? 'bg-white/20' : 'bg-white'
}`}>
<IconComponent className={`w-6 h-6 ${isExpanded ? 'text-white' : 'text-gray-600'}`} />
<IconComponent className={`w-5 h-5 ${isExpanded ? 'text-white' : 'text-gray-600'}`} />
</div>
<div className="text-right flex-1">
<p className={`font-bold ${isExpanded ? 'text-white' : 'text-gray-800'}`}>
@@ -206,53 +206,53 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
</div>
<div className="flex items-center gap-4">
<div className="text-center">
<p className={`text-2xl font-bold ${isExpanded ? 'text-white' : 'text-gray-800'}`}>
<p className={`text-xl font-bold ${isExpanded ? 'text-white' : 'text-gray-800'}`}>
{toPersianDigits(Math.round(day.tempMax))}°
</p>
<p className={`text-xs ${isExpanded ? 'text-white/60' : 'text-gray-400'}`}>
{toPersianDigits(Math.round(day.tempMin))}°
</p>
</div>
<ChevronDown className={`w-5 h-5 transition-transform ${isExpanded ? 'rotate-180 text-white' : 'text-gray-400'}`} />
<ChevronDown className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-180 text-white' : 'text-gray-400'}`} />
</div>
</button>
{isExpanded && (
<div className="bg-white p-4 border-t border-emerald-100">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-50 rounded-xl p-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-gray-50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-600 font-medium">دما</span>
</div>
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(day.tempMax))}°</p>
<p className="text-sm text-gray-500">حداکثر</p>
<p className="text-xl font-bold text-blue-600 mt-2">{toPersianDigits(Math.round(day.tempMin))}°</p>
<p className="text-sm text-gray-500">حداقل</p>
<p className="text-xl font-bold text-gray-800">{toPersianDigits(Math.round(day.tempMax))}°</p>
<p className="text-xs text-gray-500">حداکثر</p>
<p className="text-lg font-bold text-blue-600 mt-2">{toPersianDigits(Math.round(day.tempMin))}°</p>
<p className="text-xs text-gray-500">حداقل</p>
</div>
<div className="bg-gray-50 rounded-xl p-4">
<div className="bg-gray-50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-600 font-medium">بارش</span>
</div>
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(day.precipitationProbability)}%</p>
<p className="text-sm text-gray-500">احتمال</p>
<p className="text-xl font-bold text-blue-600 mt-2">{toPersianDigits(day.precipitation.toFixed(1))}</p>
<p className="text-sm text-gray-500">میلی‌متر</p>
<p className="text-xl font-bold text-gray-800">{toPersianDigits(day.precipitationProbability)}%</p>
<p className="text-xs text-gray-500">احتمال</p>
<p className="text-lg font-bold text-blue-600 mt-2">{toPersianDigits(day.precipitation.toFixed(1))}</p>
<p className="text-xs text-gray-500">میلی‌متر</p>
</div>
<div className="bg-gray-50 rounded-xl p-4">
<div className="bg-gray-50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-600 font-medium">ساعات آفتابی</span>
</div>
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(day.sunshineDuration / 3600))}</p>
<p className="text-sm text-gray-500">ساعت</p>
<p className="text-xl font-bold text-orange-600 mt-2">{toPersianDigits(Math.round(day.uvIndexMax))}</p>
<p className="text-sm text-gray-500">UV Index</p>
<p className="text-xl font-bold text-gray-800">{toPersianDigits(Math.round(day.sunshineDuration / 3600))}</p>
<p className="text-xs text-gray-500">ساعت</p>
<p className="text-lg font-bold text-orange-600 mt-2">{toPersianDigits(Math.round(day.uvIndexMax))}</p>
<p className="text-xs text-gray-500">UV Index</p>
</div>
<div className="bg-gray-50 rounded-xl p-4">
<div className="bg-gray-50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-600 font-medium">باد</span>
</div>
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(day.windSpeedMax))}</p>
<p className="text-sm text-gray-500">کیلومتر/ساعت</p>
<p className="text-xl font-bold text-gray-800">{toPersianDigits(Math.round(day.windSpeedMax))}</p>
<p className="text-xs text-gray-500">کیلومتر/ساعت</p>
</div>
</div>
</div>
@@ -265,4 +265,3 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
</div>
)
}

View File

@@ -18,22 +18,22 @@ export function TemperatureCard({ weatherData, isToday }: WeatherCardsProps) {
const isHot = tempMax > 35
return (
<div className={`rounded-2xl p-4 ${
isCold ? 'bg-blue-100 border-2 border-blue-300' :
isHot ? 'bg-red-100 border-2 border-red-300' :
'bg-green-100 border-2 border-green-300'
<div className={`rounded-lg p-4 border ${
isCold ? 'bg-blue-50 border-blue-200' :
isHot ? 'bg-red-50 border-red-200' :
'bg-green-50 border-green-200'
}`}>
<div className="flex items-center gap-3 mb-3">
<div className={`w-14 h-14 rounded-xl flex items-center justify-center ${
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
isCold ? 'bg-blue-500' :
isHot ? 'bg-red-500' :
'bg-green-500'
}`}>
<Thermometer className="w-8 h-8 text-white" />
<Thermometer className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-lg font-bold text-gray-800">{isToday ? 'دمای امروز' : 'دمای روز'}</p>
<p className={`text-sm ${
<p className="text-sm font-bold text-gray-800">{isToday ? 'دمای امروز' : 'دمای روز'}</p>
<p className={`text-xs ${
isCold ? 'text-blue-600' :
isHot ? 'text-red-600' :
'text-green-600'
@@ -47,12 +47,12 @@ export function TemperatureCard({ weatherData, isToday }: WeatherCardsProps) {
<div className="flex justify-between items-center">
<div className="text-center">
<p className="text-xs text-gray-500">🌙 شب</p>
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(tempMin))}°</p>
<p className="text-xl font-bold text-gray-800">{toPersianDigits(Math.round(tempMin))}°</p>
</div>
<div className="text-3xl text-gray-300">←</div>
<div className="text-2xl text-gray-300">←</div>
<div className="text-center">
<p className="text-xs text-gray-500">☀️ روز</p>
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(tempMax))}°</p>
<p className="text-xl font-bold text-gray-800">{toPersianDigits(Math.round(tempMax))}°</p>
</div>
</div>
</div>
@@ -69,25 +69,25 @@ export function PrecipitationForecastCard({ weatherData }: { weatherData: Weathe
const isMedium = probability > 30
return (
<div className={`rounded-2xl p-4 ${
isHigh ? 'bg-blue-100 border-2 border-blue-300' :
isMedium ? 'bg-sky-50 border-2 border-sky-200' :
'bg-amber-50 border-2 border-amber-200'
<div className={`rounded-lg p-4 border ${
isHigh ? 'bg-blue-50 border-blue-200' :
isMedium ? 'bg-sky-50 border-sky-200' :
'bg-amber-50 border-amber-200'
}`}>
<div className="flex items-center gap-3 mb-3">
<div className={`w-14 h-14 rounded-xl flex items-center justify-center ${
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
isHigh ? 'bg-blue-500' :
isMedium ? 'bg-sky-400' :
'bg-amber-400'
}`}>
{isMedium ?
<CloudRain className="w-8 h-8 text-white" /> :
<Sun className="w-8 h-8 text-white" />
<CloudRain className="w-5 h-5 text-white" /> :
<Sun className="w-5 h-5 text-white" />
}
</div>
<div>
<p className="text-lg font-bold text-gray-800">بارش</p>
<p className={`text-sm ${
<p className="text-sm font-bold text-gray-800">بارش</p>
<p className={`text-xs ${
isHigh ? 'text-blue-600' :
isMedium ? 'text-sky-600' :
'text-amber-600'
@@ -99,8 +99,8 @@ export function PrecipitationForecastCard({ weatherData }: { weatherData: Weathe
</div>
</div>
<div className="text-center">
<p className="text-4xl font-bold text-gray-800">{toPersianDigits(probability)}%</p>
<p className="text-sm text-gray-500">احتمال بارش</p>
<p className="text-3xl font-bold text-gray-800">{toPersianDigits(probability)}%</p>
<p className="text-xs text-gray-500">احتمال بارش</p>
</div>
</div>
)
@@ -116,25 +116,25 @@ export function PrecipitationHistoricalCard({ weatherData }: { weatherData: Weat
const hasPrecipitation = precipitation > 0
return (
<div className={`rounded-2xl p-4 ${
isHigh ? 'bg-blue-100 border-2 border-blue-300' :
hasPrecipitation ? 'bg-sky-50 border-2 border-sky-200' :
'bg-amber-50 border-2 border-amber-200'
<div className={`rounded-lg p-4 border ${
isHigh ? 'bg-blue-50 border-blue-200' :
hasPrecipitation ? 'bg-sky-50 border-sky-200' :
'bg-amber-50 border-amber-200'
}`}>
<div className="flex items-center gap-3 mb-3">
<div className={`w-14 h-14 rounded-xl flex items-center justify-center ${
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
isHigh ? 'bg-blue-500' :
hasPrecipitation ? 'bg-sky-400' :
'bg-amber-400'
}`}>
{hasPrecipitation ?
<CloudRain className="w-8 h-8 text-white" /> :
<Sun className="w-8 h-8 text-white" />
<CloudRain className="w-5 h-5 text-white" /> :
<Sun className="w-5 h-5 text-white" />
}
</div>
<div>
<p className="text-lg font-bold text-gray-800">بارش</p>
<p className={`text-sm ${
<p className="text-sm font-bold text-gray-800">بارش</p>
<p className={`text-xs ${
isHigh ? 'text-blue-600' :
hasPrecipitation ? 'text-sky-600' :
'text-amber-600'
@@ -146,8 +146,8 @@ export function PrecipitationHistoricalCard({ weatherData }: { weatherData: Weat
</div>
</div>
<div className="text-center">
<p className="text-4xl font-bold text-gray-800">{toPersianDigits(precipitation.toFixed(1))}</p>
<p className="text-sm text-gray-500">میلی‌متر بارش</p>
<p className="text-3xl font-bold text-gray-800">{toPersianDigits(precipitation.toFixed(1))}</p>
<p className="text-xs text-gray-500">میلی‌متر بارش</p>
</div>
</div>
)
@@ -161,14 +161,14 @@ export function SunlightCard({ weatherData }: { weatherData: WeatherData }) {
const uvIndex = weatherData.daily[0]?.uvIndexMax || 0
return (
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-2xl p-4">
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-14 h-14 rounded-xl bg-yellow-400 flex items-center justify-center">
<Sun className="w-8 h-8 text-white" />
<div className="w-10 h-10 rounded-lg bg-yellow-400 flex items-center justify-center">
<Sun className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-lg font-bold text-gray-800">نور آفتاب</p>
<p className="text-sm text-yellow-600">
<p className="text-sm font-bold text-gray-800">نور آفتاب</p>
<p className="text-xs text-yellow-600">
{sunshineHours > 8 ? '☀️ آفتاب زیاد' :
sunshineHours > 4 ? '🌤️ آفتاب متوسط' :
'☁️ کم‌آفتاب'}
@@ -177,11 +177,11 @@ export function SunlightCard({ weatherData }: { weatherData: WeatherData }) {
</div>
<div className="flex justify-between items-end">
<div>
<p className="text-4xl font-bold text-gray-800">{toPersianDigits(Math.round(sunshineHours))}</p>
<p className="text-sm text-gray-500">ساعت آفتاب</p>
<p className="text-3xl font-bold text-gray-800">{toPersianDigits(Math.round(sunshineHours))}</p>
<p className="text-xs text-gray-500">ساعت آفتاب</p>
</div>
<div className="text-left">
<p className="text-2xl font-bold text-orange-500">{toPersianDigits(Math.round(uvIndex))}</p>
<p className="text-xl font-bold text-orange-500">{toPersianDigits(Math.round(uvIndex))}</p>
<p className="text-xs text-gray-500">شاخص UV</p>
</div>
</div>
@@ -197,14 +197,14 @@ export function WindHumidityCard({ weatherData }: { weatherData: WeatherData })
const humidity = weatherData.current.humidity
return (
<div className="bg-cyan-50 border-2 border-cyan-200 rounded-2xl p-4">
<div className="bg-cyan-50 border border-cyan-200 rounded-lg p-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-14 h-14 rounded-xl bg-cyan-400 flex items-center justify-center">
<Wind className="w-8 h-8 text-white" />
<div className="w-10 h-10 rounded-lg bg-cyan-400 flex items-center justify-center">
<Wind className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-lg font-bold text-gray-800">باد و رطوبت</p>
<p className="text-sm text-cyan-600">
<p className="text-sm font-bold text-gray-800">باد و رطوبت</p>
<p className="text-xs text-cyan-600">
{windSpeed > 40 ? '💨 باد شدید!' :
windSpeed > 20 ? '🍃 وزش باد' :
'😌 آرام'}
@@ -213,15 +213,14 @@ export function WindHumidityCard({ weatherData }: { weatherData: WeatherData })
</div>
<div className="flex justify-between items-end">
<div>
<p className="text-3xl font-bold text-gray-800">{toPersianDigits(Math.round(windSpeed))}</p>
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(windSpeed))}</p>
<p className="text-xs text-gray-500">کیلومتر/ساعت باد</p>
</div>
<div className="text-left">
<p className="text-3xl font-bold text-blue-500">{toPersianDigits(humidity)}%</p>
<p className="text-2xl font-bold text-blue-500">{toPersianDigits(humidity)}%</p>
<p className="text-xs text-gray-500">رطوبت هوا</p>
</div>
</div>
</div>
)
}

View File

@@ -70,7 +70,7 @@ export function CodeInput({
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"
className="w-14 h-14 text-center text-2xl font-bold rounded-lg border 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-150"
disabled={disabled}
/>
))}

View File

@@ -43,7 +43,7 @@ export function FormInput({
<input
id={inputId}
className={cn(
'w-full px-4 py-3 rounded-xl border-2 transition-all duration-200',
'w-full px-4 py-3 rounded-lg border transition-all duration-150',
error
? 'border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-500/20 bg-red-50'
: 'border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20',

View File

@@ -48,7 +48,7 @@ export function MobileInput({
type="tel"
inputMode="numeric"
className={cn(
'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',
'w-full px-4 py-3 rounded-lg border 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-150 text-left',
className
)}
placeholder="09123456789"

View File

@@ -45,7 +45,7 @@ export function SearchInput({
value={value}
onChange={(e) => onValueChange(e.target.value)}
placeholder={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"
className="w-full pr-12 pl-4 py-3 rounded-lg border 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-150"
{...props}
/>
{showClearButton && value && (

View File

@@ -0,0 +1,46 @@
"use client"
import { ReactNode } from 'react'
import { LucideIcon } from 'lucide-react'
import { TopHeader } from './TopHeader'
import { BottomNav } from './BottomNav'
type AppShellProps = {
children: ReactNode
pageTitle: string
pageIcon: LucideIcon
pageInfo?: string
iconGradient?: string
hideBottomNav?: boolean
}
export function AppShell({
children,
pageTitle,
pageIcon,
pageInfo,
iconGradient,
hideBottomNav = false
}: AppShellProps) {
return (
<div className="min-h-screen flex flex-col bg-gradient-to-b from-gray-50 to-white">
<TopHeader
pageTitle={pageTitle}
pageIcon={pageIcon}
pageInfo={pageInfo}
iconGradient={iconGradient}
/>
<main className="flex-1 px-4 py-4 md:px-6 md:py-6 max-w-7xl mx-auto w-full">
{children}
</main>
{!hideBottomNav && <BottomNav />}
{/* Spacer for bottom nav */}
{!hideBottomNav && (
<div className="h-20" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} />
)}
</div>
)
}

View File

@@ -0,0 +1,101 @@
"use client"
import { usePathname, useRouter } from 'next/navigation'
import { User, Bell, BarChart3, CloudSun } from 'lucide-react'
import { useDevice } from './DeviceProvider'
import { cn } from '@/lib/utils'
type NavItem = {
label: string
icon: typeof User
route: string
matchPatterns: string[] // Routes that should highlight this tab
}
const navItems: NavItem[] = [
{
label: 'پروفایل',
icon: User,
route: '/profile',
matchPatterns: ['/profile']
},
{
label: 'هشدارها',
icon: Bell,
route: '/alert-settings',
matchPatterns: ['/alert-settings']
},
{
label: 'گزارش',
icon: BarChart3,
route: '/daily-report',
matchPatterns: ['/daily-report', '/calendar', '/day-details', '/device-settings']
},
{
label: 'آب و هوا',
icon: CloudSun,
route: '/weather',
matchPatterns: ['/weather']
}
]
export function BottomNav() {
const pathname = usePathname()
const router = useRouter()
const { deviceId } = useDevice()
const getActiveIndex = () => {
const idx = navItems.findIndex(item =>
item.matchPatterns.some(pattern => pathname.startsWith(pattern))
)
return idx >= 0 ? idx : 2 // Default to Report (index 2) if no match
}
const activeIndex = getActiveIndex()
const handleNavigate = (item: NavItem) => {
const params = deviceId ? `?deviceId=${deviceId}` : ''
router.push(`${item.route}${params}`)
}
return (
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200"
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
<div className="max-w-7xl mx-auto flex items-center justify-around h-14">
{navItems.map((item, index) => {
const isActive = index === activeIndex
const Icon = item.icon
return (
<button
key={item.route}
onClick={() => handleNavigate(item)}
className={cn(
'flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-all duration-150',
'min-w-0 min-h-0 py-1',
isActive
? 'text-green-600'
: 'text-gray-400 hover:text-gray-500'
)}
>
<div className={cn(
'rounded-md p-1.5 transition-colors duration-150',
isActive && 'bg-green-50'
)}>
<Icon className={cn(
'w-5 h-5 transition-transform duration-150',
isActive && 'scale-110'
)} />
</div>
<span className={cn(
'text-[10px] font-medium leading-tight transition-colors duration-150',
isActive ? 'text-green-600' : 'text-gray-400'
)}>
{item.label}
</span>
</button>
)
})}
</div>
</nav>
)
}

View File

@@ -0,0 +1,51 @@
"use client"
import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'
const STORAGE_KEY_DEVICE_ID = 'selectedDeviceId'
const STORAGE_KEY_DEVICE_NAME = 'selectedDeviceName'
type DeviceContextType = {
deviceId: number
deviceName: string
setDevice: (id: number, name: string) => void
}
const DeviceContext = createContext<DeviceContextType>({
deviceId: 0,
deviceName: '',
setDevice: () => {}
})
export function DeviceProvider({ children }: { children: ReactNode }) {
const [deviceId, setDeviceId] = useState<number>(0)
const [deviceName, setDeviceName] = useState<string>('')
// Initialize from localStorage on mount
useEffect(() => {
const savedId = localStorage.getItem(STORAGE_KEY_DEVICE_ID)
const savedName = localStorage.getItem(STORAGE_KEY_DEVICE_NAME)
if (savedId) {
setDeviceId(Number(savedId))
setDeviceName(savedName || '')
}
}, [])
const setDevice = useCallback((id: number, name: string) => {
setDeviceId(id)
setDeviceName(name)
// Persist to localStorage
localStorage.setItem(STORAGE_KEY_DEVICE_ID, String(id))
localStorage.setItem(STORAGE_KEY_DEVICE_NAME, name)
}, [])
return (
<DeviceContext.Provider value={{ deviceId, deviceName, setDevice }}>
{children}
</DeviceContext.Provider>
)
}
export function useDevice() {
return useContext(DeviceContext)
}

View File

@@ -0,0 +1,57 @@
"use client"
import { LucideIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useDevice } from './DeviceProvider'
type TopHeaderProps = {
pageTitle: string
pageIcon: LucideIcon
pageInfo?: string
iconGradient?: string
className?: string
}
export function TopHeader({
pageTitle,
pageIcon: Icon,
pageInfo,
iconGradient = 'from-indigo-500 to-purple-600',
className
}: TopHeaderProps) {
const { deviceName } = useDevice()
return (
<header className={cn('sticky top-0 z-40 bg-white border-b border-gray-200', className)}>
<div className="max-w-7xl mx-auto px-4 py-2 flex items-center justify-between gap-3">
{/* Right: Device Name */}
<div className="text-xs text-gray-500 font-medium truncate min-w-0 flex-1">
{deviceName || 'دستگاه'}
</div>
{/* Center: Icon + Title + PageInfo */}
<div className="flex items-center gap-2 min-w-0 flex-shrink-0">
<div className={cn(
'inline-flex items-center justify-center w-7 h-7 rounded-md',
iconGradient
)}>
<Icon className="w-4 h-4 text-white" />
</div>
<div className="flex items-center gap-2">
<h1 className="text-sm md:text-base font-bold text-gray-900 whitespace-nowrap">
{pageTitle}
</h1>
{pageInfo && (
<span className="text-[11px] text-gray-500 hidden sm:inline">
{pageInfo}
</span>
)}
</div>
</div>
{/* Left: spacer for balance (empty) */}
<div className="flex-1" />
</div>
</header>
)
}

View File

@@ -0,0 +1,4 @@
export { AppShell } from './AppShell'
export { BottomNav } from './BottomNav'
export { TopHeader } from './TopHeader'
export { DeviceProvider, useDevice } from './DeviceProvider'

View File

@@ -42,10 +42,10 @@ export function Pagination({
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className={cn(
'px-4 py-2 rounded-xl transition-all duration-200 flex items-center gap-2',
'px-4 py-2 rounded-lg transition-all duration-150 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'
: 'bg-white border border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
)}
>
<ChevronRight className="w-4 h-4" />
@@ -58,10 +58,10 @@ export function Pagination({
key={pageNum}
onClick={() => onPageChange(pageNum)}
className={cn(
'w-10 h-10 rounded-xl transition-all duration-200',
'w-10 h-10 rounded-lg transition-all duration-150',
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'
? 'bg-green-600 text-white'
: 'bg-white border border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
)}
>
{pageNum}
@@ -73,10 +73,10 @@ export function Pagination({
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className={cn(
'px-4 py-2 rounded-xl transition-all duration-200 flex items-center gap-2',
'px-4 py-2 rounded-lg transition-all duration-150 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'
: 'bg-white border border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
)}
>
بعدی

View File

@@ -21,8 +21,7 @@ export function SettingsSection({
<div className={cn('space-y-5', className)}>
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
<div className={cn(
'w-10 h-10 bg-gradient-to-br rounded-lg flex items-center justify-center',
iconGradient
'w-9 h-9 rounded-md flex items-center justify-center bg-gray-600',
)}>
<Icon className="w-5 h-5 text-white" />
</div>

View File

@@ -30,9 +30,9 @@ export function ResendButton({
onClick={onResend}
disabled={!canResend || loading}
className={cn(
'w-full flex items-center justify-center gap-2 py-2.5 rounded-xl font-medium transition-all duration-200',
'w-full flex items-center justify-center gap-2 py-2.5 rounded-lg font-medium transition-all duration-150',
canResend && !loading
? 'bg-blue-500 hover:bg-blue-600 text-white shadow-md hover:shadow-lg'
? 'bg-blue-500 hover:bg-blue-600 text-white'
: 'bg-gray-100 text-gray-400 cursor-not-allowed',
className
)}

View File

@@ -13,10 +13,13 @@ import type {
AlertConditionDto,
CreateAlertConditionDto,
UpdateAlertConditionDto,
PowerOutageSummaryDto,
} from './types'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'https://ghback.nabaksoft.ir'
console.log('api-base: ', API_BASE);
async function http<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, { ...init, headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) } })
if (!res.ok) {
@@ -33,7 +36,14 @@ async function http<T>(url: string, init?: RequestInit): Promise<T> {
error.status = res.status
throw error
}
return res.json() as Promise<T>
// Check if response has content
const text = await res.text()
if (!text) {
return undefined as T // or return null as T, depending on your preference
}
return JSON.parse(text) as T
}
export const api = {
@@ -65,7 +75,12 @@ export const api = {
updateDeviceSettings: (dto: DeviceSettingsDto) => http<void>(`${API_BASE}/api/devicesettings`, { method: 'PUT', body: JSON.stringify(dto) }),
// Authentication
sendCode: (request: SendCodeRequest) => http<SendCodeResponse>(`${API_BASE}/api/auth/send-code`, { method: 'POST', body: JSON.stringify(request) }),
sendCode: (request: SendCodeRequest) => {
const url = `${API_BASE}/api/auth/send-code`;
console.log('posting to ' + url);
return http<SendCodeResponse>(url, { method: 'POST', body: JSON.stringify(request) })
},
verifyCode: (request: VerifyCodeRequest) => http<VerifyCodeResponse>(`${API_BASE}/api/auth/verify-code`, { method: 'POST', body: JSON.stringify(request) }),
canResend: (mobile: string) => http<{ canResend: boolean }>(`${API_BASE}/api/auth/can-resend?mobile=${encodeURIComponent(mobile)}`),
@@ -98,6 +113,14 @@ export const api = {
updateAlertCondition: (dto: UpdateAlertConditionDto) =>
http<void>(`${API_BASE}/api/alertconditions`, { method: 'PUT', body: JSON.stringify(dto) }),
deleteAlertCondition: (id: number) =>
http<void>(`${API_BASE}/api/alertconditions/${id}`, { method: 'DELETE' })
http<void>(`${API_BASE}/api/alertconditions/${id}`, { method: 'DELETE' }),
// Power Outage
getPowerOutageSummary: (deviceId: number, startUtc?: string, endUtc?: string) => {
const params = new URLSearchParams({ deviceId: String(deviceId) })
if (startUtc) params.set('startUtc', startUtc)
if (endUtc) params.set('endUtc', endUtc)
return http<PowerOutageSummaryDto>(`${API_BASE}/api/PowerOutage/summary?${params.toString()}`)
},
}

View File

@@ -149,3 +149,32 @@ export type UpdateAlertConditionDto = {
rules: CreateAlertRuleRequest[]
}
// Power Outage Types
export type PowerOutageLogDto = {
id: number
deviceId: number
deviceName: string
previousPower: number
currentPower: number
eventType: string // "Connected" or "Disconnected"
eventTypePersian: string // "وصل" or "قطع"
timestampUtc: string
serverTimestampUtc: string
persianDate: string
}
export type PowerOutagePairDto = {
disconnectedAt: string
connectedAt: string | null
durationSeconds: number | null
}
export type PowerOutageSummaryDto = {
deviceId: number
totalOutageCount: number
totalDowntimeSeconds: number
startDateUtc: string | null
endDateUtc: string | null
outages: PowerOutagePairDto[]
}