pwa setting
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
# Admin Panel PWA
|
||||
|
||||
پنل ادمین (`/admin`) را به یک Progressive Web App تبدیل کن تا کاربران بتوانند آن را روی دستگاه خود نصب کنند و آفلاین هم shell اولیه را ببینند.
|
||||
|
||||
---
|
||||
|
||||
## وضعیت فعلی پروژه
|
||||
|
||||
- **Twig template**: `templates/admin/index.html.twig` — فایل HTML ورودی پنل ادمین
|
||||
- **Webpack Encore** با `addEntry('admin', './assets/admin/index.tsx')`
|
||||
- **Output path**: `public/build/`
|
||||
- **Public path**: `/build`
|
||||
- آیکونهای موجود: `public/favicon.ico` و `public/favicon.png`
|
||||
- دامنه local: `https://clinic-pro.ddev.site`
|
||||
|
||||
**قابلیتهای ۱ تا ۴ قبلاً اجرا شدهاند:**
|
||||
- `public/manifest.json` ✅ موجود است
|
||||
- `public/sw.js` ✅ موجود است
|
||||
- ثبت SW در `assets/admin/index.tsx` ✅ انجام شده
|
||||
- `assets/admin/components/ui/PwaInstallBanner.tsx` ✅ موجود است (برای کاربران logged-in)
|
||||
|
||||
---
|
||||
|
||||
## قابلیتهای مورد نیاز
|
||||
|
||||
### قابلیت ۱ — Web App Manifest
|
||||
|
||||
فایل `public/manifest.json` بساز با این مشخصات:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "ClinicPro Admin",
|
||||
"short_name": "ClinicPro",
|
||||
"description": "پنل مدیریت کلینیک پرو",
|
||||
"start_url": "/admin",
|
||||
"scope": "/admin",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"theme_color": "#6366f1",
|
||||
"background_color": "#f8fafc",
|
||||
"lang": "fa",
|
||||
"dir": "rtl",
|
||||
"icons": [
|
||||
{ "src": "/favicon.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": "/favicon.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- رنگ `theme_color` را از CSS variable `--primary` پروژه بگیر (مقدار واقعی را از `assets/admin/styles.css` بخوان)
|
||||
- در `templates/admin/index.html.twig` این تگها را به `<head>` اضافه کن:
|
||||
```html
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#6366f1">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="ClinicPro">
|
||||
<link rel="apple-touch-icon" href="/favicon.png">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### قابلیت ۲ — Service Worker (Cache Shell)
|
||||
|
||||
یک service worker در `public/sw.js` بنویس. **بدون workbox** — native Service Worker API:
|
||||
|
||||
**استراتژی:**
|
||||
- `install` event: کش کردن shell (فایلهای ضروری برای render اولیه)
|
||||
- `activate` event: پاک کردن کشهای قدیمی
|
||||
- `fetch` event:
|
||||
- درخواستهای API (`/api/`) → **فقط Network** (هرگز کش نکن، اگر آفلاین بود خطا بده)
|
||||
- فایلهای build (`/build/`) → **Cache First** (سریعتر، stale-while-revalidate)
|
||||
- navigation (`/admin*`) → **Network First با fallback** به shell کششده
|
||||
|
||||
**فایلهایی که در install کش میشوند:**
|
||||
```
|
||||
/admin
|
||||
/manifest.json
|
||||
/favicon.png
|
||||
```
|
||||
فایلهای `/build/` را در install کش **نکن** (hash در نام آنهاست و هر deploy عوض میشود) — آنها را در fetch با Cache First مدیریت کن.
|
||||
|
||||
**Cache name** شامل version باشد تا activate بتواند قدیمیها را پاک کند:
|
||||
```js
|
||||
const CACHE_NAME = 'clinicpro-admin-v1';
|
||||
const BUILD_CACHE = 'clinicpro-build-v1';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### قابلیت ۳ — ثبت Service Worker در React
|
||||
|
||||
در `assets/admin/index.tsx` (فایل entry point) بعد از `ReactDOM.render` / `createRoot`:
|
||||
|
||||
```typescript
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.catch(() => { /* silent fail in dev */ });
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**نکته:** registration باید فقط در production یا بهصورت همیشگی باشد — در هر دو حالت کار میکند چون sw.js در `/public` است و مستقیم serve میشود.
|
||||
|
||||
---
|
||||
|
||||
### قابلیت ۴ — Install Prompt Banner (اختیاری اما مطلوب)
|
||||
|
||||
یک کامپوننت کوچک React در `assets/admin/components/ui/PwaInstallBanner.tsx` بساز:
|
||||
|
||||
- `beforeinstallprompt` event را listen میکند
|
||||
- اگر prompt در دسترس بود یک banner کوچک در پایین صفحه نشان میدهد:
|
||||
```
|
||||
[آیکون] ClinicPro را نصب کنید — دسترسی سریعتر [نصب] [×]
|
||||
```
|
||||
- بعد از dismiss در `localStorage` ذخیره کن (`pwa-dismissed`) تا دوباره نشان داده نشود
|
||||
- در `assets/admin/App.tsx` یا `AdminLayout` این کامپوننت را اضافه کن
|
||||
|
||||
**طراحی**: از CSS variables پروژه استفاده کن (`--surface`, `--border`, `--primary`, `--text`). RTL رعایت شود.
|
||||
|
||||
---
|
||||
|
||||
## تست پس از هر قابلیت
|
||||
|
||||
```bash
|
||||
# Build
|
||||
ddev exec yarn dev
|
||||
|
||||
# بررسی manifest
|
||||
curl https://clinic-pro.ddev.site/manifest.json
|
||||
|
||||
# بررسی sw.js
|
||||
curl https://clinic-pro.ddev.site/sw.js | head -5
|
||||
|
||||
# بررسی Twig
|
||||
curl https://clinic-pro.ddev.site/admin | grep manifest
|
||||
```
|
||||
|
||||
**تست PWA در Chrome:**
|
||||
1. مرورگر Chrome → `https://clinic-pro.ddev.site/admin`
|
||||
2. DevTools → Application → Service Workers → باید registered باشد
|
||||
3. DevTools → Application → Manifest → باید parse شده و installable باشد
|
||||
4. آیکون نصب در address bar باید ظاهر شود
|
||||
|
||||
---
|
||||
|
||||
### قابلیت ۵ — نمایش پرامپت نصب در صفحه لاگین
|
||||
|
||||
در صفحه لاگین (`assets/admin/pages/LoginPage.tsx`) یک بخش نصب اپلیکیشن اضافه کن که **همیشه قابل مشاهده** باشد — نه فقط وقتی `beforeinstallprompt` فایر شده.
|
||||
|
||||
**منطق نمایش:**
|
||||
|
||||
- اگر app قبلاً نصب شده (`window.matchMedia('(display-mode: standalone)').matches`) → این بخش را نشان **نده**
|
||||
- اگر `pwa-dismissed` در localStorage بود → این بخش را نشان **نده**
|
||||
- در غیر این صورت همیشه نشان بده
|
||||
|
||||
**دو حالت:**
|
||||
|
||||
۱. **وقتی `beforeinstallprompt` در دسترس است** (Chrome/Edge desktop و Android):
|
||||
- دکمه «نصب اپلیکیشن» نشان بده
|
||||
- با کلیک روی دکمه، native install prompt مرورگر را باز کن
|
||||
|
||||
۲. **وقتی `beforeinstallprompt` در دسترس نیست** (Safari iOS، Firefox، یا قبل از fire شدن event):
|
||||
- یک راهنمای متنی نشان بده:
|
||||
- iOS: «در Safari: دکمه Share را بزن، سپس «Add to Home Screen» را انتخاب کن»
|
||||
- سایر: «در منوی مرورگر گزینه «Install app» یا «Add to Home Screen» را انتخاب کن»
|
||||
|
||||
**طراحی:**
|
||||
|
||||
یک card زیبا زیر فرم لاگین (یا بالای آن در موبایل) با این ظاهر:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ [آیکون ClinicPro] │
|
||||
│ ClinicPro را نصب کنید │
|
||||
│ دسترسی سریعتر — بدون نیاز به مرورگر │
|
||||
│ │
|
||||
│ [دکمه نصب / راهنما] [بعداً] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- از CSS variables پروژه استفاده کن (`--primary-soft`, `--primary-700`, `--surface`, `--border`)
|
||||
- RTL رعایت شود
|
||||
- دکمه «بعداً» مثل قابلیت ۴ عمل کند: `localStorage.setItem('pwa-dismissed', '1')` و بخش را مخفی کند
|
||||
- کامپوننت جداگانه بساز: `assets/admin/components/ui/PwaLoginCard.tsx`
|
||||
- در `LoginPage.tsx` این کامپوننت را import و زیر card اصلی فرم لاگین قرار بده
|
||||
|
||||
**نکته مهم:** این کامپوننت مستقل از `PwaInstallBanner` است — banner برای کاربران logged-in است، این card برای صفحه لاگین است. منطق `beforeinstallprompt` را در یک custom hook مشترک (`assets/admin/hooks/usePwaInstall.ts`) بگذار تا هر دو کامپوننت از آن استفاده کنند و event را دو بار capture نکنند.
|
||||
|
||||
---
|
||||
|
||||
## محدودیتها و نکات
|
||||
|
||||
- **workbox اضافه نکن** — native API کافی است و dependency غیرضروری اضافه نمیکند
|
||||
- **Webpack Encore را تغییر نده** — sw.js مستقیم در `public/` مینشیند و نیازی به bundle ندارد
|
||||
- **DDEV HTTPS**: mkcert باید قبلاً نصب شده باشد (`mkcert -install`)؛ اگر نبود service worker register نمیشود
|
||||
- **Scope**: `/admin` — service worker فقط این path را intercept میکند، `/api/` را تحت تأثیر قرار نمیدهد مگر از داخل scope فراخوانی شود
|
||||
- **آیکونها**: اگر `favicon.png` کوچک بود (زیر 192px)، یک آیکون مناسب در `public/icons/` بساز یا از همان استفاده کن — Chrome حتی با آیکون کوچک هم install prompt نشان میدهد
|
||||
@@ -33,6 +33,7 @@ import MyPatientsPage from './pages/MyPatientsPage';
|
||||
import MyFinancialPage from './pages/MyFinancialPage';
|
||||
import ClinicFormPage from './pages/ClinicFormPage';
|
||||
import PreRegistrationsPage from './pages/PreRegistrationsPage';
|
||||
import PwaInstallBanner from './components/ui/PwaInstallBanner';
|
||||
|
||||
// ── Guards ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -77,6 +78,7 @@ function RoleRoute({ roles, children }: { roles: string[]; children: React.React
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<Routes>
|
||||
{/* Public */}
|
||||
<Route
|
||||
@@ -150,5 +152,7 @@ export default function App() {
|
||||
|
||||
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
|
||||
</Routes>
|
||||
<PwaInstallBanner />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { ArrowDownTrayIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { usePwaInstall } from '../../hooks/usePwaInstall';
|
||||
|
||||
export default function PwaInstallBanner() {
|
||||
const { promptEvent, isInstalled, isDismissed, install, dismiss } = usePwaInstall();
|
||||
|
||||
if (isInstalled || isDismissed || !promptEvent) return null;
|
||||
|
||||
const handleInstall = async () => {
|
||||
await install();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', bottom: 20, right: 20, left: 20, zIndex: 9999,
|
||||
maxWidth: 420, margin: '0 auto',
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r)',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,.12)',
|
||||
padding: '14px 16px',
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
direction: 'rtl',
|
||||
}}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'var(--primary-soft)', color: 'var(--primary-700)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<ArrowDownTrayIcon style={{ width: 18, height: 18 }} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontSize: 13, fontWeight: 700, color: 'var(--text)', margin: 0 }}>نصب ClinicPro</p>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '2px 0 0' }}>دسترسی سریعتر از روی صفحه اصلی</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
style={{ height: 34, padding: '0 14px', borderRadius: 'var(--r-sm)', border: 'none', background: 'var(--primary)', color: 'var(--on-primary)', fontSize: 13, fontWeight: 700, cursor: 'pointer', flexShrink: 0 }}>
|
||||
نصب
|
||||
</button>
|
||||
<button
|
||||
onClick={dismiss}
|
||||
style={{ width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center', border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-3)', borderRadius: 6, flexShrink: 0 }}>
|
||||
<XMarkIcon style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import React, { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ArrowDownTrayIcon, XMarkIcon, CheckIcon } from '@heroicons/react/24/outline';
|
||||
import { usePwaInstall } from '../../hooks/usePwaInstall';
|
||||
|
||||
function detectIOS(): boolean {
|
||||
return /iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
export default function PwaLoginCard() {
|
||||
const { promptEvent, isInstalled, isDismissed, install, dismiss } = usePwaInstall();
|
||||
const [showSteps, setShowSteps] = useState(false);
|
||||
|
||||
if (isInstalled || isDismissed) return null;
|
||||
|
||||
const isIOS = detectIOS();
|
||||
|
||||
const handleInstallClick = async () => {
|
||||
if (promptEvent) {
|
||||
const accepted = await install();
|
||||
if (accepted) return;
|
||||
}
|
||||
// native prompt not available or dismissed → show manual steps
|
||||
setShowSteps(true);
|
||||
};
|
||||
|
||||
const steps = isIOS
|
||||
? [
|
||||
'Safari را باز کنید (فقط Safari از نصب پشتیبانی میکند)',
|
||||
'دکمه Share (مربع با فلش رو به بالا) را در پایین صفحه بزنید',
|
||||
'گزینه «Add to Home Screen» را انتخاب کنید',
|
||||
'«Add» را بزنید — تمام!',
|
||||
]
|
||||
: [
|
||||
'در Chrome منوی سهنقطه (⋮) را در گوشه بالا باز کنید',
|
||||
'گزینه «Install ClinicPro» یا «Install app» را انتخاب کنید',
|
||||
'در پنجرهای که باز میشود «Install» را بزنید',
|
||||
];
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
onClick={dismiss}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 10000,
|
||||
background: 'rgba(0,0,0,0.45)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 20, direction: 'rtl',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: '100%', maxWidth: 380,
|
||||
background: 'var(--surface)',
|
||||
borderRadius: 'var(--r)',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '16px 18px 14px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 10,
|
||||
background: 'var(--primary)', color: 'var(--on-primary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<ArrowDownTrayIcon style={{ width: 18, height: 18 }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 15, fontWeight: 800, color: 'var(--text)' }}>نصب ClinicPro</span>
|
||||
</div>
|
||||
<button onClick={dismiss} style={{
|
||||
width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
border: 'none', background: 'transparent', cursor: 'pointer',
|
||||
color: 'var(--text-3)', borderRadius: 8,
|
||||
}}>
|
||||
<XMarkIcon style={{ width: 18, height: 18 }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: '20px 18px' }}>
|
||||
|
||||
{!showSteps ? (
|
||||
<>
|
||||
{/* App icon + name */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: 20 }}>
|
||||
<div style={{
|
||||
width: 72, height: 72, borderRadius: 18,
|
||||
background: 'var(--primary-soft)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 36, marginBottom: 10,
|
||||
}}>♥</div>
|
||||
<p style={{ fontSize: 16, fontWeight: 800, color: 'var(--text)', margin: 0 }}>ClinicPro</p>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '4px 0 0' }}>پنل مدیریت کلینیک</p>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 13, color: 'var(--text-2)', textAlign: 'center', lineHeight: 1.7, margin: '0 0 20px' }}>
|
||||
اپلیکیشن را روی دستگاه خود نصب کنید و بدون باز کردن مرورگر وارد شوید.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<button
|
||||
onClick={handleInstallClick}
|
||||
style={{
|
||||
width: '100%', height: 46, borderRadius: 'var(--r-sm)',
|
||||
border: 'none', background: 'var(--primary)', color: 'var(--on-primary)',
|
||||
fontSize: 15, fontWeight: 700, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
}}>
|
||||
<ArrowDownTrayIcon style={{ width: 18, height: 18 }} />
|
||||
نصب اپلیکیشن
|
||||
</button>
|
||||
<button
|
||||
onClick={dismiss}
|
||||
style={{
|
||||
width: '100%', height: 40, borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--border)', background: 'transparent',
|
||||
color: 'var(--text-2)', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||
}}>
|
||||
بعداً
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p style={{ fontSize: 13, fontWeight: 700, color: 'var(--text)', margin: '0 0 16px' }}>
|
||||
مراحل نصب:
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 20 }}>
|
||||
{steps.map((step, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||
<div style={{
|
||||
width: 24, height: 24, borderRadius: '50%', flexShrink: 0,
|
||||
background: 'var(--primary-soft)', color: 'var(--primary-700)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 12, fontWeight: 700,
|
||||
}}>
|
||||
{i + 1}
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-2)', lineHeight: 1.6, margin: 0 }}>{step}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={dismiss}
|
||||
style={{
|
||||
width: '100%', height: 44, borderRadius: 'var(--r-sm)',
|
||||
border: 'none', background: 'var(--primary)', color: 'var(--on-primary)',
|
||||
fontSize: 14, fontWeight: 700, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
}}>
|
||||
<CheckIcon style={{ width: 16, height: 16 }} />
|
||||
متوجه شدم
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export interface BeforeInstallPromptEvent extends Event {
|
||||
prompt(): Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
}
|
||||
|
||||
const DISMISSED_KEY = 'pwa-dismissed';
|
||||
|
||||
export function usePwaInstall() {
|
||||
const [promptEvent, setPromptEvent] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [isInstalled, setIsInstalled] = useState(false);
|
||||
const [isDismissed, setIsDismissed] = useState(() => !!localStorage.getItem(DISMISSED_KEY));
|
||||
|
||||
useEffect(() => {
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||
setIsInstalled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setPromptEvent(e as BeforeInstallPromptEvent);
|
||||
};
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handler);
|
||||
return () => window.removeEventListener('beforeinstallprompt', handler);
|
||||
}, []);
|
||||
|
||||
const install = async (): Promise<boolean> => {
|
||||
if (!promptEvent) return false;
|
||||
await promptEvent.prompt();
|
||||
const { outcome } = await promptEvent.userChoice;
|
||||
if (outcome === 'accepted') {
|
||||
setPromptEvent(null);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const dismiss = () => {
|
||||
localStorage.setItem(DISMISSED_KEY, '1');
|
||||
setIsDismissed(true);
|
||||
};
|
||||
|
||||
return { promptEvent, isInstalled, isDismissed, install, dismiss };
|
||||
}
|
||||
@@ -33,6 +33,12 @@ const toastStyle: React.CSSProperties = {
|
||||
minWidth: 260,
|
||||
};
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
const root = document.getElementById('admin-root')!;
|
||||
createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -2014,6 +2014,116 @@ const editSchema = z.object({
|
||||
});
|
||||
type EditForm = z.infer<typeof editSchema>;
|
||||
|
||||
// ── Clinic Invitations Section ─────────────────────────────────────────────
|
||||
|
||||
interface InvitationItem {
|
||||
uuid: string;
|
||||
status: string;
|
||||
invited_name: string | null;
|
||||
invited_specialty: string | null;
|
||||
invited_at: number;
|
||||
expires_at: number;
|
||||
clinic: { uuid: string; name: string; logo: string | null };
|
||||
}
|
||||
|
||||
function ClinicInvitationsSection() {
|
||||
const qc = useQueryClient();
|
||||
const [respondingUuid, setRespondingUuid] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['doctor-my-invitations'],
|
||||
queryFn: () => api.get<ApiResponse<InvitationItem[]>>('/api/v1/doctor/invitations'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const invitations: InvitationItem[] = useMemo(() => {
|
||||
const raw = data?.data;
|
||||
return (raw as any)?.data ?? raw ?? [];
|
||||
}, [data]);
|
||||
|
||||
const respondMut = useMutation({
|
||||
mutationFn: ({ uuid, action }: { uuid: string; action: 'accept' | 'reject' }) =>
|
||||
api.post<ApiResponse<any>>(`/api/v1/doctor/invitation/${uuid}/respond`, { action }),
|
||||
onSuccess: (_, vars) => {
|
||||
toast.success(vars.action === 'accept' ? 'دعوتنامه پذیرفته شد' : 'دعوتنامه رد شد');
|
||||
setRespondingUuid(null);
|
||||
qc.invalidateQueries({ queryKey: ['doctor-my-invitations'] });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
if (!isLoading && invitations.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="cp-card p-6">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
|
||||
<div style={{ width: 30, height: 30, borderRadius: 8, background: 'var(--primary-soft)', color: 'var(--primary-700)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<BuildingOfficeIcon style={{ width: 16, height: 16 }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text)' }}>دعوتنامههای کلینیک</span>
|
||||
{invitations.length > 0 && (
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--primary-700)', background: 'var(--primary-soft)', padding: '2px 10px', borderRadius: 999 }}>
|
||||
{invitations.length} دعوت جدید
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{[1, 2].map(i => <div key={i} className="skeleton" style={{ height: 72, borderRadius: 'var(--r)' }} />)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{invitations.map(inv => {
|
||||
const isExpired = Date.now() / 1000 > inv.expires_at;
|
||||
const busy = respondMut.isPending && respondingUuid === inv.uuid;
|
||||
return (
|
||||
<div key={inv.uuid} style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '14px 16px', borderRadius: 'var(--r)', border: '1px solid var(--border)', background: 'var(--surface-2)' }}>
|
||||
{/* Logo */}
|
||||
<div style={{ width: 44, height: 44, borderRadius: 10, overflow: 'hidden', flexShrink: 0, background: 'var(--surface)', border: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{inv.clinic.logo
|
||||
? <img src={inv.clinic.logo} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: <BuildingOfficeIcon style={{ width: 20, height: 20, color: 'var(--text-3)' }} />
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontWeight: 700, fontSize: 14, color: 'var(--text)', marginBottom: 3 }}>{inv.clinic.name}</p>
|
||||
{inv.invited_specialty && (
|
||||
<p style={{ fontSize: 12, color: 'var(--text-2)' }}>تخصص: {inv.invited_specialty}</p>
|
||||
)}
|
||||
<p style={{ fontSize: 11, color: isExpired ? 'var(--danger)' : 'var(--text-3)', marginTop: 2 }}>
|
||||
{isExpired ? 'منقضی شده' : `انقضا: ${new Date(inv.expires_at * 1000).toLocaleDateString('fa-IR')}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{!isExpired && (
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={() => { setRespondingUuid(inv.uuid); respondMut.mutate({ uuid: inv.uuid, action: 'reject' }); }}
|
||||
style={{ height: 34, padding: '0 14px', borderRadius: 'var(--r-sm)', border: '1.5px solid var(--border)', background: 'var(--surface)', color: 'var(--text-2)', fontSize: 13, fontWeight: 600, cursor: busy ? 'not-allowed' : 'pointer', opacity: busy ? 0.5 : 1 }}>
|
||||
رد
|
||||
</button>
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={() => { setRespondingUuid(inv.uuid); respondMut.mutate({ uuid: inv.uuid, action: 'accept' }); }}
|
||||
style={{ height: 34, padding: '0 14px', borderRadius: 'var(--r-sm)', border: '1.5px solid var(--primary)', background: 'var(--primary)', color: 'var(--on-primary)', fontSize: 13, fontWeight: 600, cursor: busy ? 'not-allowed' : 'pointer', opacity: busy ? 0.5 : 1 }}>
|
||||
{busy ? '...' : 'پذیرفتن'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Page ──────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfile?: boolean }) {
|
||||
@@ -2380,6 +2490,8 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
||||
|
||||
{uuid && <ScheduleSection doctorUuid={uuid} />}
|
||||
|
||||
{isOwnProfile && <ClinicInvitationsSection />}
|
||||
|
||||
{doctor.clinics && doctor.clinics.length > 0 && (
|
||||
<div className="cp-card p-6">
|
||||
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import PwaLoginCard from '../components/ui/PwaLoginCard';
|
||||
|
||||
type Mode = 'password' | 'sms' | 'forgot';
|
||||
type SmsStep = 1 | 2;
|
||||
@@ -367,6 +368,8 @@ export default function LoginPage() {
|
||||
ClinicPro — نسخه ۱.۰.۰
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PwaLoginCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 413 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "ClinicPro Admin",
|
||||
"short_name": "ClinicPro",
|
||||
"description": "پنل مدیریت کلینیک پرو",
|
||||
"start_url": "/admin",
|
||||
"scope": "/admin",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"theme_color": "#5b4fd8",
|
||||
"background_color": "#f8fafc",
|
||||
"lang": "fa",
|
||||
"dir": "rtl",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
const CACHE_NAME = 'clinicpro-admin-v1';
|
||||
const BUILD_CACHE = 'clinicpro-build-v1';
|
||||
|
||||
const SHELL_URLS = [
|
||||
'/admin',
|
||||
'/manifest.json',
|
||||
'/icons/icon-192.png',
|
||||
'/icons/icon-512.png',
|
||||
];
|
||||
|
||||
// ── Install: cache the app shell ──────────────────────────────────────────────
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_URLS))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// ── Activate: clean up old caches ─────────────────────────────────────────────
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((k) => k !== CACHE_NAME && k !== BUILD_CACHE)
|
||||
.map((k) => caches.delete(k))
|
||||
)
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// ── Fetch ─────────────────────────────────────────────────────────────────────
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Only handle same-origin requests
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// API calls → Network only, never cache
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build assets (/build/) → Cache First
|
||||
if (url.pathname.startsWith('/build/')) {
|
||||
event.respondWith(
|
||||
caches.open(BUILD_CACHE).then(async (cache) => {
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
const response = await fetch(request);
|
||||
if (response.ok) cache.put(request, response.clone());
|
||||
return response;
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation to /admin/* → Network First, fallback to cached shell
|
||||
if (request.mode === 'navigate' && url.pathname.startsWith('/admin')) {
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match('/admin'))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything else → Network First with cache fallback
|
||||
event.respondWith(
|
||||
fetch(request).catch(() => caches.match(request))
|
||||
);
|
||||
});
|
||||
@@ -3,9 +3,11 @@
|
||||
namespace App\ClinicInvitation\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\ClinicInvitation\Entity\ClinicDoctorInvitation;
|
||||
use App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository;
|
||||
use App\ClinicInvitation\Service\ClinicInvitationService;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
@@ -20,6 +22,7 @@ class ClinicInvitationController extends BaseController
|
||||
private readonly ClinicInvitationService $invitationService,
|
||||
private readonly ClinicDoctorInvitationRepository $invRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
) {}
|
||||
|
||||
// ── Admin endpoints ──────────────────────────────────────────────────────
|
||||
@@ -130,6 +133,63 @@ class ClinicInvitationController extends BaseController
|
||||
return $this->success(null, 204);
|
||||
}
|
||||
|
||||
// ── Doctor-facing endpoints ──────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/doctor/invitations', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_DOCTOR')]
|
||||
public function myInvitations(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
if (!$doctor) {
|
||||
throw new AppException('ERR_NOT_FOUND_001', 'پروفایل پزشک یافت نشد', 404);
|
||||
}
|
||||
|
||||
$invitations = $this->invRepo->findPendingByDoctor($doctor);
|
||||
|
||||
$data = array_map(function (ClinicDoctorInvitation $inv): array {
|
||||
$clinic = $inv->getClinic();
|
||||
$arr = $inv->toArray();
|
||||
$arr['clinic'] = [
|
||||
'uuid' => $clinic->getUuid(),
|
||||
'name' => $clinic->getName(),
|
||||
'logo' => $clinic->getClinicLogo(),
|
||||
];
|
||||
return $arr;
|
||||
}, $invitations);
|
||||
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/doctor/invitation/{invUuid}/respond', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_DOCTOR')]
|
||||
public function respondToInvitation(string $invUuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
if (!$doctor) {
|
||||
throw new AppException('ERR_NOT_FOUND_001', 'پروفایل پزشک یافت نشد', 404);
|
||||
}
|
||||
|
||||
$inv = $this->invRepo->findOneBy(['uuid' => $invUuid]);
|
||||
if (!$inv || $inv->getDoctor()?->getId() !== $doctor->getId()) {
|
||||
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
|
||||
}
|
||||
|
||||
$body = json_decode($request->getContent(), true) ?? [];
|
||||
$action = $body['action'] ?? '';
|
||||
|
||||
if ($action === 'accept') {
|
||||
$this->invitationService->accept($inv);
|
||||
return $this->success(['message' => 'دعوتنامه پذیرفته شد']);
|
||||
}
|
||||
|
||||
if ($action === 'reject') {
|
||||
$this->invitationService->reject($inv);
|
||||
return $this->success(['message' => 'دعوتنامه رد شد']);
|
||||
}
|
||||
|
||||
throw new AppException('ERR_VALIDATION_001', 'action باید accept یا reject باشد', 422);
|
||||
}
|
||||
|
||||
private function assertClinicAccess(\App\Clinic\Entity\Clinic $clinic, User $user): void
|
||||
{
|
||||
if ($user->hasRole('ROLE_ADMIN')) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\ClinicInvitation\Repository;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\ClinicInvitation\Entity\ClinicDoctorInvitation;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
@@ -32,6 +33,19 @@ class ClinicDoctorInvitationRepository extends ServiceEntityRepository
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/** @return ClinicDoctorInvitation[] */
|
||||
public function findPendingByDoctor(Doctor $doctor): array
|
||||
{
|
||||
return $this->createQueryBuilder('i')
|
||||
->where('i.doctor = :doctor')
|
||||
->andWhere('i.status = :status')
|
||||
->setParameter('doctor', $doctor)
|
||||
->setParameter('status', ClinicDoctorInvitation::STATUS_PENDING)
|
||||
->orderBy('i.invitedAt', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function save(ClinicDoctorInvitation $invitation): void
|
||||
{
|
||||
$em = $this->getEntityManager();
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ClinicPro Admin</title>
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#5b4fd8">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="ClinicPro">
|
||||
<link rel="apple-touch-icon" href="/favicon.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100..900&display=swap" rel="stylesheet">
|
||||
|
||||
Reference in New Issue
Block a user