This commit is contained in:
talorr
2026-03-27 03:36:08 +03:00
parent 8a97ce6d54
commit cda36918e8
225 changed files with 35641 additions and 0 deletions

View 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));
});
}

View 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
};
}

View 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
};
}

View 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 || "Не удалось выполнить запрос к чату");
});
}

View 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 };
};

View 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
};
}

View 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
};
}

View 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
};
}

View 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
};
};