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:
2026-05-10 17:25:04 +03:00
parent 5a9b6c4c7f
commit af9e7ce1af
12 changed files with 688 additions and 18 deletions
+2
View File
@@ -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>
+342
View File
@@ -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;
+25
View File
@@ -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 };
}
+27 -1
View File
@@ -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 */}
+2 -1
View File
@@ -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: 'Настройки' },
+13
View File
@@ -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} />
+91
View File
@@ -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 };
}
+25
View File
@@ -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'),
);
});
+127
View File
@@ -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
View File
@@ -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 } },
},
],
},
}),
],