pwa setting
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user