- {user.avatar ? (
+ {user?.avatar ? (
// eslint-disable-next-line @next/next/no-img-element
{cal.name || cal.title}
diff --git a/frontend/src/components/Providers.tsx b/frontend/src/components/Providers.tsx
index 2897769d..7afb9dce 100644
--- a/frontend/src/components/Providers.tsx
+++ b/frontend/src/components/Providers.tsx
@@ -4,6 +4,7 @@ import { UserProvider } from "@/contexts/UserContext";
import { CalendarProvider } from "@/contexts/CalendarContext";
import { NotificationsProvider } from "@/contexts/NotificationsContext";
import { MobileNavPlacementProvider } from "@/contexts/MobileNavPlacementContext";
+import { PwaProvider } from "@/contexts/PwaContext";
import { ThemeProvider, useTheme } from "@/contexts/ThemeContext";
import { ReactNode } from "react";
import { Toaster } from "sonner";
@@ -21,8 +22,10 @@ export function Providers({ children }: { children: ReactNode }) {
-
- {children}
+
+
+ {children}
+
diff --git a/frontend/src/components/calendar/CalendarCard.tsx b/frontend/src/components/calendar/CalendarCard.tsx
index c376c867..f2017aa8 100644
--- a/frontend/src/components/calendar/CalendarCard.tsx
+++ b/frontend/src/components/calendar/CalendarCard.tsx
@@ -11,6 +11,7 @@ import {
formatDateKey,
type CalendarEvent,
type CalendarEventDraft,
+ toCalendarEventDraft,
} from "@/services/calendarService";
import { DEFAULT_EVENT_COLOR, resolveEventColor } from "@/lib/calendar-event-colors";
import {
@@ -153,12 +154,12 @@ export const CalendarCard = memo(function CalendarCard({
if (event.is_recurring && event.event_id) {
try {
const fullEvent = await calendarService.loadFullEvent(event.event_id, event.start || undefined, event.end || undefined);
- onOpenEventModal(fullEvent);
+ onOpenEventModal(toCalendarEventDraft(fullEvent));
} catch (err) {
console.error("Ошибка загрузки базового события:", err);
}
} else {
- onOpenEventModal(event);
+ onOpenEventModal(toCalendarEventDraft(event));
}
}, [onOpenEventModal]);
@@ -622,7 +623,7 @@ export const CalendarCard = memo(function CalendarCard({
{event.title}
{(event.is_recurring || event.rule) && (
diff --git a/frontend/src/contexts/PwaContext.tsx b/frontend/src/contexts/PwaContext.tsx
new file mode 100644
index 00000000..ee14f484
--- /dev/null
+++ b/frontend/src/contexts/PwaContext.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
+
+import {
+ type BeforeInstallPromptEvent,
+ isStandaloneDisplayMode,
+ registerAppServiceWorker,
+} from "@/lib/pwa";
+
+type InstallResult = "accepted" | "dismissed" | "unavailable";
+
+type PwaContextValue = {
+ canInstall: boolean;
+ isInstalled: boolean;
+ isRegistrationReady: boolean;
+ install: () => Promise
;
+};
+
+const PwaContext = createContext(undefined);
+
+export function PwaProvider({ children }: { children: ReactNode }) {
+ const [deferredPrompt, setDeferredPrompt] = useState(null);
+ const [isInstalled, setIsInstalled] = useState(() => isStandaloneDisplayMode());
+ const [isRegistrationReady, setIsRegistrationReady] = useState(false);
+
+ useEffect(() => {
+ void registerAppServiceWorker()
+ .then((registration) => {
+ setIsRegistrationReady(Boolean(registration));
+ })
+ .catch((error) => {
+ console.error("[PWA] Service Worker registration failed:", error);
+ setIsRegistrationReady(false);
+ });
+ }, []);
+
+ useEffect(() => {
+ const handleBeforeInstallPrompt = (event: Event) => {
+ const installEvent = event as BeforeInstallPromptEvent;
+ installEvent.preventDefault();
+ setDeferredPrompt(installEvent);
+ };
+
+ const handleAppInstalled = () => {
+ setDeferredPrompt(null);
+ setIsInstalled(true);
+ };
+
+ window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
+ window.addEventListener("appinstalled", handleAppInstalled);
+
+ return () => {
+ window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
+ window.removeEventListener("appinstalled", handleAppInstalled);
+ };
+ }, []);
+
+ const install = useCallback(async (): Promise => {
+ if (!deferredPrompt) {
+ return "unavailable";
+ }
+
+ await deferredPrompt.prompt();
+ const choice = await deferredPrompt.userChoice;
+
+ if (choice.outcome === "accepted") {
+ setIsInstalled(true);
+ setDeferredPrompt(null);
+ return "accepted";
+ }
+
+ return "dismissed";
+ }, [deferredPrompt]);
+
+ const value = useMemo(
+ () => ({
+ canInstall: !isInstalled && deferredPrompt !== null,
+ isInstalled,
+ isRegistrationReady,
+ install,
+ }),
+ [deferredPrompt, install, isInstalled, isRegistrationReady],
+ );
+
+ return {children};
+}
+
+export function usePwa() {
+ const context = useContext(PwaContext);
+ if (!context) {
+ throw new Error("usePwa must be used within a PwaProvider");
+ }
+ return context;
+}
diff --git a/frontend/src/hooks/useCalendarModals.ts b/frontend/src/hooks/useCalendarModals.ts
index 0d026ff8..9a3694a2 100644
--- a/frontend/src/hooks/useCalendarModals.ts
+++ b/frontend/src/hooks/useCalendarModals.ts
@@ -4,7 +4,11 @@ import { useState, useCallback } from "react";
import { apiClient } from "@/lib/api";
import { type CalendarParticipantsTarget } from "@/lib/calendar/ui";
import { DEFAULT_EVENT_COLOR } from "@/lib/calendar-event-colors";
-import type { CalendarEvent, CalendarEventDraft } from "@/services/calendarService";
+import {
+ toCalendarEventDraft,
+ type CalendarEvent,
+ type CalendarEventDraft,
+} from "@/services/calendarService";
export interface CalendarModalsState {
showCalendarModal: boolean;
@@ -16,7 +20,7 @@ export interface CalendarModalsState {
editingEvent: CalendarEventDraft | null;
participantsCalendar: CalendarParticipantsTarget | null;
selectedDateForModal: Date | null;
- viewingEvent: CalendarEvent | null;
+ viewingEvent: CalendarEventDraft | null;
sidebarEvents: CalendarEvent[];
currentSelectedCalendarId: number | null;
eventsRefreshTrigger: number;
@@ -33,7 +37,7 @@ export function useCalendarModals() {
const [editingEvent, setEditingEvent] = useState(null);
const [participantsCalendar, setParticipantsCalendar] = useState(null);
const [selectedDateForModal, setSelectedDateForModal] = useState(null);
- const [viewingEvent, setViewingEvent] = useState(null);
+ const [viewingEvent, setViewingEvent] = useState(null);
const [sidebarEvents, setSidebarEvents] = useState([]);
const [currentSelectedCalendarId, setCurrentSelectedCalendarId] = useState(null);
const [eventsRefreshTrigger, setEventsRefreshTrigger] = useState(0);
@@ -60,7 +64,7 @@ export function useCalendarModals() {
setSelectedDateForModal(date);
setShowDayEventsModal(true);
} else if (event.id) {
- setViewingEvent(event as CalendarEvent);
+ setViewingEvent(event);
setShowEventDetailsModal(true);
} else {
setEditingEvent(event);
@@ -106,17 +110,17 @@ export function useCalendarModals() {
if (event.is_recurring && event.event_id) {
try {
const fullEvent = await apiClient.getEvent(event.event_id);
- setViewingEvent({
+ setViewingEvent(toCalendarEventDraft({
...fullEvent,
start: event.start,
end: event.end,
- });
+ }));
setShowEventDetailsModal(true);
} catch (err) {
console.error("Ошибка загрузки события:", err);
}
} else {
- setViewingEvent(event);
+ setViewingEvent(toCalendarEventDraft(event));
setShowEventDetailsModal(true);
}
}, []);
diff --git a/frontend/src/lib/push.ts b/frontend/src/lib/push.ts
index e8b15083..48f35846 100644
--- a/frontend/src/lib/push.ts
+++ b/frontend/src/lib/push.ts
@@ -2,6 +2,8 @@
* Web Push утилиты для управления подписками
*/
+import { registerAppServiceWorker } from "@/lib/pwa";
+
/**
* Конвертирует base64 строку в Uint8Array для applicationServerKey
*/
@@ -68,27 +70,12 @@ export async function requestNotificationPermission(): Promise {
- if (!('serviceWorker' in navigator)) {
+ const registration = await registerAppServiceWorker();
+ if (!registration) {
throw new Error('Service Worker не поддерживается');
}
-
- const registration = await navigator.serviceWorker.register('/sw.js', {
- scope: '/'
- });
-
+
console.log('[PushNotifications] Service Worker зарегистрирован:', registration.scope);
-
- // Ждем активации
- if (registration.installing) {
- await new Promise((resolve) => {
- registration.installing!.addEventListener('statechange', (e) => {
- if ((e.target as ServiceWorker).state === 'activated') {
- resolve();
- }
- });
- });
- }
-
return registration;
}
diff --git a/frontend/src/lib/pwa.ts b/frontend/src/lib/pwa.ts
new file mode 100644
index 00000000..cd938d47
--- /dev/null
+++ b/frontend/src/lib/pwa.ts
@@ -0,0 +1,49 @@
+"use client";
+
+export interface BeforeInstallPromptEvent extends Event {
+ readonly platforms: string[];
+ readonly userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
+ prompt: () => Promise;
+}
+
+export function isStandaloneDisplayMode(): boolean {
+ if (typeof window === "undefined") {
+ return false;
+ }
+
+ const standaloneMatch = window.matchMedia?.("(display-mode: standalone)").matches;
+ const navigatorStandalone =
+ typeof navigator !== "undefined" &&
+ "standalone" in navigator &&
+ Boolean((navigator as Navigator & { standalone?: boolean }).standalone);
+
+ return Boolean(standaloneMatch || navigatorStandalone);
+}
+
+export async function registerAppServiceWorker(): Promise {
+ if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
+ return null;
+ }
+
+ const existingRegistration = await navigator.serviceWorker.getRegistration("/");
+ if (existingRegistration) {
+ await existingRegistration.update().catch(() => undefined);
+ return existingRegistration;
+ }
+
+ const registration = await navigator.serviceWorker.register("/sw.js", {
+ scope: "/",
+ });
+
+ if (registration.installing) {
+ await new Promise((resolve) => {
+ registration.installing?.addEventListener("statechange", (event) => {
+ if ((event.target as ServiceWorker).state === "activated") {
+ resolve();
+ }
+ });
+ });
+ }
+
+ return registration;
+}
diff --git a/frontend/src/services/calendarService.ts b/frontend/src/services/calendarService.ts
index 62acfbdd..ee8c294f 100644
--- a/frontend/src/services/calendarService.ts
+++ b/frontend/src/services/calendarService.ts
@@ -27,6 +27,25 @@ export type CalendarEventDraft = Record & {
can_delete?: boolean;
};
+export function toCalendarEventDraft(
+ event: CalendarEvent | CalendarEventDetails,
+): CalendarEventDraft {
+ return {
+ ...event,
+ id: typeof event.id === "number" ? event.id : undefined,
+ start: event.start ?? null,
+ end: event.end ?? null,
+ calendar:
+ typeof event.calendar === "number" ? event.calendar : event.calendar ?? null,
+ color_event: event.color_event ?? null,
+ rule: event.rule ?? null,
+ event_id: "event_id" in event ? event.event_id : undefined,
+ is_recurring: "is_recurring" in event ? event.is_recurring : false,
+ can_edit: event.can_edit,
+ can_delete: event.can_delete,
+ };
+}
+
interface EventsCache {
key: string;
events: CalendarEvent[];