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
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
This commit is contained in:
282
plans/bottom-nav-and-header-restructure.md
Normal file
282
plans/bottom-nav-and-header-restructure.md
Normal 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`.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
const CACHE_NAME = 'greenhome-1766182760520';
|
const CACHE_NAME = 'greenhome-1778524870692';
|
||||||
const STATIC_CACHE_NAME = 'greenhome-static-1766182760520';
|
const STATIC_CACHE_NAME = 'greenhome-static-1778524870692';
|
||||||
|
|
||||||
// Static assets to cache on install
|
// Static assets to cache on install
|
||||||
const STATIC_FILES_TO_CACHE = [
|
const STATIC_FILES_TO_CACHE = [
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "1766182760520"
|
"version": "1778524870692"
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { useEffect, useState, useCallback, Suspense } from 'react'
|
import { useEffect, useState, useCallback, Suspense } from 'react'
|
||||||
import { useSearchParams } from 'next/navigation'
|
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 { Bell, Plus, AlertTriangle } from 'lucide-react'
|
||||||
import Loading from '@/components/Loading'
|
import Loading from '@/components/Loading'
|
||||||
import { PageHeader, EmptyState } from '@/components/common'
|
import { EmptyState } from '@/components/common'
|
||||||
import { confirmDialog } from '@/components/utils'
|
import { confirmDialog } from '@/components/utils'
|
||||||
import { AlertListItem } from '@/components/alert-settings/AlertListItem'
|
import { AlertListItem } from '@/components/alert-settings/AlertListItem'
|
||||||
import { AlertFormModal } from '@/components/alert-settings/AlertFormModal'
|
import { AlertFormModal } from '@/components/alert-settings/AlertFormModal'
|
||||||
|
import { AppShell } from '@/components/layout'
|
||||||
|
|
||||||
function AlertSettingsContent() {
|
function AlertSettingsContent() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
@@ -102,24 +103,20 @@ function AlertSettingsContent() {
|
|||||||
if (loading) return <Loading message="در حال بارگذاری تنظیمات هشدار..." />
|
if (loading) return <Loading message="در حال بارگذاری تنظیمات هشدار..." />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen p-4 md:p-6 bg-gray-50">
|
<>
|
||||||
<div className="max-w-7xl mx-auto space-y-6">
|
<AppShell
|
||||||
<PageHeader
|
pageTitle="تنظیمات هشدارها"
|
||||||
icon={Bell}
|
pageIcon={Bell}
|
||||||
title="تنظیمات هشدارها"
|
|
||||||
subtitle="مدیریت شرایط و هشدارهای دستگاه"
|
|
||||||
iconGradient="from-orange-500 to-red-600"
|
iconGradient="from-orange-500 to-red-600"
|
||||||
action={
|
>
|
||||||
|
<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}
|
<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">
|
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" /> افزودن هشدار
|
<Plus className="w-5 h-5" /> افزودن هشدار
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{alerts.length === 0 ? (
|
{alerts.length === 0 ? (
|
||||||
@@ -135,6 +132,7 @@ function AlertSettingsContent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
|
||||||
<AlertFormModal
|
<AlertFormModal
|
||||||
isOpen={modal.open}
|
isOpen={modal.open}
|
||||||
@@ -148,7 +146,7 @@ function AlertSettingsContent() {
|
|||||||
onRemoveRule={removeRule}
|
onRemoveRule={removeRule}
|
||||||
onUpdateRule={updateRule}
|
onUpdateRule={updateRule}
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { api } from '@/lib/api'
|
|||||||
import { getCurrentPersianYear } from '@/lib/date/persian-date'
|
import { getCurrentPersianYear } from '@/lib/date/persian-date'
|
||||||
import { Calendar as CalendarIcon, Database, TrendingUp } from 'lucide-react'
|
import { Calendar as CalendarIcon, Database, TrendingUp } from 'lucide-react'
|
||||||
import Loading from '@/components/Loading'
|
import Loading from '@/components/Loading'
|
||||||
import { PageHeader, BackLink, Card } from '@/components/common'
|
import { Card } from '@/components/common'
|
||||||
import { YearSelector } from '@/components/calendar'
|
import { YearSelector } from '@/components/calendar'
|
||||||
import { MonthCard } from '@/components/cards'
|
import { MonthCard } from '@/components/cards'
|
||||||
import { StatsCard } from '@/components/cards'
|
import { StatsCard } from '@/components/cards'
|
||||||
|
import { AppShell } from '@/components/layout'
|
||||||
|
|
||||||
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
||||||
|
|
||||||
@@ -75,16 +76,12 @@ export default function CalendarPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen p-4 md:p-6">
|
<AppShell
|
||||||
<div className="max-w-6xl mx-auto">
|
pageTitle="انتخاب سال و ماه"
|
||||||
{/* Header */}
|
pageIcon={CalendarIcon}
|
||||||
<BackLink href="/" label="بازگشت به صفحه اصلی" />
|
|
||||||
<PageHeader
|
|
||||||
icon={CalendarIcon}
|
|
||||||
title="انتخاب سال و ماه"
|
|
||||||
iconGradient="from-green-500 to-green-600"
|
iconGradient="from-green-500 to-green-600"
|
||||||
/>
|
>
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
{/* Main Card */}
|
{/* Main Card */}
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
{/* Year Selector */}
|
{/* Year Selector */}
|
||||||
@@ -130,6 +127,6 @@ export default function CalendarPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -6,14 +6,14 @@ import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCur
|
|||||||
import { formatPersianDate, ensureDateFormat } from '@/lib/format/persian-date'
|
import { formatPersianDate, ensureDateFormat } from '@/lib/format/persian-date'
|
||||||
import { TABS, TabType } from '@/features/daily-report'
|
import { TABS, TabType } from '@/features/daily-report'
|
||||||
import { detectDataGaps } from '@/features/daily-report/utils'
|
import { detectDataGaps } from '@/features/daily-report/utils'
|
||||||
import { BarChart3, Bell, CalendarIcon, ChevronRight } from 'lucide-react'
|
import { BarChart3, CalendarIcon, ChevronRight } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
|
||||||
import Loading from '@/components/Loading'
|
import Loading from '@/components/Loading'
|
||||||
import { SummaryTab } from '@/components/daily-report'
|
import { SummaryTab } from '@/components/daily-report'
|
||||||
import { fetchForecastWeather, isToday as checkIsToday, WeatherData } from '@/features/weather'
|
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 { DateNavigation } from '@/components/navigation'
|
||||||
import { usePullToRefresh } from '@/hooks/usePullToRefresh'
|
import { usePullToRefresh } from '@/hooks/usePullToRefresh'
|
||||||
|
import { AppShell } from '@/components/layout'
|
||||||
|
|
||||||
// Lazy load heavy components
|
// Lazy load heavy components
|
||||||
const ChartsTab = lazy(() => import('@/components/daily-report/ChartsTab').then(m => ({ default: m.ChartsTab })))
|
const ChartsTab = lazy(() => import('@/components/daily-report/ChartsTab').then(m => ({ default: m.ChartsTab })))
|
||||||
@@ -201,7 +201,12 @@ function DailyReportContent() {
|
|||||||
|
|
||||||
if (!selectedDate) {
|
if (!selectedDate) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4">
|
<AppShell
|
||||||
|
pageTitle="گزارش روزانه"
|
||||||
|
pageIcon={BarChart3}
|
||||||
|
iconGradient="from-indigo-500 to-purple-600"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center p-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<CalendarIcon className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
<CalendarIcon className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||||
<div className="text-lg text-red-600 mb-4">تاریخ انتخاب نشده است</div>
|
<div className="text-lg text-red-600 mb-4">تاریخ انتخاب نشده است</div>
|
||||||
@@ -214,37 +219,25 @@ function DailyReportContent() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen p-4 md:p-6">
|
<AppShell
|
||||||
<div className="max-w-7xl mx-auto space-y-6">
|
pageTitle="گزارش روزانه"
|
||||||
{/* Header */}
|
pageIcon={BarChart3}
|
||||||
<PageHeader
|
pageInfo={selectedDate}
|
||||||
icon={BarChart3}
|
|
||||||
title="گزارش روزانه"
|
|
||||||
iconGradient="from-indigo-500 to-purple-600"
|
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" />
|
<div className="space-y-6">
|
||||||
<span className="hidden sm:inline">تنظیمات هشدار</span>
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Date Navigation Buttons */}
|
{/* Date Navigation Buttons */}
|
||||||
{selectedDate && (
|
|
||||||
<DateNavigation
|
<DateNavigation
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
onPrevious={goToPreviousDay}
|
onPrevious={goToPreviousDay}
|
||||||
onNext={goToNextDay}
|
onNext={goToNextDay}
|
||||||
onCalendar={goToCalendar}
|
onCalendar={goToCalendar}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<Tabs
|
<Tabs
|
||||||
@@ -273,7 +266,7 @@ function DailyReportContent() {
|
|||||||
}}
|
}}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { api } from '@/lib/api'
|
|||||||
import { getCurrentPersianYear, getCurrentPersianMonth, getPersianMonthStartWeekday, getPersianMonthDays } from '@/lib/date/persian-date'
|
import { getCurrentPersianYear, getCurrentPersianMonth, getPersianMonthStartWeekday, getPersianMonthDays } from '@/lib/date/persian-date'
|
||||||
import { Calendar as CalendarIcon, Database } from 'lucide-react'
|
import { Calendar as CalendarIcon, Database } from 'lucide-react'
|
||||||
import Loading from '@/components/Loading'
|
import Loading from '@/components/Loading'
|
||||||
import { PageHeader, BackLink, Card } from '@/components/common'
|
import { Card } from '@/components/common'
|
||||||
import { WeekdayHeaders } from '@/components/calendar'
|
import { WeekdayHeaders } from '@/components/calendar'
|
||||||
import { CalendarDayCell, StatsCard } from '@/components/cards'
|
import { CalendarDayCell, StatsCard } from '@/components/cards'
|
||||||
|
import { AppShell } from '@/components/layout'
|
||||||
|
|
||||||
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
||||||
|
|
||||||
@@ -71,16 +72,12 @@ function DayDetailsContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen p-4 md:p-6">
|
<AppShell
|
||||||
<div className="max-w-6xl mx-auto">
|
pageTitle={`${monthNames[month - 1]} ${year}`}
|
||||||
{/* Header */}
|
pageIcon={CalendarIcon}
|
||||||
<BackLink href={`/calendar?deviceId=${deviceId}`} label="بازگشت به تقویم" />
|
|
||||||
<PageHeader
|
|
||||||
icon={CalendarIcon}
|
|
||||||
title={`${monthNames[month - 1]} ${year}`}
|
|
||||||
iconGradient="from-green-500 to-green-600"
|
iconGradient="from-green-500 to-green-600"
|
||||||
/>
|
>
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
{/* Calendar Grid */}
|
{/* Calendar Grid */}
|
||||||
<Card className="overflow-hidden" padding="none">
|
<Card className="overflow-hidden" padding="none">
|
||||||
{/* Weekday Headers */}
|
{/* Weekday Headers */}
|
||||||
@@ -138,7 +135,7 @@ function DayDetailsContent() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { api, DeviceSettingsDto } from '@/lib/api'
|
import { api, DeviceSettingsDto } from '@/lib/api'
|
||||||
import { Settings, Thermometer, Wind, Sun, Droplets, Save, Loader2, AlertCircle, Bell } from 'lucide-react'
|
import { Settings, Thermometer, Wind, Sun, Droplets, Save, Loader2, AlertCircle } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
|
||||||
import Loading from '@/components/Loading'
|
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 { SettingsSection, SettingsInputGroup } from '@/components/settings'
|
||||||
|
import { AppShell } from '@/components/layout'
|
||||||
|
|
||||||
function useQueryParam(name: string) {
|
function useQueryParam(name: string) {
|
||||||
if (typeof window === 'undefined') return null as string | null
|
if (typeof window === 'undefined') return null as string | null
|
||||||
@@ -95,36 +95,25 @@ export default function DeviceSettingsPage() {
|
|||||||
|
|
||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
return (
|
return (
|
||||||
|
<AppShell
|
||||||
|
pageTitle="تنظیمات دستگاه"
|
||||||
|
pageIcon={Settings}
|
||||||
|
iconGradient="from-blue-500 to-blue-600"
|
||||||
|
>
|
||||||
<ErrorMessage
|
<ErrorMessage
|
||||||
message="شناسه دستگاه مشخص نشده است"
|
message="شناسه دستگاه مشخص نشده است"
|
||||||
fullPage
|
|
||||||
action={
|
|
||||||
<BackLink href="/devices" label="بازگشت به انتخاب دستگاه" />
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen p-4 md:p-6">
|
<AppShell
|
||||||
<div className="max-w-5xl mx-auto">
|
pageTitle={`تنظیمات ${deviceName}`}
|
||||||
{/* Header */}
|
pageIcon={Settings}
|
||||||
<BackLink href="/devices" label="بازگشت به انتخاب دستگاه" />
|
|
||||||
<PageHeader
|
|
||||||
icon={Settings}
|
|
||||||
title={`تنظیمات ${deviceName}`}
|
|
||||||
iconGradient="from-blue-500 to-blue-600"
|
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" />
|
<div className="max-w-5xl mx-auto">
|
||||||
<span className="hidden sm:inline">تنظیمات هشدار</span>
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
{error && <ErrorMessage message={error} className="mb-6" />}
|
{error && <ErrorMessage message={error} className="mb-6" />}
|
||||||
{success && <SuccessMessage message={success} className="mb-6" />}
|
{success && <SuccessMessage message={success} className="mb-6" />}
|
||||||
@@ -252,7 +241,7 @@ export default function DeviceSettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ import { api, DeviceDto, PagedResult } from '@/lib/api'
|
|||||||
import { Settings, LogOut } from 'lucide-react'
|
import { Settings, LogOut } from 'lucide-react'
|
||||||
import Loading from '@/components/Loading'
|
import Loading from '@/components/Loading'
|
||||||
import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/date/persian-date'
|
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 { SearchInput } from '@/components/forms'
|
||||||
import { DeviceCard } from '@/components/cards'
|
import { DeviceCard } from '@/components/cards'
|
||||||
import { Pagination } from '@/components/navigation'
|
import { Pagination } from '@/components/navigation'
|
||||||
|
import { useDevice } from '@/components/layout'
|
||||||
|
|
||||||
export default function DevicesPage() {
|
export default function DevicesPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { setDevice } = useDevice()
|
||||||
const [pagedResult, setPagedResult] = useState<PagedResult<DeviceDto> | null>(null)
|
const [pagedResult, setPagedResult] = useState<PagedResult<DeviceDto> | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -22,6 +24,12 @@ export default function DevicesPage() {
|
|||||||
|
|
||||||
const pageSize = 10
|
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) => {
|
const fetchDevices = useCallback(async (page: number, search?: string) => {
|
||||||
if (!user) return
|
if (!user) return
|
||||||
|
|
||||||
@@ -39,9 +47,8 @@ export default function DevicesPage() {
|
|||||||
if (result.items.length === 0 && page === 1) {
|
if (result.items.length === 0 && page === 1) {
|
||||||
setError('شما هیچ دستگاهی ندارید. لطفاً با پشتیبانی تماس بگیرید.')
|
setError('شما هیچ دستگاهی ندارید. لطفاً با پشتیبانی تماس بگیرید.')
|
||||||
} else if (result.items.length === 1 && result.totalCount === 1 && !search) {
|
} else if (result.items.length === 1 && result.totalCount === 1 && !search) {
|
||||||
// Single device - redirect to today's daily report
|
// Single device - set context and redirect to today's daily report
|
||||||
const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}`
|
handleDeviceSelect(result.items[0])
|
||||||
router.push(`/daily-report?deviceId=${result.items[0].id}&date=${encodeURIComponent(today)}`)
|
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
setPagedResult(result)
|
setPagedResult(result)
|
||||||
@@ -52,7 +59,7 @@ export default function DevicesPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [user, router])
|
}, [user, router, handleDeviceSelect])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check authentication
|
// Check authentication
|
||||||
@@ -120,11 +127,24 @@ export default function DevicesPage() {
|
|||||||
<div className="min-h-screen p-4 md:p-8">
|
<div className="min-h-screen p-4 md:p-8">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<PageHeader
|
<div className="mb-6">
|
||||||
icon={Settings}
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
title="انتخاب دستگاه"
|
<div className="flex items-center gap-3">
|
||||||
subtitle={user ? `${user.name} ${user.family} (${user.mobile})` : undefined}
|
<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">
|
||||||
action={
|
<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
|
<button
|
||||||
onClick={handleLogout}
|
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"
|
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"
|
||||||
@@ -132,9 +152,9 @@ export default function DevicesPage() {
|
|||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
خروج
|
خروج
|
||||||
</button>
|
</button>
|
||||||
}
|
</div>
|
||||||
iconGradient="from-green-500 to-green-600"
|
</div>
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -174,16 +194,13 @@ export default function DevicesPage() {
|
|||||||
{pagedResult && pagedResult.items.length > 0 ? (
|
{pagedResult && pagedResult.items.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||||
{pagedResult.items.map((device) => {
|
{pagedResult.items.map((device) => (
|
||||||
const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}`
|
|
||||||
return (
|
|
||||||
<DeviceCard
|
<DeviceCard
|
||||||
key={device.id}
|
key={device.id}
|
||||||
device={device}
|
device={device}
|
||||||
href={`/daily-report?deviceId=${device.id}&date=${encodeURIComponent(today)}`}
|
onClick={() => handleDeviceSelect(device)}
|
||||||
/>
|
/>
|
||||||
)
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
@@ -203,11 +220,6 @@ export default function DevicesPage() {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Back to Home */}
|
|
||||||
<div className="mt-6 text-center">
|
|
||||||
<BackLink href="/" label="بازگشت به صفحه اصلی" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -157,3 +157,23 @@ button, a, [role="button"] {
|
|||||||
background-size: 2000px 100%;
|
background-size: 2000px 100%;
|
||||||
animation: shimmer 2s infinite;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
|
|||||||
import './globals.css'
|
import './globals.css'
|
||||||
import ServiceWorkerRegistration from '@/components/ServiceWorkerRegistration'
|
import ServiceWorkerRegistration from '@/components/ServiceWorkerRegistration'
|
||||||
import UpdateNotification from '@/components/UpdateNotification'
|
import UpdateNotification from '@/components/UpdateNotification'
|
||||||
|
import { DeviceProvider } from '@/components/layout'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'GreenHome',
|
title: 'GreenHome',
|
||||||
@@ -42,7 +43,9 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
|
|||||||
<body className='persian-number'>
|
<body className='persian-number'>
|
||||||
<ServiceWorkerRegistration />
|
<ServiceWorkerRegistration />
|
||||||
<UpdateNotification />
|
<UpdateNotification />
|
||||||
|
<DeviceProvider>
|
||||||
{children}
|
{children}
|
||||||
|
</DeviceProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Smartphone, ArrowLeft } from 'lucide-react'
|
|||||||
import Loading from '@/components/Loading'
|
import Loading from '@/components/Loading'
|
||||||
import { MobileInput } from '@/components/forms'
|
import { MobileInput } from '@/components/forms'
|
||||||
import { ErrorMessage } from '@/components/common'
|
import { ErrorMessage } from '@/components/common'
|
||||||
|
import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/date/persian-date'
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -20,9 +21,35 @@ export default function Home() {
|
|||||||
|
|
||||||
if (token && userStr) {
|
if (token && userStr) {
|
||||||
try {
|
try {
|
||||||
JSON.parse(userStr)
|
const user = JSON.parse(userStr)
|
||||||
// Redirect to devices check
|
|
||||||
|
// 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')
|
router.push('/devices')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// On error, fall back to devices page
|
||||||
|
router.push('/devices')
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// Invalid user data, go to login
|
// Invalid user data, go to login
|
||||||
}
|
}
|
||||||
|
|||||||
138
src/app/profile/page.tsx
Normal file
138
src/app/profile/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
104
src/app/weather/page.tsx
Normal file
104
src/app/weather/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,14 +4,48 @@ import { DeviceDto } from '@/lib/api'
|
|||||||
|
|
||||||
type DeviceCardProps = {
|
type DeviceCardProps = {
|
||||||
device: DeviceDto
|
device: DeviceDto
|
||||||
href: string
|
href?: string
|
||||||
|
onClick?: () => void
|
||||||
className?: string
|
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-2xl shadow-md hover:shadow-xl transition-all duration-300 overflow-hidden border border-gray-100 p-6 relative cursor-pointer ${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>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-1 group-hover:text-green-600 transition-colors">
|
||||||
|
{device.deviceName}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 mb-2">
|
||||||
|
{device.location || 'بدون موقعیت'}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||||
|
<span>{device.userName} {device.userFamily}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Calendar className="w-5 h-5 text-gray-400 group-hover:text-green-600 transition-colors" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-green-500 to-green-600 transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
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 || ''}`}
|
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 || ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
|
|||||||
46
src/components/layout/AppShell.tsx
Normal file
46
src/components/layout/AppShell.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
101
src/components/layout/BottomNav.tsx
Normal file
101
src/components/layout/BottomNav.tsx
Normal 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 shadow-[0_-2px_10px_rgba(0,0,0,0.08)]"
|
||||||
|
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
|
||||||
|
<div className="max-w-7xl mx-auto flex items-center justify-around h-16">
|
||||||
|
{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-200',
|
||||||
|
'min-w-0 min-h-0 py-1',
|
||||||
|
isActive
|
||||||
|
? 'text-green-600'
|
||||||
|
: 'text-gray-400 hover:text-gray-600'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
'rounded-lg p-1.5 transition-colors duration-200',
|
||||||
|
isActive && 'bg-green-50'
|
||||||
|
)}>
|
||||||
|
<Icon className={cn(
|
||||||
|
'w-5 h-5 transition-transform duration-200',
|
||||||
|
isActive && 'scale-110'
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
<span className={cn(
|
||||||
|
'text-[10px] font-medium leading-tight transition-colors duration-200',
|
||||||
|
isActive ? 'text-green-600' : 'text-gray-400'
|
||||||
|
)}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
src/components/layout/DeviceProvider.tsx
Normal file
51
src/components/layout/DeviceProvider.tsx
Normal 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)
|
||||||
|
}
|
||||||
57
src/components/layout/TopHeader.tsx
Normal file
57
src/components/layout/TopHeader.tsx
Normal 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/95 backdrop-blur-md border-b border-gray-200 shadow-sm', 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-8 h-8 bg-gradient-to-br rounded-lg shadow-sm',
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
4
src/components/layout/index.ts
Normal file
4
src/components/layout/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { AppShell } from './AppShell'
|
||||||
|
export { BottomNav } from './BottomNav'
|
||||||
|
export { TopHeader } from './TopHeader'
|
||||||
|
export { DeviceProvider, useDevice } from './DeviceProvider'
|
||||||
Reference in New Issue
Block a user