diff --git a/plans/bottom-nav-and-header-restructure.md b/plans/bottom-nav-and-header-restructure.md new file mode 100644 index 0000000..7a637e0 --- /dev/null +++ b/plans/bottom-nav-and-header-restructure.md @@ -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
Tab: Report] + + DailyReport -->|BottomNav: Alerts| AlertSettings[/alert-settings
Tab: Alerts] + DailyReport -->|BottomNav: Profile| Profile[/profile
Tab: Profile] + DailyReport -->|BottomNav: Weather| Weather[/weather
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`. diff --git a/public/sw.js b/public/sw.js index 8cf56d0..83bbbb5 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ -const CACHE_NAME = 'greenhome-1766182760520'; -const STATIC_CACHE_NAME = 'greenhome-static-1766182760520'; +const CACHE_NAME = 'greenhome-1778524870692'; +const STATIC_CACHE_NAME = 'greenhome-static-1778524870692'; // Static assets to cache on install const STATIC_FILES_TO_CACHE = [ diff --git a/public/version.json b/public/version.json index 9b633ad..da8a2a6 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "1766182760520" + "version": "1778524870692" } \ No newline at end of file diff --git a/src/app/alert-settings/page.tsx b/src/app/alert-settings/page.tsx index 8125ead..2d4cbfe 100644 --- a/src/app/alert-settings/page.tsx +++ b/src/app/alert-settings/page.tsx @@ -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 return ( -
-
- - افزودن هشدار - - } - /> - -
-
-

لیست هشدارها ({alerts.length})

-
- - {alerts.length === 0 ? ( - افزودن اولین هشدار} - /> - ) : ( -
- {alerts.map(alert => ( - - ))} + <> + +
+
+
+

لیست هشدارها ({alerts.length})

+
- )} + + {alerts.length === 0 ? ( + افزودن اولین هشدار} + /> + ) : ( +
+ {alerts.map(alert => ( + + ))} +
+ )} +
-
+ -
+ ) } diff --git a/src/app/calendar/page.tsx b/src/app/calendar/page.tsx index 2e76318..92cb757 100644 --- a/src/app/calendar/page.tsx +++ b/src/app/calendar/page.tsx @@ -5,10 +5,11 @@ 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 = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'] @@ -75,16 +76,12 @@ export default function CalendarPage() { } return ( -
+
- {/* Header */} - - - {/* Main Card */} {/* Year Selector */} @@ -130,6 +127,6 @@ export default function CalendarPage() {
-
+ ) } \ No newline at end of file diff --git a/src/app/daily-report/page.tsx b/src/app/daily-report/page.tsx index 94a2794..1a50f52 100644 --- a/src/app/daily-report/page.tsx +++ b/src/app/daily-report/page.tsx @@ -6,14 +6,14 @@ import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCur 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 }))) @@ -201,50 +201,43 @@ function DailyReportContent() { if (!selectedDate) { return ( -
-
- -
تاریخ انتخاب نشده است
- + +
+
+ +
تاریخ انتخاب نشده است
+ +
-
+ ) } return ( -
-
- {/* Header */} - - - تنظیمات هشدار - - } - /> - + +
{/* Date Navigation Buttons */} - {selectedDate && ( - - )} + {/* Tabs */}
-
+ ) } diff --git a/src/app/day-details/page.tsx b/src/app/day-details/page.tsx index 1f36c53..66bd0a9 100644 --- a/src/app/day-details/page.tsx +++ b/src/app/day-details/page.tsx @@ -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 ( -
+
- {/* Header */} - - - {/* Calendar Grid */} {/* Weekday Headers */} @@ -138,7 +135,7 @@ function DayDetailsContent() { />
-
+ ) } diff --git a/src/app/device-settings/page.tsx b/src/app/device-settings/page.tsx index 5f03479..d423b63 100644 --- a/src/app/device-settings/page.tsx +++ b/src/app/device-settings/page.tsx @@ -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 ( - - } - /> + + + ) } return ( -
+
- {/* Header */} - - - - تنظیمات هشدار - - } - /> - {/* Messages */} {error && } {success && } @@ -252,7 +241,7 @@ export default function DeviceSettingsPage() { )}
-
+ ) } diff --git a/src/app/devices/page.tsx b/src/app/devices/page.tsx index 1c18fdf..f46389a 100644 --- a/src/app/devices/page.tsx +++ b/src/app/devices/page.tsx @@ -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 | null>(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(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() {
{/* Header */} - - - خروج - - } - iconGradient="from-green-500 to-green-600" - /> +
+
+
+
+ +
+
+

+ انتخاب دستگاه +

+ {user && ( +

+ {user.name} {user.family} ({user.mobile}) +

+ )} +
+
+
+ +
+
+
{/* Search */}
@@ -174,16 +194,13 @@ export default function DevicesPage() { {pagedResult && pagedResult.items.length > 0 ? ( <>
- {pagedResult.items.map((device) => { - const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}` - return ( - - ) - })} + {pagedResult.items.map((device) => ( + handleDeviceSelect(device)} + /> + ))}
{/* Pagination */} @@ -203,11 +220,6 @@ export default function DevicesPage() { /> ) )} - - {/* Back to Home */} -
- -
) diff --git a/src/app/globals.css b/src/app/globals.css index 5172000..b9d0f19 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -156,4 +156,24 @@ 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; + } } \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f479197..d29c9b4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 - {children} + + {children} + ) diff --git a/src/app/page.tsx b/src/app/page.tsx index 38fa997..175589e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 } diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx new file mode 100644 index 0000000..2cc2388 --- /dev/null +++ b/src/app/profile/page.tsx @@ -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(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 ( + +
+ {/* User Info Card */} + +
+
+ +
+
+

+ {user ? `${user.name} ${user.family}` : 'کاربر'} +

+

+ + {user?.mobile || '---'} +

+
+
+ +
+
+ نقش کاربری + + + {user?.role === 1 ? 'مدیر' : 'کاربر'} + +
+
+
+ + {/* Actions */} + +
+ {/* Select Device — always visible so user can change device */} + + + + + +
+
+
+
+ ) +} diff --git a/src/app/weather/page.tsx b/src/app/weather/page.tsx new file mode 100644 index 0000000..41b6489 --- /dev/null +++ b/src/app/weather/page.tsx @@ -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(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [expandedDayIndex, setExpandedDayIndex] = useState(null) + const [locationName, setLocationName] = useState('در حال دریافت...') + + 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 ( + +
+ {/* Location Header */} +
+
+ + {locationName} +
+ +
+ + {loading && ( + + )} + + {error && ( + + تلاش مجدد + + } + /> + )} + + {weatherData && !loading && ( + + )} +
+
+ ) +} diff --git a/src/components/cards/DeviceCard.tsx b/src/components/cards/DeviceCard.tsx index 3d18201..59ab9ed 100644 --- a/src/components/cards/DeviceCard.tsx +++ b/src/components/cards/DeviceCard.tsx @@ -4,14 +4,48 @@ 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 ( +
{ 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 || ''}`} + > +
+
+ +
+
+

+ {device.deviceName} +

+

+ {device.location || 'بدون موقعیت'} +

+
+ {device.userName} {device.userFamily} +
+
+
+ +
+
+
+
+ ) + } + return (
diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..0440b61 --- /dev/null +++ b/src/components/layout/AppShell.tsx @@ -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 ( +
+ + +
+ {children} +
+ + {!hideBottomNav && } + + {/* Spacer for bottom nav */} + {!hideBottomNav && ( +
+ )} +
+ ) +} diff --git a/src/components/layout/BottomNav.tsx b/src/components/layout/BottomNav.tsx new file mode 100644 index 0000000..b76e14d --- /dev/null +++ b/src/components/layout/BottomNav.tsx @@ -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 ( + + ) +} diff --git a/src/components/layout/DeviceProvider.tsx b/src/components/layout/DeviceProvider.tsx new file mode 100644 index 0000000..6e743db --- /dev/null +++ b/src/components/layout/DeviceProvider.tsx @@ -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({ + deviceId: 0, + deviceName: '', + setDevice: () => {} +}) + +export function DeviceProvider({ children }: { children: ReactNode }) { + const [deviceId, setDeviceId] = useState(0) + const [deviceName, setDeviceName] = useState('') + + // 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 ( + + {children} + + ) +} + +export function useDevice() { + return useContext(DeviceContext) +} diff --git a/src/components/layout/TopHeader.tsx b/src/components/layout/TopHeader.tsx new file mode 100644 index 0000000..bec26ab --- /dev/null +++ b/src/components/layout/TopHeader.tsx @@ -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 ( +
+
+ {/* Right: Device Name */} +
+ {deviceName || 'دستگاه'} +
+ + {/* Center: Icon + Title + PageInfo */} +
+
+ +
+
+

+ {pageTitle} +

+ {pageInfo && ( + + {pageInfo} + + )} +
+
+ + {/* Left: spacer for balance (empty) */} +
+
+
+ ) +} diff --git a/src/components/layout/index.ts b/src/components/layout/index.ts new file mode 100644 index 0000000..ffbb37b --- /dev/null +++ b/src/components/layout/index.ts @@ -0,0 +1,4 @@ +export { AppShell } from './AppShell' +export { BottomNav } from './BottomNav' +export { TopHeader } from './TopHeader' +export { DeviceProvider, useDevice } from './DeviceProvider'