feat: push notifications, calendar view, photo attachments
- Push notifications: custom SW (injectManifest), usePushNotifications hook, push-notify Edge Function, push_subscriptions migration, bell toggle in header - Calendar view: month-grid CalendarPage + useCalendar hook, scheduled_at column migration, datetime field in OrderModal, CalendarDays nav item + route - Photo attachments: useOrderPhotos hook, photo gallery/upload in OrderModal (edit mode), order-photos Storage bucket migration with RLS policies Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ const CompletedOrdersTable = React.lazy(() => import('./admin/analytics/Complete
|
||||
const UserManagementPage = React.lazy(() => import('./admin/users/UserManagementPage'));
|
||||
const SettingsPage = React.lazy(() => import('./admin/settings/SettingsPage'));
|
||||
const ClientsPage = React.lazy(() => import('./admin/clients/ClientsPage'));
|
||||
const CalendarPage = React.lazy(() => import('./admin/calendar/CalendarPage'));
|
||||
|
||||
function PublicSite() {
|
||||
return (
|
||||
@@ -54,6 +55,7 @@ function App() {
|
||||
<Route path="users" element={<UserManagementPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="clients" element={<ClientsPage />} />
|
||||
<Route path="calendar" element={<CalendarPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useCalendar } from './useCalendar';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь',
|
||||
];
|
||||
|
||||
const DAY_HEADERS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
|
||||
const STATUS_COLORS = {
|
||||
'новая': '#8C9097',
|
||||
'замер': '#F59E0B',
|
||||
'согласование': '#8b5cf6',
|
||||
'монтаж': '#4361EE',
|
||||
'закрыт': '#22C55E',
|
||||
};
|
||||
|
||||
function getStatusColor(status) {
|
||||
return STATUS_COLORS[status] || '#8C9097';
|
||||
}
|
||||
|
||||
// Returns a 6×7 array of Date objects for the calendar grid (Mon-first)
|
||||
function buildCalendarDays(year, month) {
|
||||
const firstDay = new Date(year, month, 1);
|
||||
// getDay(): 0=Sun,1=Mon,...,6=Sat — convert to Mon=0,...,Sun=6
|
||||
const startOffset = (firstDay.getDay() + 6) % 7;
|
||||
const days = [];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const d = new Date(year, month, 1 - startOffset + i);
|
||||
days.push(d);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
function isSameDay(a, b) {
|
||||
return a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
function OrderChip({ order }) {
|
||||
const color = getStatusColor(order.status);
|
||||
return (
|
||||
<div
|
||||
title={`${order.name} — ${order.status}`}
|
||||
style={{
|
||||
background: color + '1A',
|
||||
color,
|
||||
fontSize: '0.68rem',
|
||||
fontWeight: 600,
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{order.name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DayCell({ date, orders, isCurrentMonth, isToday, isSelected, onSelect, isMobile }) {
|
||||
const dayOrders = orders.filter((o) => o.scheduled_at && isSameDay(new Date(o.scheduled_at), date));
|
||||
const visible = dayOrders.slice(0, 2);
|
||||
const extra = dayOrders.length - visible.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelect(date)}
|
||||
style={{
|
||||
minHeight: isMobile ? '60px' : '80px',
|
||||
padding: isMobile ? '4px' : '6px 8px',
|
||||
border: isSelected ? '2px solid #4361EE' : '1px solid #E9ECEF',
|
||||
borderRadius: '8px',
|
||||
background: isToday ? '#EEF2FF' : '#FFFFFF',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '3px',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected) e.currentTarget.style.borderColor = '#A5B4FC';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected) e.currentTarget.style.borderColor = '#E9ECEF';
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: isMobile ? '0.72rem' : '0.8rem',
|
||||
fontWeight: isToday ? 700 : 500,
|
||||
color: isToday
|
||||
? '#4361EE'
|
||||
: isCurrentMonth
|
||||
? 'var(--text-main, #1E1E2D)'
|
||||
: 'var(--text-muted, #8C9097)',
|
||||
lineHeight: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{date.getDate()}
|
||||
</span>
|
||||
|
||||
{!isMobile && visible.map((o) => (
|
||||
<OrderChip key={o.id} order={o} />
|
||||
))}
|
||||
|
||||
{isMobile && dayOrders.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '3px' }}>
|
||||
{dayOrders.map((o) => (
|
||||
<div
|
||||
key={o.id}
|
||||
style={{
|
||||
width: '8px', height: '8px', borderRadius: '50%',
|
||||
background: getStatusColor(o.status),
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMobile && extra > 0 && (
|
||||
<span style={{ fontSize: '0.65rem', color: '#8C9097', fontWeight: 600 }}>
|
||||
+{extra} ещё
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DayDetail({ date, orders }) {
|
||||
const dayOrders = orders.filter((o) => o.scheduled_at && isSameDay(new Date(o.scheduled_at), date));
|
||||
|
||||
if (dayOrders.length === 0) {
|
||||
return (
|
||||
<div style={{ color: 'var(--text-muted, #8C9097)', fontSize: '0.85rem' }}>
|
||||
На этот день записей нет
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{dayOrders.map((o) => {
|
||||
const color = getStatusColor(o.status);
|
||||
const time = new Date(o.scheduled_at).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||
return (
|
||||
<div
|
||||
key={o.id}
|
||||
className="glass-panel"
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderLeft: `3px solid ${color}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontFamily: 'var(--font-heading)', fontWeight: 700, fontSize: '0.92rem', marginBottom: '2px' }}>
|
||||
{o.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted, #8C9097)' }}>
|
||||
{o.phone}{o.area_sqm ? ` · ${o.area_sqm} м²` : ''}{o.film_thickness ? ` · ${o.film_thickness} мкм` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '4px', flexShrink: 0 }}>
|
||||
<span
|
||||
style={{
|
||||
background: color + '1A',
|
||||
color,
|
||||
fontSize: '0.72rem',
|
||||
fontWeight: 700,
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
{o.status}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted, #8C9097)' }}>{time}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CalendarPage = () => {
|
||||
const today = new Date();
|
||||
const [year, setYear] = useState(today.getFullYear());
|
||||
const [month, setMonth] = useState(today.getMonth());
|
||||
const [selectedDate, setSelectedDate] = useState(today);
|
||||
const isMobile = useIsMobile();
|
||||
const { orders, loading } = useCalendar(year, month);
|
||||
|
||||
const prevMonth = () => {
|
||||
if (month === 0) { setYear((y) => y - 1); setMonth(11); }
|
||||
else setMonth((m) => m - 1);
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
if (month === 11) { setYear((y) => y + 1); setMonth(0); }
|
||||
else setMonth((m) => m + 1);
|
||||
};
|
||||
|
||||
const calendarDays = buildCalendarDays(year, month);
|
||||
|
||||
const selectedDateLabel = selectedDate.toLocaleDateString('ru-RU', {
|
||||
weekday: 'long', day: 'numeric', month: 'long',
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? '16px' : '30px', maxWidth: '1100px' }}>
|
||||
<h1 style={{
|
||||
fontFamily: 'var(--font-heading)', fontSize: '1.5rem', fontWeight: 800,
|
||||
marginBottom: '24px',
|
||||
}}>
|
||||
Календарь
|
||||
</h1>
|
||||
|
||||
{/* Month navigation header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
marginBottom: '16px',
|
||||
}}>
|
||||
<button
|
||||
onClick={prevMonth}
|
||||
aria-label="Предыдущий месяц"
|
||||
style={{
|
||||
background: 'none', border: '1.5px solid #E9ECEF',
|
||||
borderRadius: '8px', cursor: 'pointer', padding: '6px 10px',
|
||||
display: 'flex', alignItems: 'center', color: '#1E1E2D',
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.borderColor = '#4361EE'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.borderColor = '#E9ECEF'}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
|
||||
<h2 style={{
|
||||
fontFamily: 'var(--font-heading)', fontWeight: 700,
|
||||
fontSize: isMobile ? '1rem' : '1.15rem',
|
||||
color: '#1E1E2D',
|
||||
}}>
|
||||
{MONTH_NAMES[month]} {year}
|
||||
</h2>
|
||||
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
aria-label="Следующий месяц"
|
||||
style={{
|
||||
background: 'none', border: '1.5px solid #E9ECEF',
|
||||
borderRadius: '8px', cursor: 'pointer', padding: '6px 10px',
|
||||
display: 'flex', alignItems: 'center', color: '#1E1E2D',
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.borderColor = '#4361EE'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.borderColor = '#E9ECEF'}
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Day headers */}
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)',
|
||||
gap: '4px', marginBottom: '4px',
|
||||
}}>
|
||||
{DAY_HEADERS.map((d) => (
|
||||
<div
|
||||
key={d}
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
fontSize: '0.72rem',
|
||||
fontWeight: 700,
|
||||
color: '#8C9097',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
padding: '4px 0',
|
||||
}}
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
{loading ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#8C9097', fontSize: '0.9rem' }}>
|
||||
Загрузка...
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)',
|
||||
gap: '4px',
|
||||
}}>
|
||||
{calendarDays.map((date, idx) => (
|
||||
<DayCell
|
||||
key={idx}
|
||||
date={date}
|
||||
orders={orders}
|
||||
isCurrentMonth={date.getMonth() === month}
|
||||
isToday={isSameDay(date, today)}
|
||||
isSelected={isSameDay(date, selectedDate)}
|
||||
onSelect={setSelectedDate}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Day detail panel */}
|
||||
<div style={{ marginTop: '28px' }}>
|
||||
<h3 style={{
|
||||
fontFamily: 'var(--font-heading)', fontWeight: 700,
|
||||
fontSize: '0.95rem', marginBottom: '14px',
|
||||
color: '#1E1E2D',
|
||||
textTransform: 'capitalize',
|
||||
}}>
|
||||
{selectedDateLabel}
|
||||
</h3>
|
||||
{loading ? null : <DayDetail date={selectedDate} orders={orders} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarPage;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
export function useCalendar(year, month) {
|
||||
const [orders, setOrders] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const from = new Date(year, month, 1).toISOString();
|
||||
const to = new Date(year, month + 1, 0, 23, 59, 59).toISOString();
|
||||
|
||||
supabase
|
||||
.from('orders')
|
||||
.select('id, name, phone, status, scheduled_at, film_thickness, area_sqm')
|
||||
.gte('scheduled_at', from)
|
||||
.lte('scheduled_at', to)
|
||||
.order('scheduled_at', { ascending: true })
|
||||
.then(({ data }) => {
|
||||
setOrders(data || []);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [year, month]);
|
||||
|
||||
return { orders, loading };
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { Plus, Menu } from 'lucide-react';
|
||||
import { Plus, Menu, Bell, BellOff } from 'lucide-react';
|
||||
import Sidebar from './Sidebar';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { usePushNotifications } from '../../hooks/usePushNotifications';
|
||||
|
||||
const PAGE_TITLES = {
|
||||
'/admin/kanban': 'Канбан',
|
||||
@@ -18,6 +19,7 @@ const AdminLayout = () => {
|
||||
const [newCount, setNewCount] = useState(0);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const { supported: pushSupported, subscribed: pushSubscribed, loading: pushLoading, subscribe: pushSubscribe, unsubscribe: pushUnsubscribe } = usePushNotifications();
|
||||
|
||||
useEffect(() => {
|
||||
const refreshBadge = () => {
|
||||
@@ -98,6 +100,29 @@ const AdminLayout = () => {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{pushSupported && (
|
||||
<button
|
||||
onClick={pushSubscribed ? pushUnsubscribe : pushSubscribe}
|
||||
disabled={pushLoading}
|
||||
title={pushSubscribed ? 'Отключить уведомления' : 'Включить уведомления'}
|
||||
aria-label={pushSubscribed ? 'Отключить push-уведомления' : 'Включить push-уведомления'}
|
||||
style={{
|
||||
background: pushSubscribed ? 'rgba(67,97,238,0.08)' : 'none',
|
||||
border: '1.5px solid var(--border-light)',
|
||||
borderRadius: '8px',
|
||||
padding: '7px',
|
||||
cursor: pushLoading ? 'not-allowed' : 'pointer',
|
||||
color: pushSubscribed ? '#4361EE' : 'var(--text-muted)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
opacity: pushLoading ? 0.6 : 1,
|
||||
transition: 'opacity 0.15s, background 0.15s, color 0.15s',
|
||||
}}
|
||||
>
|
||||
{pushSubscribed ? <Bell size={16} /> : <BellOff size={16} />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowNewOrder(true)}
|
||||
className="btn-primary"
|
||||
@@ -106,6 +131,7 @@ const AdminLayout = () => {
|
||||
<Plus size={15} />
|
||||
Заявка
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { LayoutDashboard, BarChart2, CheckSquare, Shield, LogOut, Users, Settings, BookUser, X } from 'lucide-react';
|
||||
import { LayoutDashboard, BarChart2, CheckSquare, Shield, LogOut, Users, Settings, BookUser, X, CalendarDays } from 'lucide-react';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/admin/kanban', icon: LayoutDashboard, label: 'Канбан' },
|
||||
{ to: '/admin/analytics', icon: BarChart2, label: 'Аналитика' },
|
||||
{ to: '/admin/completed', icon: CheckSquare, label: 'Завершённые' },
|
||||
{ to: '/admin/calendar', icon: CalendarDays, label: 'Календарь' },
|
||||
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
|
||||
{ to: '/admin/clients', icon: BookUser, label: 'Клиенты' },
|
||||
{ to: '/admin/settings', icon: Settings, label: 'Настройки' },
|
||||
|
||||
@@ -50,6 +50,7 @@ const OrderModal = ({ order, onClose, onSaved, mode = 'edit' }) => {
|
||||
telegram_username: order?.telegram_username || '',
|
||||
max_username: order?.max_username || '',
|
||||
address: order?.address || '',
|
||||
scheduled_at: order?.scheduled_at || '',
|
||||
});
|
||||
const [events, setEvents] = useState([]);
|
||||
const { photos, uploading, uploadPhotos, deletePhoto } = useOrderPhotos(mode === 'edit' ? order?.id : null);
|
||||
@@ -92,6 +93,7 @@ const OrderModal = ({ order, onClose, onSaved, mode = 'edit' }) => {
|
||||
...f,
|
||||
area_sqm: f.area_sqm === '' ? null : Number(f.area_sqm),
|
||||
final_cost: f.final_cost === '' ? null : Number(f.final_cost),
|
||||
scheduled_at: f.scheduled_at === '' ? null : f.scheduled_at,
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -240,6 +242,17 @@ const OrderModal = ({ order, onClose, onSaved, mode = 'edit' }) => {
|
||||
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label htmlFor="modal-scheduled" style={labelStyle}>Дата визита</label>
|
||||
<input
|
||||
id="modal-scheduled"
|
||||
type="datetime-local"
|
||||
name="scheduled_at"
|
||||
value={form.scheduled_at ? new Date(form.scheduled_at).toISOString().slice(0, 16) : ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, scheduled_at: e.target.value ? new Date(e.target.value).toISOString() : '' }))}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="modal-area" style={labelStyle}>Площадь (м²)</label>
|
||||
<input id="modal-area" type="number" name="area_sqm" value={form.area_sqm} onChange={set} style={inputStyle} />
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
const VAPID_PUBLIC_KEY = import.meta.env.VITE_VAPID_PUBLIC_KEY;
|
||||
|
||||
/**
|
||||
* Convert a base64url string to a Uint8Array suitable for
|
||||
* PushManager.subscribe({ applicationServerKey }).
|
||||
*/
|
||||
function urlB64ToUint8Array(base64String) {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const rawData = atob(base64);
|
||||
return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that manages Web Push subscription state for the current admin user.
|
||||
*
|
||||
* Returns:
|
||||
* supported – browser supports Push API
|
||||
* subscribed – a push subscription is active
|
||||
* loading – async operation in progress
|
||||
* subscribe – enable push & save subscription to Supabase
|
||||
* unsubscribe – revoke push & remove from Supabase
|
||||
*/
|
||||
export function usePushNotifications() {
|
||||
const [supported, setSupported] = useState(false);
|
||||
const [subscribed, setSubscribed] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Detect browser support on mount
|
||||
useEffect(() => {
|
||||
setSupported('serviceWorker' in navigator && 'PushManager' in window);
|
||||
}, []);
|
||||
|
||||
// Sync subscribed state from current SW registration
|
||||
useEffect(() => {
|
||||
if (!supported) return;
|
||||
navigator.serviceWorker.ready.then((reg) => {
|
||||
reg.pushManager.getSubscription().then((sub) => setSubscribed(!!sub));
|
||||
});
|
||||
}, [supported]);
|
||||
|
||||
const subscribe = async () => {
|
||||
if (!supported || !VAPID_PUBLIC_KEY) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlB64ToUint8Array(VAPID_PUBLIC_KEY),
|
||||
});
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
await supabase.from('push_subscriptions').upsert(
|
||||
{ user_id: user.id, subscription: sub.toJSON() },
|
||||
{ onConflict: 'user_id' },
|
||||
);
|
||||
|
||||
setSubscribed(true);
|
||||
} catch (e) {
|
||||
console.error('Push subscribe failed:', e);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const unsubscribe = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
if (sub) await sub.unsubscribe();
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
await supabase.from('push_subscriptions').delete().eq('user_id', user.id);
|
||||
|
||||
setSubscribed(false);
|
||||
} catch (e) {
|
||||
console.error('Push unsubscribe failed:', e);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return { supported, subscribed, loading, subscribe, unsubscribe };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { precacheAndRoute } from 'workbox-precaching';
|
||||
|
||||
// Workbox precache manifest — injected at build time by vite-plugin-pwa
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
|
||||
// ── Push notification handler ────────────────────────────────────────────────
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
const data = event.data?.json() ?? {};
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || 'Осколкам.Нет', {
|
||||
body: data.body || '',
|
||||
icon: '/pwa-192x192.png',
|
||||
badge: '/pwa-64x64.png',
|
||||
data: { url: data.url || '/admin/kanban' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
event.waitUntil(
|
||||
clients.openWindow(event.notification.data?.url || '/admin/kanban'),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
};
|
||||
|
||||
// Minimal Web Push implementation using Deno crypto
|
||||
async function sendWebPush(
|
||||
subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
|
||||
payload: string,
|
||||
vapidPrivateKeyB64: string,
|
||||
vapidPublicKeyB64: string,
|
||||
): Promise<number> {
|
||||
const endpoint = subscription.endpoint;
|
||||
const audience = new URL(endpoint).origin;
|
||||
|
||||
const header = { alg: 'ES256', typ: 'JWT' };
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const claims = {
|
||||
aud: audience,
|
||||
exp: now + 12 * 3600,
|
||||
sub: 'mailto:admin@oskolkam.net',
|
||||
};
|
||||
|
||||
const encode = (obj: object) =>
|
||||
btoa(JSON.stringify(obj))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
const headerB64 = encode(header);
|
||||
const claimsB64 = encode(claims);
|
||||
const signingInput = `${headerB64}.${claimsB64}`;
|
||||
|
||||
// Import private key from raw 32-byte scalar
|
||||
const privKeyBytes = Uint8Array.from(
|
||||
atob(vapidPrivateKeyB64.replace(/-/g, '+').replace(/_/g, '/')),
|
||||
(c) => c.charCodeAt(0),
|
||||
);
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
privKeyBytes,
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
false,
|
||||
['sign'],
|
||||
);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const sig = await crypto.subtle.sign(
|
||||
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||
cryptoKey,
|
||||
encoder.encode(signingInput),
|
||||
);
|
||||
const sigB64 = btoa(String.fromCharCode(...new Uint8Array(sig)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
const jwt = `${signingInput}.${sigB64}`;
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `vapid t=${jwt},k=${vapidPublicKeyB64}`,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'TTL': '86400',
|
||||
},
|
||||
body: encoder.encode(payload),
|
||||
});
|
||||
return res.status;
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const order = body.record;
|
||||
|
||||
const supabaseAdmin = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
|
||||
);
|
||||
|
||||
const { data: subs } = await supabaseAdmin
|
||||
.from('push_subscriptions')
|
||||
.select('subscription');
|
||||
|
||||
if (!subs?.length) {
|
||||
return new Response(
|
||||
JSON.stringify({ sent: 0 }),
|
||||
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
title: 'Новая заявка',
|
||||
body: `${order?.name || 'Клиент'} — ${order?.phone || ''}`,
|
||||
url: '/admin/kanban',
|
||||
});
|
||||
|
||||
const vapidPriv = Deno.env.get('VAPID_PRIVATE_KEY')!;
|
||||
const vapidPub = Deno.env.get('VITE_VAPID_PUBLIC_KEY')!;
|
||||
|
||||
let sent = 0;
|
||||
for (const row of subs) {
|
||||
try {
|
||||
const status = await sendWebPush(row.subscription, payload, vapidPriv, vapidPub);
|
||||
if (status === 200 || status === 201) sent++;
|
||||
// Clean up expired/invalid subscriptions
|
||||
if (status === 404 || status === 410) {
|
||||
await supabaseAdmin
|
||||
.from('push_subscriptions')
|
||||
.delete()
|
||||
.eq('subscription->>endpoint', row.subscription.endpoint);
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore individual push failures — don't abort the whole batch
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ sent }),
|
||||
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Push notification subscriptions
|
||||
-- One row per user. The subscription JSONB holds the PushSubscription JSON
|
||||
-- (endpoint, keys.p256dh, keys.auth) returned by the browser PushManager.
|
||||
CREATE TABLE push_subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
subscription JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE (user_id)
|
||||
);
|
||||
|
||||
ALTER TABLE push_subscriptions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Admins can only manage their own subscription
|
||||
CREATE POLICY "Users manage own subscriptions"
|
||||
ON push_subscriptions
|
||||
USING (auth.uid() = user_id)
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add scheduled appointment date to orders
|
||||
ALTER TABLE orders ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ;
|
||||
+6
-8
@@ -6,6 +6,11 @@ export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
// injectManifest lets us supply our own src/sw.js so we can add
|
||||
// push/notificationclick handlers alongside Workbox precaching.
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'sw.js',
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.svg', 'favicon.ico', 'apple-touch-icon-180x180.png'],
|
||||
manifest: {
|
||||
@@ -25,15 +30,8 @@ export default defineConfig({
|
||||
{ src: 'maskable-icon-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
injectManifest: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: { cacheName: 'google-fonts-cache', expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 365 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user