diff --git a/Caddyfile b/Caddyfile
index 2bf9bdc..64af54d 100644
--- a/Caddyfile
+++ b/Caddyfile
@@ -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 "/"
}
diff --git a/deploy.log b/deploy.log
new file mode 100644
index 0000000..3abc464
--- /dev/null
+++ b/deploy.log
@@ -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
diff --git a/deploy.sh b/deploy.sh
new file mode 100755
index 0000000..4f277ff
--- /dev/null
+++ b/deploy.sh
@@ -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
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
index 107202f..c722e80 100644
--- a/public/manifest.webmanifest
+++ b/public/manifest.webmanifest
@@ -1,7 +1,7 @@
{
- "name": "Школьное питание",
- "short_name": "Школьное питание",
- "description": "Панель управления доставкой заказов с доступом к кабинетам логиста, водителя и менеджера.",
+ "name": "SuperSam Доставка",
+ "short_name": "SuperSam",
+ "description": "Панель управления доставкой стройматериалов с уведомлениями.",
"start_url": "/dashboard",
"scope": "/",
"display": "standalone",
diff --git a/public/service-worker.js b/public/service-worker.js
index 1d6ea31..3593d27 100644
--- a/public/service-worker.js
+++ b/public/service-worker.js
@@ -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);
+ }),
+ );
+});
diff --git a/src/components/UI/Icons.jsx b/src/components/UI/Icons.jsx
new file mode 100644
index 0000000..614ad12
--- /dev/null
+++ b/src/components/UI/Icons.jsx
@@ -0,0 +1,50 @@
+import React from "react";
+
+const Icon = ({ children, className = "" }) => (
+
+);
+
+export const Bell = (props) => (
+
+
+
+
+);
+
+export const Settings = (props) => (
+
+
+
+
+);
+
+export const Check = (props) => (
+
+
+
+);
+
+export const CheckCheck = (props) => (
+
+
+
+
+);
+
+export const X = (props) => (
+
+
+
+
+);
diff --git a/src/components/notifications/NotificationBell.jsx b/src/components/notifications/NotificationBell.jsx
new file mode 100644
index 0000000..da7d0ac
--- /dev/null
+++ b/src/components/notifications/NotificationBell.jsx
@@ -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 (
+
+
+
+ {isOpen && (
+
+
+
+
Уведомления
+
+ {unreadCount > 0 && (
+
+ )}
+ {onOpenSettings && (
+
+ )}
+
+
+
+
+
+ {notifications.length === 0 ? (
+
+ Нет уведомлений
+
+ ) : (
+ notifications.map((notif) => (
+
!notif.read && onMarkAsRead(notif.id)}
+ >
+
+ {TYPE_ICONS[notif.type] || "📦"}
+
+
+
+
+ {notif.title}
+
+
+ {formatTimeAgo(notif.created_at)}
+
+
+ {notif.body && (
+
+ {notif.body}
+
+ )}
+
+ {!notif.read && (
+
+ )}
+
+ ))
+ )}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/notifications/NotificationSettings.jsx b/src/components/notifications/NotificationSettings.jsx
new file mode 100644
index 0000000..aae93de
--- /dev/null
+++ b/src/components/notifications/NotificationSettings.jsx
@@ -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 (
+
+
+ {onBack && (
+
+ )}
+
Настройки уведомлений
+
+
+ {/* Push toggle */}
+
+
+
+
+
+
Push-уведомления
+
+ {isSupported
+ ? isSubscribed
+ ? "Включены — вы получаете уведомления на устройстве"
+ : "Выкл — нажмите чтобы включить"
+ : "Не поддерживаются в этом браузере"}
+
+
+
+ {isSupported && (
+
+ )}
+
+
+
+ {/* Type preferences */}
+
+
+
+ Что уведомлять
+
+
+ {NOTIF_TYPES.map(({ key, label, description }) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/hooks/useNotifications.js b/src/hooks/useNotifications.js
new file mode 100644
index 0000000..8ae7f67
--- /dev/null
+++ b/src/hooks/useNotifications.js
@@ -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 };
+}
diff --git a/src/hooks/usePushNotifications.js b/src/hooks/usePushNotifications.js
new file mode 100644
index 0000000..cece5a6
--- /dev/null
+++ b/src/hooks/usePushNotifications.js
@@ -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 };
+}
diff --git a/src/layouts/AppShell.jsx b/src/layouts/AppShell.jsx
index 643e2c9..8630722 100644
--- a/src/layouts/AppShell.jsx
+++ b/src/layouts/AppShell.jsx
@@ -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 (
+
+
+ setShowNotifSettings(false)}
+ />
+
+
+ );
+ }
return (
@@ -75,6 +95,13 @@ export const AppShell = ({
+ setShowNotifSettings(true)}
+ />
{onOpenGuide ? (
+
setShowNotifSettings(true)}
+ />
{user.name}
{ROLE_LABELS[user.role]}
diff --git a/src/pages/DashboardPage.jsx b/src/pages/DashboardPage.jsx
index 3a561d4..c994e27 100644
--- a/src/pages/DashboardPage.jsx
+++ b/src/pages/DashboardPage.jsx
@@ -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
;
+ return
;
}
const renderManagerWorkspace = () => (
@@ -130,6 +139,10 @@ export const DashboardPage = () => {
activeSection={activeSection}
onSectionChange={setActiveSection}
sectionMeta={activeSectionMeta}
+ notifications={notifications}
+ unreadCount={unreadCount}
+ onMarkNotificationRead={markNotificationRead}
+ onMarkAllNotificationsRead={markAllNotificationsRead}
>
{isLoading ? (
diff --git a/webhook_listener.py b/webhook_listener.py
new file mode 100644
index 0000000..5149600
--- /dev/null
+++ b/webhook_listener.py
@@ -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()