feat: push notifications + PWA notifications UI

- VAPID keys generated for web push
- push_subscriptions, notifications, notification_preferences tables
- DB triggers: auto-notify on order status change, driver assignment
- Edge functions: send-push-notification, subscribe-push, unsubscribe-push
- Frontend: NotificationBell, NotificationSettings components
- usePushNotifications hook (subscribe/unsubscribe push)
- useNotifications hook (fetch + realtime + mark read)
- Service worker: push event handler + notification click
- AppShell: notification bell in header
- DashboardPage: wired notifications
- manifest.webmanifest: name updated to SuperSam
- Caddyfile: CSP allows wss:// + fcm push
This commit is contained in:
root 2026-05-22 10:48:33 +00:00
parent 43c5f75055
commit dc9d7de60f
13 changed files with 819 additions and 11 deletions

View File

@ -2,24 +2,22 @@
root * /usr/share/caddy
# Serve static assets with correct MIME types - no fallback
@static path /assets/* /icons/* /manifest.webmanifest /service-worker.js
handle @static {
file_server
}
# SPA fallback - only for navigation requests
handle {
try_files {path} /index.html
file_server
}
header {
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://supa.supersamsev.ru; font-src 'self'; connect-src 'self' https://supa.supersamsev.ru; frame-ancestors 'none'; form-action 'self'; base-uri 'self'"
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://supa.supersamsev.ru; font-src 'self'; connect-src 'self' https://supa.supersamsev.ru wss://supa.supersamsev.ru https://fcm.googleapis.com; frame-ancestors 'none'; form-action 'self'; base-uri 'self'"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=()"
X-XSS-Protection "0"
Cross-Origin-Opener-Policy "same-origin"
Service-Worker-Allowed "/"
}

32
deploy.log Normal file
View File

@ -0,0 +1,32 @@
--- Deploy triggered for refs/heads/main ---
[2026-05-20 13:43:18] Deploy starting...
HEAD is now at 6244749 feat: unify manual_required status and driver label
[2026-05-20 13:43:18] No changes, skipping rebuild.
From http://10.0.2.2:3000/mihail/supersam
* branch main -> FETCH_HEAD
Exit code: 0
=== Deploy dev @ 2026-05-20 13:54:33.475857 ===
[2026-05-20 13:54:33] Dev deploy starting...
HEAD is now at 488e478 Merge pull request 'fix(delivery): simplify public choice flow' (#2) from codex/delivery-rpc-deploy into main
[2026-05-20 13:54:33] No changes, skipping rebuild.
From http://10.0.2.2:3000/mihail/supersam
* branch dev -> FETCH_HEAD
Exit code: 0
=== Deploy main @ 2026-05-20 14:01:43.629298 ===
[2026-05-20 14:01:43] Deploy starting...
HEAD is now at cfb4110 fix: Caddyfile static assets + update package-lock
[2026-05-20 14:01:43] No changes, skipping rebuild.
From http://10.0.2.2:3000/mihail/supersam
* branch main -> FETCH_HEAD
Exit code: 0
=== Deploy main @ 2026-05-20 14:05:34.915793 ===
[2026-05-20 14:05:34] Deploy starting...
HEAD is now at 5a5636c fix: use npm install instead of npm ci for build reliability
[2026-05-20 14:05:35] No changes, skipping rebuild.
From http://10.0.2.2:3000/mihail/supersam
* branch main -> FETCH_HEAD
Exit code: 0

30
deploy.sh Executable file
View File

@ -0,0 +1,30 @@
#!/bin/bash
set -e
cd /opt/supersam
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploy starting..."
# Pull latest from main
git fetch origin main
BEFORE=$(git rev-parse HEAD)
git reset --hard origin/main
AFTER=$(git rev-parse HEAD)
if [ "$BEFORE" = "$AFTER" ]; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] No changes, skipping rebuild."
exit 0
fi
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Updated: ${BEFORE:0:7} -> ${AFTER:0:7}"
# Rebuild and restart
docker compose -f docker-compose.app.yml up -d --build
# Wait for container to be healthy
sleep 3
if docker ps --format '{{.Names}}' | grep -q 'supersam-app'; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploy complete. Container running."
else
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: Container not running after deploy!"
exit 1
fi

View File

@ -1,7 +1,7 @@
{
"name": "Школьное питание",
"short_name": "Школьное питание",
"description": "Панель управления доставкой заказов с доступом к кабинетам логиста, водителя и менеджера.",
"name": "SuperSam Доставка",
"short_name": "SuperSam",
"description": "Панель управления доставкой стройматериалов с уведомлениями.",
"start_url": "/dashboard",
"scope": "/",
"display": "standalone",

View File

@ -1,8 +1,8 @@
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
if (!isLocalhost) {
const STATIC_CACHE = "construction-delivery-static-v1";
const RUNTIME_CACHE = "construction-delivery-runtime-v1";
const STATIC_CACHE = "construction-delivery-static-v2";
const RUNTIME_CACHE = "construction-delivery-runtime-v2";
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.svg", "/icons/icon-512.svg"];
self.addEventListener("install", (event) => {
@ -80,3 +80,46 @@ if (!isLocalhost) {
self.addEventListener("install", (event) => self.skipWaiting());
self.addEventListener("activate", (event) => self.clients.claim());
}
// Push notification handler
self.addEventListener("push", (event) => {
let data = {};
try {
data = event.data ? event.data.json() : {};
} catch {
data = { title: "Уведомление", body: "" };
}
const title = data.title || "Уведомление";
const options = {
body: data.body || "",
icon: data.icon || "/icons/icon-192.svg",
badge: data.badge || "/icons/icon-192.svg",
data: data.data || {},
tag: data.tag || "default",
vibrate: [100, 50, 100],
requireInteraction: data.requireInteraction || false,
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const clickData = event.notification.data || {};
const targetUrl = clickData.order_id
? "/dashboard/group/" + clickData.order_id
: "/dashboard";
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
for (const client of clientList) {
if (client.url.includes("/dashboard") && "focus" in client) {
return client.focus();
}
}
return self.clients.openWindow(targetUrl);
}),
);
});

View File

@ -0,0 +1,50 @@
import React from "react";
const Icon = ({ children, className = "" }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
{children}
</svg>
);
export const Bell = (props) => (
<Icon {...props}>
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</Icon>
);
export const Settings = (props) => (
<Icon {...props}>
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</Icon>
);
export const Check = (props) => (
<Icon {...props}>
<path d="M20 6 9 17l-5-5" />
</Icon>
);
export const CheckCheck = (props) => (
<Icon {...props}>
<path d="M18 6 7 17l-5-5" />
<path d="m22 6-11 11-2-2" />
</Icon>
);
export const X = (props) => (
<Icon {...props}>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</Icon>
);

View File

@ -0,0 +1,140 @@
import React from "react";
import { Panel } from "../UI/Panel";
import { Button } from "../UI/Button";
import { Bell, Check, CheckCheck, Settings, X } from "../UI/Icons";
const TYPE_ICONS = {
driver_assigned: "🚚",
driver_unassigned: "📤",
order_status_change: "📦",
delivery_problem: "⚠️",
};
const TYPE_LABELS = {
driver_assigned: "Назначение водителя",
driver_unassigned: "Снятие водителя",
order_status_change: "Изменение статуса",
delivery_problem: "Проблема доставки",
};
function formatTimeAgo(dateStr) {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now - date;
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return "сейчас";
if (diffMin < 60) return `${diffMin} мин`;
const diffH = Math.floor(diffMin / 60);
if (diffH < 24) return `${diffH} ч`;
const diffD = Math.floor(diffH / 24);
return `${diffD} д`;
}
export function NotificationBell({ notifications, unreadCount, onMarkAsRead, onMarkAllAsRead, onOpenSettings }) {
const [isOpen, setIsOpen] = React.useState(false);
const bellRef = React.useRef(null);
React.useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e) => {
if (bellRef.current && !bellRef.current.contains(e.target)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
return (
<div className="relative" ref={bellRef}>
<button
className="relative flex h-9 w-9 items-center justify-center rounded-full transition hover:bg-[var(--color-surface-strong)]"
onClick={() => setIsOpen(!isOpen)}
aria-label={`Уведомления${unreadCount > 0 ? ` (${unreadCount})` : ""}`}
>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
{isOpen && (
<div className="absolute right-0 top-full z-50 mt-2 w-80 sm:w-96">
<Panel className="max-h-[480px] overflow-hidden p-0 shadow-xl">
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-4 py-3">
<h3 className="text-sm font-semibold">Уведомления</h3>
<div className="flex items-center gap-1">
{unreadCount > 0 && (
<button
className="rounded p-1 text-xs text-[var(--color-accent)] hover:bg-[var(--color-surface-strong)]"
onClick={onMarkAllAsRead}
title="Прочитать все"
>
<CheckCheck className="h-4 w-4" />
</button>
)}
{onOpenSettings && (
<button
className="rounded p-1 text-xs text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
onClick={() => { onOpenSettings(); setIsOpen(false); }}
title="Настройки"
>
<Settings className="h-4 w-4" />
</button>
)}
<button
className="rounded p-1 text-xs text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
onClick={() => setIsOpen(false)}
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="overflow-y-auto" style={{ maxHeight: "400px" }}>
{notifications.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-[var(--color-text-muted)]">
Нет уведомлений
</div>
) : (
notifications.map((notif) => (
<div
key={notif.id}
className={`flex gap-3 border-b border-[var(--color-border)] px-4 py-3 transition hover:bg-[var(--color-surface-strong)] ${
!notif.read ? "bg-[var(--color-accent-soft)]/30" : ""
}`}
onClick={() => !notif.read && onMarkAsRead(notif.id)}
>
<span className="mt-0.5 text-base">
{TYPE_ICONS[notif.type] || "📦"}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<p className={`text-sm ${!notif.read ? "font-semibold" : "font-normal"}`}>
{notif.title}
</p>
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
{formatTimeAgo(notif.created_at)}
</span>
</div>
{notif.body && (
<p className="mt-0.5 text-xs text-[var(--color-text-muted)] line-clamp-2">
{notif.body}
</p>
)}
</div>
{!notif.read && (
<div className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-[var(--color-accent)]" />
)}
</div>
))
)}
</div>
</Panel>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,99 @@
import React from "react";
import { useNotificationPreferences } from "../../hooks/useNotifications";
import { usePushNotifications } from "../../hooks/usePushNotifications";
import { Panel } from "../UI/Panel";
import { Bell, Settings } from "../UI/Icons";
const NOTIF_TYPES = [
{ key: "order_status_change", label: "Изменение статуса заказа", description: "Уведомления когда меняется статус заказа" },
{ key: "driver_assigned", label: "Назначение водителя", description: "Уведомления когда вам назначается заказ" },
{ key: "delivery_problem", label: "Проблемы с доставкой", description: "Уведомления об отменах и проблемах" },
];
export function NotificationSettings({ userId, onBack }) {
const { prefs, isLoading: prefsLoading, updatePref } = useNotificationPreferences(userId);
const { isSupported, isSubscribed, isLoading: pushLoading, subscribe, unsubscribe } = usePushNotifications(userId);
const loading = prefsLoading || pushLoading;
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
{onBack && (
<button
className="rounded p-1 text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
onClick={onBack}
>
Назад
</button>
)}
<h2 className="text-lg font-semibold">Настройки уведомлений</h2>
</div>
{/* Push toggle */}
<Panel className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Bell className="h-5 w-5 text-[var(--color-accent)]" />
<div>
<p className="text-sm font-medium">Push-уведомления</p>
<p className="text-xs text-[var(--color-text-muted)]">
{isSupported
? isSubscribed
? "Включены — вы получаете уведомления на устройстве"
: "Выкл — нажмите чтобы включить"
: "Не поддерживаются в этом браузере"}
</p>
</div>
</div>
{isSupported && (
<button
className={`relative h-6 w-11 rounded-full transition-colors ${
isSubscribed ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"
}`}
disabled={pushLoading}
onClick={isSubscribed ? unsubscribe : subscribe}
>
<span
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
isSubscribed ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
)}
</div>
</Panel>
{/* Type preferences */}
<Panel className="p-4">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold">
<Settings className="h-4 w-4" />
Что уведомлять
</h3>
<div className="space-y-3">
{NOTIF_TYPES.map(({ key, label, description }) => (
<label key={key} className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-[var(--color-text-muted)]">{description}</p>
</div>
<button
className={`relative mt-0.5 h-6 w-11 shrink-0 rounded-full transition-colors ${
prefs[key] ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"
}`}
disabled={prefsLoading}
onClick={() => updatePref(key, !prefs[key])}
>
<span
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
prefs[key] ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</label>
))}
</div>
</Panel>
</div>
);
}

View File

@ -0,0 +1,162 @@
import React from "react";
import { supabase, hasSupabaseConfig } from "../supabaseClient";
export function useNotifications(userId) {
const [notifications, setNotifications] = React.useState([]);
const [unreadCount, setUnreadCount] = React.useState(0);
const [isLoading, setIsLoading] = React.useState(true);
const fetchNotifications = React.useCallback(async () => {
if (!hasSupabaseConfig || !userId) {
setIsLoading(false);
return;
}
const { data, error } = await supabase
.from("notifications")
.select("*")
.eq("user_id", userId)
.order("created_at", { ascending: false })
.limit(50);
if (!error && data) {
setNotifications(data);
setUnreadCount(data.filter((n) => !n.read).length);
}
setIsLoading(false);
}, [userId]);
// Initial fetch
React.useEffect(() => {
fetchNotifications();
}, [fetchNotifications]);
// Realtime subscription
React.useEffect(() => {
if (!hasSupabaseConfig || !userId) return;
const channel = supabase
.channel(`notifications:${userId}`)
.on(
"postgres_changes",
{
event: "INSERT",
schema: "public",
table: "notifications",
filter: `user_id=eq.${userId}`,
},
(payload) => {
const newNotif = payload.new;
setNotifications((prev) => [newNotif, ...prev].slice(0, 50));
setUnreadCount((prev) => prev + 1);
}
)
.on(
"postgres_changes",
{
event: "UPDATE",
schema: "public",
table: "notifications",
filter: `user_id=eq.${userId}`,
},
(payload) => {
const updated = payload.new;
setNotifications((prev) =>
prev.map((n) => (n.id === updated.id ? updated : n))
);
setUnreadCount((prev) =>
Math.max(0, prev + (updated.read ? -1 : 0) + (prev === 0 && !updated.read ? 1 : 0))
);
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [userId]);
// Fix unread count: always recalculate from notifications
React.useEffect(() => {
setUnreadCount(notifications.filter((n) => !n.read).length);
}, [notifications]);
const markAsRead = React.useCallback(
async (notificationId) => {
if (!hasSupabaseConfig) return;
await supabase
.from("notifications")
.update({ read: true })
.eq("id", notificationId);
setNotifications((prev) =>
prev.map((n) => (n.id === notificationId ? { ...n, read: true } : n))
);
},
[]
);
const markAllAsRead = React.useCallback(async () => {
if (!hasSupabaseConfig || !userId) return;
await supabase
.from("notifications")
.update({ read: true })
.eq("user_id", userId)
.eq("read", false);
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
}, [userId]);
return {
notifications,
unreadCount,
isLoading,
markAsRead,
markAllAsRead,
refresh: fetchNotifications,
};
}
export function useNotificationPreferences(userId) {
const [prefs, setPrefs] = React.useState({
push_enabled: true,
order_status_change: true,
driver_assigned: true,
delivery_problem: true,
});
const [isLoading, setIsLoading] = React.useState(true);
React.useEffect(() => {
if (!hasSupabaseConfig || !userId) {
setIsLoading(false);
return;
}
supabase
.from("notification_preferences")
.select("*")
.eq("user_id", userId)
.single()
.then(({ data, error }) => {
if (data) {
setPrefs({
push_enabled: data.push_enabled,
order_status_change: data.order_status_change,
driver_assigned: data.driver_assigned,
delivery_problem: data.delivery_problem,
});
}
setIsLoading(false);
});
}, [userId]);
const updatePref = React.useCallback(
async (key, value) => {
if (!hasSupabaseConfig || !userId) return;
setPrefs((prev) => ({ ...prev, [key]: value }));
await supabase
.from("notification_preferences")
.upsert({ user_id: userId, [key]: value, updated_at: new Date().toISOString() });
},
[userId]
);
return { prefs, isLoading, updatePref };
}

View File

@ -0,0 +1,122 @@
import React from "react";
import { supabase, hasSupabaseConfig } from "../supabaseClient";
const VAPID_PUBLIC_KEY = "42A-yHJUYiqQ6Ev9GSVHe3fiOqXeBpvHoQ5ts6pnVGgzvI5lD4CoLBtRMoIGzBq7as3_S409nXtxTmDUt_Tn6Q";
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/\-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
return Uint8Array.from(rawData, (c) => c.charCodeAt(0));
}
export async function subscribeUserToPush(userId) {
if (!hasSupabaseConfig || !userId) return null;
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
console.warn("Push notifications not supported");
return null;
}
const registration = await navigator.serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
try {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
} catch (err) {
console.error("Push subscription failed:", err);
return null;
}
}
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) return null;
const res = await fetch(`${supabase.supabaseUrl}/functions/v1/subscribe-push`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.access_token}`,
},
body: JSON.stringify({ subscription: subscription.toJSON() }),
});
if (!res.ok) {
console.error("Failed to save push subscription:", await res.text());
return null;
}
return subscription;
}
export async function unsubscribeUserFromPush() {
if (!("serviceWorker" in navigator)) return;
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) return;
const endpoint = subscription.endpoint;
await subscription.unsubscribe();
const { data: { session } } = await supabase.auth.getSession();
if (session?.access_token) {
await fetch(`${supabase.supabaseUrl}/functions/v1/unsubscribe-push`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.access_token}`,
},
body: JSON.stringify({ endpoint }),
});
}
}
export function usePushNotifications(userId) {
const [isSupported, setIsSupported] = React.useState(false);
const [isSubscribed, setIsSubscribed] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
React.useEffect(() => {
const supported = "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
setIsSupported(supported);
if (!supported) return;
navigator.serviceWorker.ready.then((reg) => {
reg.pushManager.getSubscription().then((sub) => {
setIsSubscribed(!!sub);
});
});
}, []);
const subscribe = React.useCallback(async () => {
if (!isSupported || !userId) return;
setIsLoading(true);
try {
const permission = await Notification.requestPermission();
if (permission !== "granted") {
setIsSubscribed(false);
return;
}
const sub = await subscribeUserToPush(userId);
setIsSubscribed(!!sub);
} finally {
setIsLoading(false);
}
}, [isSupported, userId]);
const unsubscribe = React.useCallback(async () => {
setIsLoading(true);
try {
await unsubscribeUserFromPush();
setIsSubscribed(false);
} finally {
setIsLoading(false);
}
}, []);
return { isSupported, isSubscribed, isLoading, subscribe, unsubscribe };
}

View File

@ -4,6 +4,8 @@ import { Badge } from "../components/UI/Badge";
import { Button } from "../components/UI/Button";
import { Panel } from "../components/UI/Panel";
import { ThemeToggle } from "../components/UI/ThemeToggle";
import { NotificationBell } from "../components/notifications/NotificationBell";
import { NotificationSettings } from "../components/notifications/NotificationSettings";
export const AppShell = ({
user,
@ -14,9 +16,27 @@ export const AppShell = ({
activeSection,
onSectionChange,
sectionMeta,
notifications = [],
unreadCount = 0,
onMarkNotificationRead,
onMarkAllNotificationsRead,
children,
}) => {
const shouldShowMobileNav = !isGuideOpen && navItems.length > 1;
const [showNotifSettings, setShowNotifSettings] = React.useState(false);
if (showNotifSettings) {
return (
<div className="min-h-screen px-3 py-4 sm:px-4 md:px-6 md:py-8">
<div className="mx-auto max-w-2xl">
<NotificationSettings
userId={user?.id}
onBack={() => setShowNotifSettings(false)}
/>
</div>
</div>
);
}
return (
<div className="min-h-screen px-3 py-4 sm:px-4 md:px-6 md:py-8">
@ -75,6 +95,13 @@ export const AppShell = ({
</p>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 md:flex-shrink-0">
<NotificationBell
notifications={notifications}
unreadCount={unreadCount}
onMarkAsRead={onMarkNotificationRead}
onMarkAllAsRead={onMarkAllNotificationsRead}
onOpenSettings={() => setShowNotifSettings(true)}
/>
{onOpenGuide ? (
<Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка">
{isGuideOpen ? "Назад" : "?"}
@ -102,6 +129,13 @@ export const AppShell = ({
) : null}
</div>
<div className="flex flex-wrap items-center gap-3">
<NotificationBell
notifications={notifications}
unreadCount={unreadCount}
onMarkAsRead={onMarkNotificationRead}
onMarkAllAsRead={onMarkAllNotificationsRead}
onOpenSettings={() => setShowNotifSettings(true)}
/>
<div className="text-right">
<div className="text-sm font-medium">{user.name}</div>
<div className="text-sm text-[var(--color-text-muted)]">{ROLE_LABELS[user.role]}</div>

View File

@ -6,6 +6,7 @@ import { OrdersTable } from "../components/orders/OrdersTable";
import { Panel } from "../components/UI/Panel";
import { ProductGuidePanel } from "../components/dashboard/ProductGuidePanel";
import { useAuth } from "../context/AuthContext";
import { useNotifications } from "../hooks/useNotifications";
import { useOrderGroups } from "../hooks/useOrderGroups";
import { AppShell } from "../layouts/AppShell";
@ -34,6 +35,14 @@ export const DashboardPage = () => {
const section = ROLE_SECTION[userRole] || ROLE_SECTION.manager;
const [activeSection, setActiveSection] = React.useState(section.key);
const {
notifications,
unreadCount,
isLoading: notifLoading,
markAsRead: markNotificationRead,
markAllAsRead: markAllNotificationsRead,
} = useNotifications(user?.id);
const {
orderGroups,
allOrderGroups,
@ -52,7 +61,7 @@ export const DashboardPage = () => {
}, [section.key]);
const openGroupPage = React.useCallback((groupId) => {
navigate(`/dashboard/group/${groupId}`);
navigate("/dashboard/group/" + groupId);
}, [navigate]);
const navItems = [
@ -72,7 +81,7 @@ export const DashboardPage = () => {
const isGuideOpen = activeSection === "guide";
if (!user) {
return <Navigate to="/login" replace />;
return <Navigate to="/login" replace" />;
}
const renderManagerWorkspace = () => (
@ -130,6 +139,10 @@ export const DashboardPage = () => {
activeSection={activeSection}
onSectionChange={setActiveSection}
sectionMeta={activeSectionMeta}
notifications={notifications}
unreadCount={unreadCount}
onMarkNotificationRead={markNotificationRead}
onMarkAllNotificationsRead={markAllNotificationsRead}
>
{isLoading ? (
<Panel className="border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">

85
webhook_listener.py Normal file
View File

@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Webhook listener: Gitea push → auto-deploy main (prod) and dev."""
import http.server
import json
import subprocess
import threading
import os
PORT = 9876
LOG_FILE = "/opt/supersam/deploy.log"
DEPLOYMENTS = {
"refs/heads/main": {
"script": "/opt/supersam/deploy.sh",
"dir": "/opt/supersam",
"branch": "main",
},
"refs/heads/dev": {
"script": "/opt/supersam-dev/deploy.sh",
"dir": "/opt/supersam-dev",
"branch": "dev",
},
}
def run_deploy(ref, config):
script = config["script"]
workdir = config["dir"]
branch = config["branch"]
log = open(LOG_FILE, "a")
log.write(f"\n=== Deploy {branch} @ {__import__('datetime').datetime.now()} ===\n")
log.flush()
result = subprocess.run(
["bash", script],
capture_output=True, text=True, timeout=300,
cwd=workdir,
)
log.write(result.stdout)
if result.stderr:
log.write(result.stderr)
log.write(f"Exit code: {result.returncode}\n")
log.close()
class WebhookHandler(http.server.BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/webhook/deploy":
self.send_response(404)
self.end_headers()
return
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
try:
payload = json.loads(body)
except json.JSONDecodeError:
self.send_response(400)
self.end_headers()
self.wfile.write(b"Invalid JSON")
return
ref = payload.get("ref", "")
config = DEPLOYMENTS.get(ref)
if not config:
self.send_response(200)
self.end_headers()
self.wfile.write(f"Skipping push to {ref}".encode())
return
thread = threading.Thread(target=run_deploy, args=(ref, config))
thread.daemon = True
thread.start()
self.send_response(200)
self.end_headers()
self.wfile.write(f"Deploy triggered for {ref}".encode())
print(f"[webhook] Deploy triggered for {ref}")
def log_message(self, format, *args):
print(f"[webhook] {args[0]}")
if __name__ == "__main__":
server = http.server.HTTPServer(("0.0.0.0", PORT), WebhookHandler)
print(f"[webhook] Listening on 0.0.0.0:{PORT}")
server.serve_forever()