init
This commit is contained in:
91
frontend/composables/useApi.ts
Normal file
91
frontend/composables/useApi.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
type ApiErrorPayload = {
|
||||
message?: string;
|
||||
issues?: {
|
||||
formErrors?: string[];
|
||||
fieldErrors?: Record<string, string[] | undefined>;
|
||||
};
|
||||
};
|
||||
|
||||
type ApiLikeError = Error & {
|
||||
statusCode?: number;
|
||||
status?: number;
|
||||
data?: ApiErrorPayload;
|
||||
};
|
||||
|
||||
const apiFieldLabels: Record<string, string> = {
|
||||
email: "Email",
|
||||
password: "Пароль",
|
||||
confirmPassword: "Повтор пароля",
|
||||
title: "Заголовок",
|
||||
body: "Текст",
|
||||
status: "Статус",
|
||||
startsAt: "Начало",
|
||||
expiresAt: "Окончание"
|
||||
};
|
||||
|
||||
function getApiFieldLabel(field: string) {
|
||||
return apiFieldLabels[field] ?? field;
|
||||
}
|
||||
|
||||
function getApiErrorMessage(error: ApiLikeError) {
|
||||
const formErrors = Array.isArray(error.data?.issues?.formErrors)
|
||||
? error.data!.issues!.formErrors.filter((entry) => typeof entry === "string" && entry.trim())
|
||||
: [];
|
||||
|
||||
if (formErrors.length > 0) {
|
||||
return formErrors.join("\n");
|
||||
}
|
||||
|
||||
const fieldErrors = error.data?.issues?.fieldErrors;
|
||||
if (fieldErrors && typeof fieldErrors === "object") {
|
||||
const messages = Object.entries(fieldErrors)
|
||||
.flatMap(([field, value]) =>
|
||||
(Array.isArray(value) ? value : [])
|
||||
.filter((entry) => typeof entry === "string" && entry.trim())
|
||||
.map((entry) => `${getApiFieldLabel(field)}: ${entry}`)
|
||||
);
|
||||
|
||||
if (messages.length > 0) {
|
||||
return messages.join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
const responseMessage = typeof error.data?.message === "string" ? error.data.message.trim() : "";
|
||||
if (responseMessage) {
|
||||
return responseMessage;
|
||||
}
|
||||
|
||||
const statusCode = error.statusCode ?? error.status;
|
||||
|
||||
if (statusCode === 400) return "Проверьте правильность заполнения формы";
|
||||
if (statusCode === 401) return "Неверный логин или пароль";
|
||||
if (statusCode === 403) return "Недостаточно прав для выполнения действия";
|
||||
if (statusCode === 404) return "Запрашиваемые данные не найдены";
|
||||
if (statusCode === 409) return "Такая запись уже существует";
|
||||
if (statusCode === 422) return "Не удалось обработать введённые данные";
|
||||
if (statusCode && statusCode >= 500) return "Ошибка сервера. Попробуйте ещё раз позже";
|
||||
|
||||
return error.message || "Не удалось выполнить запрос";
|
||||
}
|
||||
|
||||
export function useApi<T>(path: string, options: Parameters<typeof $fetch<T>>[1] = {}) {
|
||||
const config = useRuntimeConfig();
|
||||
const token = useState<string | null>("auth-token", () => null);
|
||||
const baseUrl = process.server ? config.apiBaseInternal : config.public.apiBase;
|
||||
|
||||
const headers = new Headers(options.headers as HeadersInit | undefined);
|
||||
if (!headers.has("Content-Type") && options.method && options.method !== "GET") {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
if (token.value) {
|
||||
headers.set("Authorization", `Bearer ${token.value}`);
|
||||
}
|
||||
|
||||
return $fetch<T>(`${baseUrl}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: "include"
|
||||
}).catch((error: ApiLikeError) => {
|
||||
throw new Error(getApiErrorMessage(error));
|
||||
});
|
||||
}
|
||||
92
frontend/composables/useAuth.ts
Normal file
92
frontend/composables/useAuth.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { Preferences } from "@capacitor/preferences";
|
||||
import type { User } from "~/types";
|
||||
|
||||
export function useAuth() {
|
||||
const user = useState<User | null>("auth-user", () => null);
|
||||
const token = useState<string | null>("auth-token", () => null);
|
||||
const loading = useState<boolean>("auth-loading", () => true);
|
||||
|
||||
const isNativeApp = () => process.client && Capacitor.isNativePlatform();
|
||||
const webTokenStorageKey = "auth-token";
|
||||
|
||||
const persistToken = async (nextToken: string | null) => {
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNativeApp()) {
|
||||
if (nextToken) {
|
||||
await Preferences.set({ key: webTokenStorageKey, value: nextToken });
|
||||
return;
|
||||
}
|
||||
|
||||
await Preferences.remove({ key: webTokenStorageKey });
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextToken) {
|
||||
window.localStorage.setItem(webTokenStorageKey, nextToken);
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(webTokenStorageKey);
|
||||
};
|
||||
|
||||
const restoreToken = async () => {
|
||||
if (!process.client || token.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNativeApp()) {
|
||||
const storedToken = await Preferences.get({ key: webTokenStorageKey });
|
||||
token.value = storedToken.value;
|
||||
return;
|
||||
}
|
||||
|
||||
token.value = window.localStorage.getItem(webTokenStorageKey);
|
||||
};
|
||||
|
||||
const setSession = async (nextToken: string | null, nextUser: User | null) => {
|
||||
token.value = nextToken;
|
||||
user.value = nextUser;
|
||||
await persistToken(nextToken);
|
||||
};
|
||||
|
||||
const refreshMe = async () => {
|
||||
await restoreToken();
|
||||
|
||||
try {
|
||||
const me = await useApi<User>("/auth/me");
|
||||
user.value = me;
|
||||
} catch {
|
||||
await setSession(null, null);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (nextToken: string | null, nextUser: User) => {
|
||||
loading.value = false;
|
||||
await setSession(nextToken, nextUser);
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
const { deactivateCurrentPushSubscription, syncServiceWorkerContext } = usePush();
|
||||
|
||||
await deactivateCurrentPushSubscription().catch(() => undefined);
|
||||
await useApi("/auth/logout", { method: "POST" }).catch(() => undefined);
|
||||
await setSession(null, null);
|
||||
await syncServiceWorkerContext().catch(() => undefined);
|
||||
await navigateTo("/login");
|
||||
};
|
||||
|
||||
return {
|
||||
user,
|
||||
token,
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
refreshMe
|
||||
};
|
||||
}
|
||||
34
frontend/composables/useBrowserDateTime.ts
Normal file
34
frontend/composables/useBrowserDateTime.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
type BrowserDateTimeOptions = Intl.DateTimeFormatOptions;
|
||||
|
||||
const DEFAULT_DATE_TIME_OPTIONS: BrowserDateTimeOptions = {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
};
|
||||
|
||||
export function useBrowserDateTime() {
|
||||
const browserTimeZone = useState<string | null>("browser-time-zone", () => null);
|
||||
|
||||
onMounted(() => {
|
||||
browserTimeZone.value = Intl.DateTimeFormat().resolvedOptions().timeZone || null;
|
||||
});
|
||||
|
||||
const formatDateTime = (value: string | Date | null | undefined, options: BrowserDateTimeOptions = DEFAULT_DATE_TIME_OPTIONS) => {
|
||||
if (!value) return "—";
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
...options,
|
||||
timeZone: browserTimeZone.value ?? "UTC"
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
return {
|
||||
browserTimeZone,
|
||||
formatDateTime
|
||||
};
|
||||
}
|
||||
25
frontend/composables/useChatApi.ts
Normal file
25
frontend/composables/useChatApi.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export function useChatApi<T>(path: string, options: Parameters<typeof $fetch<T>>[1] = {}) {
|
||||
const config = useRuntimeConfig();
|
||||
const { token, user } = useAuth();
|
||||
const headers = new Headers(options.headers as HeadersInit | undefined);
|
||||
|
||||
if (!headers.has("Content-Type") && options.method && options.method !== "GET") {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
if (token.value) {
|
||||
headers.set("Authorization", `Bearer ${token.value}`);
|
||||
}
|
||||
|
||||
if (user.value?.email) {
|
||||
headers.set("x-user-email", user.value.email);
|
||||
}
|
||||
|
||||
return $fetch<T>(`${config.public.chatApiBase}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: "include"
|
||||
}).catch((error: Error) => {
|
||||
throw new Error(error.message || "Не удалось выполнить запрос к чату");
|
||||
});
|
||||
}
|
||||
45
frontend/composables/useClipboard.ts
Normal file
45
frontend/composables/useClipboard.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export const useClipboard = () => {
|
||||
const fallbackCopy = (value: string) => {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = value;
|
||||
textarea.setAttribute("readonly", "true");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "-9999px";
|
||||
textarea.style.left = "-9999px";
|
||||
textarea.style.opacity = "0";
|
||||
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
return document.execCommand("copy");
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
};
|
||||
|
||||
const copyText = async (value: string) => {
|
||||
if (!process.client) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(normalized);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to legacy copy API for embedded webviews.
|
||||
}
|
||||
|
||||
return fallbackCopy(normalized);
|
||||
};
|
||||
|
||||
return { copyText };
|
||||
};
|
||||
784
frontend/composables/usePush.ts
Normal file
784
frontend/composables/usePush.ts
Normal file
@@ -0,0 +1,784 @@
|
||||
import { App as CapacitorApp } from "@capacitor/app";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { Device } from "@capacitor/device";
|
||||
import { Preferences } from "@capacitor/preferences";
|
||||
import { PushNotifications, type Token } from "@capacitor/push-notifications";
|
||||
|
||||
type PushSubscriptionsResponse = {
|
||||
items: Array<{
|
||||
id: string;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
endpoint?: string;
|
||||
platform?: string;
|
||||
}>;
|
||||
hasActiveSubscription: boolean;
|
||||
};
|
||||
|
||||
type BeforeInstallPromptEvent = Event & {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
|
||||
};
|
||||
|
||||
type NotificationPermissionState = "default" | "denied" | "granted" | "prompt" | "unsupported";
|
||||
|
||||
const ANON_PUSH_CLIENT_ID_KEY = "anonymous-push-client-id";
|
||||
const PENDING_PUSH_ROUTE_KEY = "pending-push-route";
|
||||
|
||||
export function usePush() {
|
||||
const { user, token } = useAuth();
|
||||
const config = useRuntimeConfig();
|
||||
const installPromptEvent = useState<BeforeInstallPromptEvent | null>("pwa-install-prompt-event", () => null);
|
||||
const anonymousClientId = useState<string | null>("anonymous-push-client-id", () => null);
|
||||
const nativePushToken = useState<string | null>("native-push-token", () => null);
|
||||
const nativePushDeviceId = useState<string | null>("native-push-device-id", () => null);
|
||||
const nativePushInitialized = useState<boolean>("native-push-initialized", () => false);
|
||||
const webPushClickInitialized = useState<boolean>("web-push-click-initialized", () => false);
|
||||
const pendingPushRoute = useState<string | null>("pending-push-route", () => null);
|
||||
|
||||
const isNativeApp = () => process.client && Capacitor.isNativePlatform();
|
||||
|
||||
const getNativePlatform = () => {
|
||||
if (!isNativeApp()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const platform = Capacitor.getPlatform();
|
||||
if (platform === "android" || platform === "ios") {
|
||||
return platform;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const readStoredAnonymousClientId = async () => {
|
||||
if (!process.client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isNativeApp()) {
|
||||
const result = await Preferences.get({ key: ANON_PUSH_CLIENT_ID_KEY });
|
||||
return result.value;
|
||||
}
|
||||
|
||||
return localStorage.getItem(ANON_PUSH_CLIENT_ID_KEY);
|
||||
};
|
||||
|
||||
const persistAnonymousClientId = async (value: string) => {
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNativeApp()) {
|
||||
await Preferences.set({ key: ANON_PUSH_CLIENT_ID_KEY, value });
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(ANON_PUSH_CLIENT_ID_KEY, value);
|
||||
};
|
||||
|
||||
const clearAnonymousClientId = async () => {
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
anonymousClientId.value = null;
|
||||
|
||||
if (isNativeApp()) {
|
||||
await Preferences.remove({ key: ANON_PUSH_CLIENT_ID_KEY });
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem(ANON_PUSH_CLIENT_ID_KEY);
|
||||
};
|
||||
|
||||
const readStoredPendingPushRoute = async () => {
|
||||
if (!process.client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isNativeApp()) {
|
||||
const result = await Preferences.get({ key: PENDING_PUSH_ROUTE_KEY });
|
||||
return result.value;
|
||||
}
|
||||
|
||||
return localStorage.getItem(PENDING_PUSH_ROUTE_KEY);
|
||||
};
|
||||
|
||||
const persistPendingPushRoute = async (value: string | null) => {
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingPushRoute.value = value;
|
||||
|
||||
if (isNativeApp()) {
|
||||
if (value) {
|
||||
await Preferences.set({ key: PENDING_PUSH_ROUTE_KEY, value });
|
||||
} else {
|
||||
await Preferences.remove({ key: PENDING_PUSH_ROUTE_KEY });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
localStorage.setItem(PENDING_PUSH_ROUTE_KEY, value);
|
||||
} else {
|
||||
localStorage.removeItem(PENDING_PUSH_ROUTE_KEY);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeTargetRoute = (targetUrl: unknown) => {
|
||||
if (typeof targetUrl !== "string" || targetUrl.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (targetUrl.startsWith("/")) {
|
||||
return targetUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(targetUrl);
|
||||
return `${parsed.pathname}${parsed.search}${parsed.hash}` || "/";
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const openPushTarget = async (targetUrl: unknown) => {
|
||||
const route = normalizeTargetRoute(targetUrl);
|
||||
if (!route) {
|
||||
return;
|
||||
}
|
||||
|
||||
await persistPendingPushRoute(route);
|
||||
|
||||
if (user.value) {
|
||||
await navigateTo(route);
|
||||
await persistPendingPushRoute(null);
|
||||
return;
|
||||
}
|
||||
|
||||
await navigateTo(`/login?redirect=${encodeURIComponent(route)}`);
|
||||
};
|
||||
|
||||
const consumePendingPushRoute = async () => {
|
||||
if (!process.client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!pendingPushRoute.value) {
|
||||
pendingPushRoute.value = await readStoredPendingPushRoute();
|
||||
}
|
||||
|
||||
const route = pendingPushRoute.value;
|
||||
if (!route || !user.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await navigateTo(route);
|
||||
await persistPendingPushRoute(null);
|
||||
return route;
|
||||
};
|
||||
|
||||
const ensureAnonymousClientId = async () => {
|
||||
if (!process.client) {
|
||||
return "server-client";
|
||||
}
|
||||
|
||||
if (!anonymousClientId.value) {
|
||||
anonymousClientId.value = await readStoredAnonymousClientId();
|
||||
}
|
||||
|
||||
if (!anonymousClientId.value) {
|
||||
anonymousClientId.value = crypto.randomUUID().replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
await persistAnonymousClientId(anonymousClientId.value);
|
||||
}
|
||||
|
||||
return anonymousClientId.value;
|
||||
};
|
||||
|
||||
const registerServiceWorker = async () => {
|
||||
if (!process.client || isNativeApp() || !("serviceWorker" in navigator)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return navigator.serviceWorker.register("/sw.js");
|
||||
};
|
||||
|
||||
const syncServiceWorkerContext = async () => {
|
||||
if (!process.client || isNativeApp() || !("serviceWorker" in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const registration = await registerServiceWorker();
|
||||
const readyRegistration = registration ?? (await navigator.serviceWorker.ready);
|
||||
const worker = readyRegistration.active ?? navigator.serviceWorker.controller;
|
||||
if (!worker) {
|
||||
return;
|
||||
}
|
||||
|
||||
worker.postMessage({
|
||||
type: "push-context-sync",
|
||||
payload: {
|
||||
apiBase: config.public.apiBase,
|
||||
isAuthenticated: Boolean(user.value),
|
||||
anonymousClientId: await ensureAnonymousClientId()
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const urlBase64ToUint8Array = (base64String: string) => {
|
||||
const normalizedInput = base64String.trim();
|
||||
if (!normalizedInput || normalizedInput.startsWith("replace_")) {
|
||||
throw new Error("Push-уведомления еще не настроены на сервере");
|
||||
}
|
||||
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (normalizedInput + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
|
||||
try {
|
||||
const rawData = window.atob(base64);
|
||||
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
|
||||
} catch {
|
||||
throw new Error("На сервере задан некорректный VAPID public key");
|
||||
}
|
||||
};
|
||||
|
||||
const isMobileDevice = () => {
|
||||
if (!process.client) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isNativeApp()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
};
|
||||
|
||||
const isIosDevice = () => {
|
||||
if (!process.client) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isNativeApp()) {
|
||||
return Capacitor.getPlatform() === "ios";
|
||||
}
|
||||
|
||||
return /iPhone|iPad|iPod/i.test(navigator.userAgent);
|
||||
};
|
||||
|
||||
const isStandaloneMode = () => {
|
||||
if (!process.client) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isNativeApp()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const standaloneMedia = window.matchMedia?.("(display-mode: standalone)")?.matches ?? false;
|
||||
const standaloneNavigator =
|
||||
"standalone" in navigator ? Boolean((navigator as Navigator & { standalone?: boolean }).standalone) : false;
|
||||
return standaloneMedia || standaloneNavigator;
|
||||
};
|
||||
|
||||
const setInstallPromptEvent = (event: BeforeInstallPromptEvent | null) => {
|
||||
installPromptEvent.value = event;
|
||||
};
|
||||
|
||||
const canTriggerInstallPrompt = () => !isNativeApp() && Boolean(installPromptEvent.value);
|
||||
|
||||
const triggerInstallPrompt = async () => {
|
||||
if (!installPromptEvent.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await installPromptEvent.value.prompt();
|
||||
const choice = await installPromptEvent.value.userChoice;
|
||||
installPromptEvent.value = null;
|
||||
return choice.outcome === "accepted";
|
||||
};
|
||||
|
||||
const getBrowserPermissionState = (): NotificationPermissionState => {
|
||||
if (!process.client || !("Notification" in window)) {
|
||||
return "unsupported";
|
||||
}
|
||||
|
||||
return Notification.permission;
|
||||
};
|
||||
|
||||
const getNativePermissionState = async (): Promise<NotificationPermissionState> => {
|
||||
if (!isNativeApp()) {
|
||||
return "unsupported";
|
||||
}
|
||||
|
||||
const permissions = await PushNotifications.checkPermissions();
|
||||
if (permissions.receive === "prompt") {
|
||||
return "default";
|
||||
}
|
||||
|
||||
return permissions.receive;
|
||||
};
|
||||
|
||||
const getPermissionState = () => {
|
||||
if (isNativeApp()) {
|
||||
return getNativePermissionState();
|
||||
}
|
||||
|
||||
return Promise.resolve(getBrowserPermissionState());
|
||||
};
|
||||
|
||||
const requestBrowserPermission = async (): Promise<NotificationPermissionState> => {
|
||||
if (!process.client || !("Notification" in window)) {
|
||||
return "unsupported";
|
||||
}
|
||||
|
||||
return Notification.requestPermission();
|
||||
};
|
||||
|
||||
const requestNativePermission = async (): Promise<NotificationPermissionState> => {
|
||||
if (!isNativeApp()) {
|
||||
return "unsupported";
|
||||
}
|
||||
|
||||
const permissions = await PushNotifications.requestPermissions();
|
||||
if (permissions.receive === "prompt") {
|
||||
return "default";
|
||||
}
|
||||
|
||||
return permissions.receive;
|
||||
};
|
||||
|
||||
const requestPermission = () => {
|
||||
if (isNativeApp()) {
|
||||
return requestNativePermission();
|
||||
}
|
||||
|
||||
return requestBrowserPermission();
|
||||
};
|
||||
|
||||
const syncWebSubscription = async (subscription: PushSubscription) => {
|
||||
const serializedSubscription = subscription.toJSON();
|
||||
if (!serializedSubscription.endpoint || !serializedSubscription.keys?.p256dh || !serializedSubscription.keys?.auth) {
|
||||
throw new Error("Браузер вернул неполную push-подписку");
|
||||
}
|
||||
|
||||
if (user.value) {
|
||||
await useApi("/me/push-subscriptions", {
|
||||
method: "POST",
|
||||
body: serializedSubscription
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await useApi("/public/push-subscriptions", {
|
||||
method: "POST",
|
||||
body: {
|
||||
clientId: await ensureAnonymousClientId(),
|
||||
...serializedSubscription
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const syncNativeSubscription = async (tokenValue = nativePushToken.value) => {
|
||||
const platform = getNativePlatform();
|
||||
if (!platform || !tokenValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nativePushDeviceId.value) {
|
||||
const device = await Device.getId();
|
||||
nativePushDeviceId.value = device.identifier;
|
||||
}
|
||||
|
||||
const body = {
|
||||
token: tokenValue,
|
||||
platform,
|
||||
deviceId: nativePushDeviceId.value ?? undefined
|
||||
};
|
||||
|
||||
if (user.value) {
|
||||
await useApi("/me/native-push-subscriptions", {
|
||||
method: "POST",
|
||||
body
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await useApi("/public/native-push-subscriptions", {
|
||||
method: "POST",
|
||||
body: {
|
||||
clientId: await ensureAnonymousClientId(),
|
||||
...body
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const initializeNativePush = async () => {
|
||||
if (!isNativeApp() || nativePushInitialized.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
nativePushInitialized.value = true;
|
||||
|
||||
await PushNotifications.createChannel({
|
||||
id: "signals",
|
||||
name: "Signals",
|
||||
description: "Уведомления о новых сигналах",
|
||||
importance: 5,
|
||||
visibility: 1,
|
||||
sound: "default"
|
||||
}).catch(() => undefined);
|
||||
|
||||
await PushNotifications.addListener("registration", async (registrationToken: Token) => {
|
||||
nativePushToken.value = registrationToken.value;
|
||||
await syncNativeSubscription(registrationToken.value);
|
||||
});
|
||||
|
||||
await PushNotifications.addListener("registrationError", (error) => {
|
||||
console.error("Native push registration failed", error);
|
||||
});
|
||||
|
||||
await PushNotifications.addListener("pushNotificationActionPerformed", (notification) => {
|
||||
const targetUrl =
|
||||
notification.notification.data?.url ||
|
||||
notification.notification.data?.link ||
|
||||
notification.notification.data?.path;
|
||||
|
||||
void openPushTarget(targetUrl);
|
||||
});
|
||||
|
||||
await CapacitorApp.addListener("appUrlOpen", ({ url }) => {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const route = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
void openPushTarget(route || "/");
|
||||
} catch {
|
||||
// Ignore invalid deep links.
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const initializeWebPushRouting = () => {
|
||||
if (!process.client || isNativeApp() || webPushClickInitialized.value || !("serviceWorker" in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
webPushClickInitialized.value = true;
|
||||
|
||||
navigator.serviceWorker.addEventListener("message", (event) => {
|
||||
if (event.data?.type !== "push-notification-click") {
|
||||
return;
|
||||
}
|
||||
|
||||
void openPushTarget(event.data.url);
|
||||
});
|
||||
};
|
||||
|
||||
const getCurrentBrowserSubscription = async () => {
|
||||
const registration = await registerServiceWorker();
|
||||
if (!registration || !("PushManager" in window)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return registration.pushManager.getSubscription();
|
||||
};
|
||||
|
||||
const claimAnonymousPushSubscriptions = async () => {
|
||||
if (!process.client || !user.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingClientId = anonymousClientId.value ?? (await readStoredAnonymousClientId());
|
||||
if (!existingClientId) {
|
||||
await syncServiceWorkerContext();
|
||||
return;
|
||||
}
|
||||
|
||||
anonymousClientId.value = existingClientId;
|
||||
|
||||
if (isNativeApp()) {
|
||||
if (nativePushToken.value) {
|
||||
await syncNativeSubscription(nativePushToken.value);
|
||||
}
|
||||
|
||||
const anonymousSubscriptions = await useApi<PushSubscriptionsResponse>(
|
||||
`/public/native-push-subscriptions/${existingClientId}`
|
||||
).catch(() => null);
|
||||
|
||||
for (const subscription of anonymousSubscriptions?.items ?? []) {
|
||||
await useApi(`/public/native-push-subscriptions/${existingClientId}/${subscription.id}`, {
|
||||
method: "DELETE"
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
} else {
|
||||
const currentSubscription = await getCurrentBrowserSubscription();
|
||||
if (currentSubscription) {
|
||||
await syncWebSubscription(currentSubscription);
|
||||
}
|
||||
|
||||
const anonymousSubscriptions = await useApi<PushSubscriptionsResponse>(
|
||||
`/public/push-subscriptions/${existingClientId}`
|
||||
).catch(() => null);
|
||||
|
||||
for (const subscription of anonymousSubscriptions?.items ?? []) {
|
||||
await useApi(`/public/push-subscriptions/${existingClientId}/${subscription.id}`, {
|
||||
method: "DELETE"
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
await clearAnonymousClientId();
|
||||
await syncServiceWorkerContext();
|
||||
};
|
||||
|
||||
const getCurrentSubscription = async () => {
|
||||
if (isNativeApp()) {
|
||||
return nativePushToken.value;
|
||||
}
|
||||
|
||||
return getCurrentBrowserSubscription();
|
||||
};
|
||||
|
||||
const deactivateCurrentPushSubscription = async () => {
|
||||
if (!process.client || !user.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNativeApp()) {
|
||||
if (!nativePushToken.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await useApi("/me/native-push-subscriptions/deactivate", {
|
||||
method: "POST",
|
||||
body: {
|
||||
token: nativePushToken.value
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSubscription = await getCurrentBrowserSubscription();
|
||||
if (!currentSubscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serializedSubscription = currentSubscription.toJSON();
|
||||
if (!serializedSubscription.endpoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
await useApi("/me/push-subscriptions/deactivate", {
|
||||
method: "POST",
|
||||
body: {
|
||||
endpoint: serializedSubscription.endpoint
|
||||
}
|
||||
});
|
||||
|
||||
await currentSubscription.unsubscribe().catch(() => undefined);
|
||||
};
|
||||
|
||||
const subscribeToBrowserPush = async () => {
|
||||
const registration = await registerServiceWorker();
|
||||
if (!registration || !("PushManager" in window)) {
|
||||
throw new Error("Push не поддерживается в этом браузере");
|
||||
}
|
||||
|
||||
const vapid = await useApi<{ publicKey: string }>("/vapid-public-key");
|
||||
if (!vapid.publicKey || vapid.publicKey.startsWith("replace_")) {
|
||||
throw new Error("Push-уведомления еще не настроены на сервере");
|
||||
}
|
||||
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapid.publicKey)
|
||||
});
|
||||
|
||||
await syncWebSubscription(subscription);
|
||||
return subscription;
|
||||
};
|
||||
|
||||
const subscribeToNativePush = async () => {
|
||||
await initializeNativePush();
|
||||
|
||||
let permission = await getNativePermissionState();
|
||||
if (permission === "default") {
|
||||
permission = await requestNativePermission();
|
||||
}
|
||||
|
||||
if (permission !== "granted") {
|
||||
throw new Error("Разрешение на уведомления не выдано");
|
||||
}
|
||||
|
||||
const device = await Device.getId();
|
||||
nativePushDeviceId.value = device.identifier;
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
let finished = false;
|
||||
|
||||
const finalize = (callback: () => void) => {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
finished = true;
|
||||
callback();
|
||||
};
|
||||
|
||||
PushNotifications.addListener("registration", async (registrationToken: Token) => {
|
||||
finalize(() => {
|
||||
nativePushToken.value = registrationToken.value;
|
||||
void syncNativeSubscription(registrationToken.value);
|
||||
resolve(registrationToken.value);
|
||||
});
|
||||
}).catch(reject);
|
||||
|
||||
PushNotifications.addListener("registrationError", (error) => {
|
||||
finalize(() => {
|
||||
reject(new Error(error.error || "Не удалось зарегистрировать устройство для push"));
|
||||
});
|
||||
}).catch(reject);
|
||||
|
||||
PushNotifications.register().catch((error) => {
|
||||
finalize(() => {
|
||||
reject(error instanceof Error ? error : new Error("Не удалось зарегистрировать устройство для push"));
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const subscribeToPush = () => {
|
||||
if (isNativeApp()) {
|
||||
return subscribeToNativePush();
|
||||
}
|
||||
|
||||
return subscribeToBrowserPush();
|
||||
};
|
||||
|
||||
const ensurePushSubscription = async () => {
|
||||
const permission = await getPermissionState();
|
||||
if (permission === "unsupported") {
|
||||
throw new Error("Push не поддерживается на этом устройстве");
|
||||
}
|
||||
|
||||
let finalPermission = permission;
|
||||
if (finalPermission === "default") {
|
||||
finalPermission = await requestPermission();
|
||||
}
|
||||
|
||||
if (finalPermission !== "granted") {
|
||||
throw new Error("Разрешение на уведомления не выдано");
|
||||
}
|
||||
|
||||
if (isNativeApp()) {
|
||||
if (nativePushToken.value) {
|
||||
await syncNativeSubscription(nativePushToken.value);
|
||||
return nativePushToken.value;
|
||||
}
|
||||
|
||||
return subscribeToNativePush();
|
||||
}
|
||||
|
||||
const currentSubscription = await getCurrentBrowserSubscription();
|
||||
if (currentSubscription) {
|
||||
await syncWebSubscription(currentSubscription);
|
||||
return currentSubscription;
|
||||
}
|
||||
|
||||
return subscribeToBrowserPush();
|
||||
};
|
||||
|
||||
const getPushStatus = async () => {
|
||||
const permission = await getPermissionState();
|
||||
const isMobile = isMobileDevice();
|
||||
const isIos = isIosDevice();
|
||||
const isStandalone = isStandaloneMode();
|
||||
const installRequired = !isNativeApp() && isMobile && !isStandalone;
|
||||
|
||||
if (permission === "unsupported") {
|
||||
return {
|
||||
supported: false,
|
||||
permission,
|
||||
isMobile,
|
||||
isIos,
|
||||
isStandalone,
|
||||
installRequired,
|
||||
canInstall: canTriggerInstallPrompt(),
|
||||
hasBrowserSubscription: false,
|
||||
hasServerSubscription: false
|
||||
};
|
||||
}
|
||||
|
||||
let hasServerSubscription = false;
|
||||
|
||||
try {
|
||||
if (isNativeApp()) {
|
||||
if (user.value) {
|
||||
const serverSubscriptions = await useApi<PushSubscriptionsResponse>("/me/native-push-subscriptions");
|
||||
hasServerSubscription = serverSubscriptions.hasActiveSubscription;
|
||||
} else {
|
||||
const clientId = await ensureAnonymousClientId();
|
||||
const serverSubscriptions = await useApi<PushSubscriptionsResponse>(`/public/native-push-subscriptions/${clientId}`);
|
||||
hasServerSubscription = serverSubscriptions.hasActiveSubscription;
|
||||
}
|
||||
} else if (user.value) {
|
||||
const serverSubscriptions = await useApi<PushSubscriptionsResponse>("/me/push-subscriptions");
|
||||
hasServerSubscription = serverSubscriptions.hasActiveSubscription;
|
||||
} else {
|
||||
const clientId = await ensureAnonymousClientId();
|
||||
const serverSubscriptions = await useApi<PushSubscriptionsResponse>(`/public/push-subscriptions/${clientId}`);
|
||||
hasServerSubscription = serverSubscriptions.hasActiveSubscription;
|
||||
}
|
||||
} catch {
|
||||
hasServerSubscription = false;
|
||||
}
|
||||
|
||||
return {
|
||||
supported: true,
|
||||
permission,
|
||||
isMobile,
|
||||
isIos,
|
||||
isStandalone,
|
||||
installRequired,
|
||||
canInstall: canTriggerInstallPrompt(),
|
||||
hasBrowserSubscription: isNativeApp()
|
||||
? Boolean(nativePushToken.value)
|
||||
: Boolean(await getCurrentBrowserSubscription()),
|
||||
hasServerSubscription
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
registerServiceWorker,
|
||||
syncServiceWorkerContext,
|
||||
syncNativeSubscription,
|
||||
initializeNativePush,
|
||||
initializeWebPushRouting,
|
||||
isNativeApp,
|
||||
isMobileDevice,
|
||||
isIosDevice,
|
||||
isStandaloneMode,
|
||||
getPermissionState,
|
||||
requestPermission,
|
||||
getCurrentSubscription,
|
||||
deactivateCurrentPushSubscription,
|
||||
getPushStatus,
|
||||
subscribeToPush,
|
||||
ensurePushSubscription,
|
||||
claimAnonymousPushSubscriptions,
|
||||
consumePendingPushRoute,
|
||||
setInstallPromptEvent,
|
||||
canTriggerInstallPrompt,
|
||||
triggerInstallPrompt,
|
||||
ensureAnonymousClientId
|
||||
};
|
||||
}
|
||||
70
frontend/composables/useSupportRealtime.ts
Normal file
70
frontend/composables/useSupportRealtime.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { io, type Socket } from "socket.io-client";
|
||||
import type { SupportConversation, SupportMessage } from "~/types";
|
||||
|
||||
type SupportRealtimePayload = {
|
||||
conversation: SupportConversation;
|
||||
message?: SupportMessage;
|
||||
};
|
||||
|
||||
export function useSupportRealtime() {
|
||||
const socket = useState<Socket | null>("support-realtime-socket", () => null);
|
||||
const { token } = useAuth();
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
const connect = () => {
|
||||
if (!process.client || !token.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (socket.value?.connected) {
|
||||
return socket.value;
|
||||
}
|
||||
|
||||
if (socket.value) {
|
||||
socket.value.auth = { token: token.value };
|
||||
socket.value.connect();
|
||||
return socket.value;
|
||||
}
|
||||
|
||||
const nextSocket = io(config.public.chatApiBase, {
|
||||
transports: ["websocket", "polling"],
|
||||
withCredentials: true,
|
||||
auth: {
|
||||
token: token.value
|
||||
}
|
||||
});
|
||||
|
||||
socket.value = nextSocket;
|
||||
return nextSocket;
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
socket.value?.disconnect();
|
||||
};
|
||||
|
||||
const onConversationUpdated = (handler: (payload: SupportRealtimePayload) => void) => {
|
||||
const activeSocket = connect();
|
||||
activeSocket?.on("support:conversation.updated", handler);
|
||||
|
||||
return () => {
|
||||
activeSocket?.off("support:conversation.updated", handler);
|
||||
};
|
||||
};
|
||||
|
||||
const onMessageCreated = (handler: (payload: SupportRealtimePayload) => void) => {
|
||||
const activeSocket = connect();
|
||||
activeSocket?.on("support:message.created", handler);
|
||||
|
||||
return () => {
|
||||
activeSocket?.off("support:message.created", handler);
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
socket,
|
||||
connect,
|
||||
disconnect,
|
||||
onConversationUpdated,
|
||||
onMessageCreated
|
||||
};
|
||||
}
|
||||
106
frontend/composables/useSupportUnread.ts
Normal file
106
frontend/composables/useSupportUnread.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { SupportConversation } from "~/types";
|
||||
|
||||
const toConversationMap = (items: SupportConversation[]) =>
|
||||
Object.fromEntries(items.map((item) => [item.id, item])) as Record<string, SupportConversation>;
|
||||
|
||||
export function useSupportUnread() {
|
||||
const { user, token } = useAuth();
|
||||
const { connect, onConversationUpdated, onMessageCreated } = useSupportRealtime();
|
||||
|
||||
const conversations = useState<Record<string, SupportConversation>>("support-unread-conversations", () => ({}));
|
||||
const unreadCount = useState<number>("support-unread-count", () => 0);
|
||||
const initialized = useState<boolean>("support-unread-initialized", () => false);
|
||||
const loading = useState<boolean>("support-unread-loading", () => false);
|
||||
const offConversationUpdated = useState<(() => void) | null>("support-unread-off-conversation", () => null);
|
||||
const offMessageCreated = useState<(() => void) | null>("support-unread-off-message", () => null);
|
||||
|
||||
const recomputeUnreadCount = () => {
|
||||
const items = Object.values(conversations.value);
|
||||
|
||||
unreadCount.value = user.value?.role === "admin"
|
||||
? items.filter((item) => item.unreadForAdmin).length
|
||||
: items.some((item) => item.unreadForUser) ? 1 : 0;
|
||||
};
|
||||
|
||||
const replaceConversations = (items: SupportConversation[]) => {
|
||||
conversations.value = toConversationMap(items);
|
||||
recomputeUnreadCount();
|
||||
};
|
||||
|
||||
const upsertConversation = (item: SupportConversation) => {
|
||||
conversations.value = {
|
||||
...conversations.value,
|
||||
[item.id]: item
|
||||
};
|
||||
recomputeUnreadCount();
|
||||
};
|
||||
|
||||
const clearState = () => {
|
||||
conversations.value = {};
|
||||
unreadCount.value = 0;
|
||||
initialized.value = false;
|
||||
offConversationUpdated.value?.();
|
||||
offConversationUpdated.value = null;
|
||||
offMessageCreated.value?.();
|
||||
offMessageCreated.value = null;
|
||||
};
|
||||
|
||||
const refreshUnread = async () => {
|
||||
if (!user.value || !token.value || loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
if (user.value.role === "admin") {
|
||||
const response = await useChatApi<SupportConversation[]>("/admin/support/conversations");
|
||||
replaceConversations(response);
|
||||
} else {
|
||||
const response = await useChatApi<SupportConversation>("/support/conversation");
|
||||
replaceConversations([response]);
|
||||
}
|
||||
} catch {
|
||||
// Ignore unread refresh failures in shell navigation.
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureRealtime = () => {
|
||||
if (!process.client || !token.value || offConversationUpdated.value || offMessageCreated.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
connect();
|
||||
offConversationUpdated.value = onConversationUpdated(({ conversation }) => {
|
||||
if (conversation) {
|
||||
upsertConversation(conversation);
|
||||
}
|
||||
});
|
||||
offMessageCreated.value = onMessageCreated(({ conversation }) => {
|
||||
if (conversation) {
|
||||
upsertConversation(conversation);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const initializeUnread = async () => {
|
||||
if (initialized.value || !user.value || !token.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshUnread();
|
||||
ensureRealtime();
|
||||
initialized.value = true;
|
||||
};
|
||||
|
||||
return {
|
||||
unreadCount,
|
||||
initializeUnread,
|
||||
refreshUnread,
|
||||
replaceConversations,
|
||||
upsertConversation,
|
||||
clearState
|
||||
};
|
||||
}
|
||||
66
frontend/composables/useTheme.ts
Normal file
66
frontend/composables/useTheme.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
type ThemeMode = "light" | "dark";
|
||||
|
||||
const STORAGE_KEY = "signals-theme";
|
||||
|
||||
const themes: Record<ThemeMode, { background: string; surface: string }> = {
|
||||
light: {
|
||||
background: "#f3f6fb",
|
||||
surface: "#ffffff"
|
||||
},
|
||||
dark: {
|
||||
background: "#0f172a",
|
||||
surface: "#162033"
|
||||
}
|
||||
};
|
||||
|
||||
export const useTheme = () => {
|
||||
const theme = useState<ThemeMode>("theme-mode", () => "light");
|
||||
const initialized = useState("theme-initialized", () => false);
|
||||
|
||||
const applyTheme = (nextTheme: ThemeMode) => {
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
theme.value = nextTheme;
|
||||
|
||||
const root = document.documentElement;
|
||||
root.dataset.theme = nextTheme;
|
||||
root.style.colorScheme = nextTheme;
|
||||
root.classList.toggle("app-dark", nextTheme === "dark");
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, nextTheme);
|
||||
|
||||
const themeMeta = document.querySelector('meta[name="theme-color"]');
|
||||
const themeColor = themes[nextTheme].surface;
|
||||
|
||||
if (themeMeta) {
|
||||
themeMeta.setAttribute("content", themeColor);
|
||||
}
|
||||
|
||||
document.body.style.backgroundColor = themes[nextTheme].background;
|
||||
};
|
||||
|
||||
const initializeTheme = () => {
|
||||
if (!process.client || initialized.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storedTheme = localStorage.getItem(STORAGE_KEY);
|
||||
const nextTheme = storedTheme === "light" || storedTheme === "dark" ? storedTheme : "light";
|
||||
|
||||
applyTheme(nextTheme);
|
||||
initialized.value = true;
|
||||
};
|
||||
|
||||
const toggleTheme = () => {
|
||||
applyTheme(theme.value === "dark" ? "light" : "dark");
|
||||
};
|
||||
|
||||
return {
|
||||
theme: readonly(theme),
|
||||
initializeTheme,
|
||||
applyTheme,
|
||||
toggleTheme
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user