Compare commits

...

2 Commits

Author SHA1 Message Date
9da69c96b1 refactor(alert-settings): integrate AppShell layout and update cache version
Some checks failed
Deploy MyApp on Same Server / build-and-deploy (push) Has been cancelled
- Replace custom page layout with AppShell component for consistent UI
- Remove unused PageHeader and UpdateAlertConditionDto imports
- Update service worker cache names and version.json to new timestamp
2026-05-11 22:19:30 +03:30
676f1373c1 fix loading issue and api error on empty response 2026-05-11 21:21:21 +03:30
23 changed files with 1046 additions and 174 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 (
<div className="min-h-screen p-4 md:p-6">
<AppShell
pageTitle="انتخاب سال و ماه"
pageIcon={CalendarIcon}
iconGradient="from-green-500 to-green-600"
>
<div className="max-w-6xl mx-auto">
{/* Header */}
<BackLink href="/" label="بازگشت به صفحه اصلی" />
<PageHeader
icon={CalendarIcon}
title="انتخاب سال و ماه"
iconGradient="from-green-500 to-green-600"
/>
{/* Main Card */}
<Card padding="lg">
{/* Year Selector */}
@@ -130,6 +127,6 @@ export default function CalendarPage() {
</div>
</Card>
</div>
</div>
</AppShell>
)
}

View File

@@ -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 (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="text-center">
<CalendarIcon className="w-12 h-12 text-red-500 mx-auto mb-4" />
<div className="text-lg text-red-600 mb-4">تاریخ انتخاب نشده است</div>
<Button
onClick={goToCalendar}
variant="outline"
icon={ChevronRight}
>
بازگشت به تقویم
</Button>
<AppShell
pageTitle="گزارش روزانه"
pageIcon={BarChart3}
iconGradient="from-indigo-500 to-purple-600"
>
<div className="flex items-center justify-center p-4">
<div className="text-center">
<CalendarIcon className="w-12 h-12 text-red-500 mx-auto mb-4" />
<div className="text-lg text-red-600 mb-4">تاریخ انتخاب نشده است</div>
<Button
onClick={goToCalendar}
variant="outline"
icon={ChevronRight}
>
بازگشت به تقویم
</Button>
</div>
</div>
</div>
</AppShell>
)
}
return (
<div className="min-h-screen p-4 md:p-6">
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<PageHeader
icon={BarChart3}
title="گزارش روزانه"
iconGradient="from-indigo-500 to-purple-600"
action={
<Link
href={`/alert-settings?deviceId=${deviceId}`}
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-orange-300 hover:border-orange-500 hover:bg-orange-50 text-orange-700 hover:text-orange-800 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
>
<Bell className="w-5 h-5" />
<span className="hidden sm:inline">تنظیمات هشدار</span>
</Link>
}
/>
<AppShell
pageTitle="گزارش روزانه"
pageIcon={BarChart3}
pageInfo={selectedDate}
iconGradient="from-indigo-500 to-purple-600"
>
<div className="space-y-6">
{/* Date Navigation Buttons */}
{selectedDate && (
<DateNavigation
selectedDate={selectedDate}
onPrevious={goToPreviousDay}
onNext={goToNextDay}
onCalendar={goToCalendar}
/>
)}
<DateNavigation
selectedDate={selectedDate}
onPrevious={goToPreviousDay}
onNext={goToNextDay}
onCalendar={goToCalendar}
/>
{/* Tabs */}
<Tabs
@@ -273,7 +266,7 @@ function DailyReportContent() {
}}
</Tabs>
</div>
</div>
</AppShell>
)
}

View File

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

View File

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

View File

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

View File

@@ -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;
}
}

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

@@ -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 (
<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 (
<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 || ''}`}
>
<div className="flex items-start gap-4">

View File

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

View File

@@ -0,0 +1,101 @@
"use client"
import { usePathname, useRouter } from 'next/navigation'
import { User, Bell, BarChart3, CloudSun } from 'lucide-react'
import { useDevice } from './DeviceProvider'
import { cn } from '@/lib/utils'
type NavItem = {
label: string
icon: typeof User
route: string
matchPatterns: string[] // Routes that should highlight this tab
}
const navItems: NavItem[] = [
{
label: 'پروفایل',
icon: User,
route: '/profile',
matchPatterns: ['/profile']
},
{
label: 'هشدارها',
icon: Bell,
route: '/alert-settings',
matchPatterns: ['/alert-settings']
},
{
label: 'گزارش',
icon: BarChart3,
route: '/daily-report',
matchPatterns: ['/daily-report', '/calendar', '/day-details', '/device-settings']
},
{
label: 'آب و هوا',
icon: CloudSun,
route: '/weather',
matchPatterns: ['/weather']
}
]
export function BottomNav() {
const pathname = usePathname()
const router = useRouter()
const { deviceId } = useDevice()
const getActiveIndex = () => {
const idx = navItems.findIndex(item =>
item.matchPatterns.some(pattern => pathname.startsWith(pattern))
)
return idx >= 0 ? idx : 2 // Default to Report (index 2) if no match
}
const activeIndex = getActiveIndex()
const handleNavigate = (item: NavItem) => {
const params = deviceId ? `?deviceId=${deviceId}` : ''
router.push(`${item.route}${params}`)
}
return (
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200 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>
)
}

View File

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

View File

@@ -0,0 +1,57 @@
"use client"
import { LucideIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useDevice } from './DeviceProvider'
type TopHeaderProps = {
pageTitle: string
pageIcon: LucideIcon
pageInfo?: string
iconGradient?: string
className?: string
}
export function TopHeader({
pageTitle,
pageIcon: Icon,
pageInfo,
iconGradient = 'from-indigo-500 to-purple-600',
className
}: TopHeaderProps) {
const { deviceName } = useDevice()
return (
<header className={cn('sticky top-0 z-40 bg-white/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>
)
}

View File

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

View File

@@ -17,6 +17,8 @@ import type {
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'https://ghback.nabaksoft.ir'
console.log('api-base: ', API_BASE);
async function http<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, { ...init, headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) } })
if (!res.ok) {
@@ -33,7 +35,14 @@ async function http<T>(url: string, init?: RequestInit): Promise<T> {
error.status = res.status
throw error
}
return res.json() as Promise<T>
// Check if response has content
const text = await res.text()
if (!text) {
return undefined as T // or return null as T, depending on your preference
}
return JSON.parse(text) as T
}
export const api = {
@@ -65,7 +74,12 @@ export const api = {
updateDeviceSettings: (dto: DeviceSettingsDto) => http<void>(`${API_BASE}/api/devicesettings`, { method: 'PUT', body: JSON.stringify(dto) }),
// Authentication
sendCode: (request: SendCodeRequest) => http<SendCodeResponse>(`${API_BASE}/api/auth/send-code`, { method: 'POST', body: JSON.stringify(request) }),
sendCode: (request: SendCodeRequest) => {
const url = `${API_BASE}/api/auth/send-code`;
console.log('posting to ' + url);
return http<SendCodeResponse>(url, { method: 'POST', body: JSON.stringify(request) })
},
verifyCode: (request: VerifyCodeRequest) => http<VerifyCodeResponse>(`${API_BASE}/api/auth/verify-code`, { method: 'POST', body: JSON.stringify(request) }),
canResend: (mobile: string) => http<{ canResend: boolean }>(`${API_BASE}/api/auth/can-resend?mobile=${encodeURIComponent(mobile)}`),