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

382
frontend/app.vue Normal file
View File

@@ -0,0 +1,382 @@
<script setup lang="ts">
import { App as CapacitorApp, type AppInfo } from "@capacitor/app";
const { user, loading } = useAuth();
const { initializeTheme } = useTheme();
const {
ensurePushSubscription,
getPushStatus,
triggerInstallPrompt,
canTriggerInstallPrompt,
isNativeApp,
initializeWebPushRouting,
consumePendingPushRoute
} = usePush();
type AppVersionPayload = {
latestVersion: string | null;
minSupportedVersion: string | null;
updateUrl: string | null;
message: string | null;
};
const promptDismissed = ref(false);
const installPromptDismissed = ref(false);
const promptLoading = ref(false);
const installLoading = ref(false);
const promptMessage = ref("");
const showPushPrompt = ref(false);
const showInstallPrompt = ref(false);
const installHelpMessage = ref("");
const showUpdatePrompt = ref(false);
const isUpdateRequired = ref(false);
const updateLoading = ref(false);
const updateMessage = ref("");
const updateUrl = ref("");
let updateCheckIntervalId: ReturnType<typeof window.setInterval> | null = null;
let lastUpdateCheckAt = 0;
let appStateChangeListener: Awaited<ReturnType<typeof CapacitorApp.addListener>> | null = null;
const UPDATE_CHECK_INTERVAL_MS = 60_000;
const parseVersion = (value: string) =>
value
.split(".")
.map((part) => Number.parseInt(part.replace(/\D+/g, ""), 10))
.map((part) => (Number.isFinite(part) ? part : 0));
const compareVersions = (left: string, right: string) => {
const leftParts = parseVersion(left);
const rightParts = parseVersion(right);
const maxLength = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < maxLength; index += 1) {
const leftPart = leftParts[index] ?? 0;
const rightPart = rightParts[index] ?? 0;
if (leftPart > rightPart) return 1;
if (leftPart < rightPart) return -1;
}
return 0;
};
const formatPushError = (error: unknown) => {
if (!(error instanceof Error)) {
return "Не удалось подключить уведомления";
}
if (error.message.includes("/public/push-subscriptions") && error.message.includes("404")) {
return "На сервере пока нет публичного push-endpoint. Нужно обновить backend до последней версии.";
}
if (error.message.includes("/public/push-subscriptions") && error.message.includes("400")) {
return "Сервер отклонил push-подписку. Обычно это значит, что backend на сервере ещё не обновлён или работает старая версия API.";
}
return error.message;
};
const checkAppVersion = async () => {
if (!process.client || !isNativeApp()) {
showUpdatePrompt.value = false;
return;
}
try {
const [appInfo, versionInfo] = await Promise.all([
CapacitorApp.getInfo(),
useApi<AppVersionPayload>("/app-version")
]);
const currentVersion = (appInfo as AppInfo).version?.trim();
const latestVersion = versionInfo.latestVersion?.trim();
const minSupportedVersion = versionInfo.minSupportedVersion?.trim();
if (!currentVersion || (!latestVersion && !minSupportedVersion)) {
showUpdatePrompt.value = false;
return;
}
isUpdateRequired.value = Boolean(
minSupportedVersion && compareVersions(currentVersion, minSupportedVersion) < 0
);
const hasOptionalUpdate = Boolean(
latestVersion && compareVersions(currentVersion, latestVersion) < 0
);
showUpdatePrompt.value = isUpdateRequired.value || hasOptionalUpdate;
updateUrl.value = versionInfo.updateUrl?.trim() || "";
updateMessage.value = versionInfo.message?.trim() || "Доступна новая версия приложения";
} catch {
showUpdatePrompt.value = false;
}
};
const checkAppVersionThrottled = async (force = false) => {
if (!process.client || !isNativeApp()) {
return;
}
const now = Date.now();
if (!force && now - lastUpdateCheckAt < UPDATE_CHECK_INTERVAL_MS) {
return;
}
lastUpdateCheckAt = now;
await checkAppVersion();
};
const refreshPromptState = async () => {
if (!process.client || loading.value) {
showPushPrompt.value = false;
showInstallPrompt.value = false;
return;
}
if (!user.value) {
showPushPrompt.value = false;
showInstallPrompt.value = false;
promptMessage.value = "";
return;
}
const status = await getPushStatus();
showInstallPrompt.value = false;
showPushPrompt.value = false;
installHelpMessage.value = "";
if (isNativeApp()) {
return;
}
if (status.installRequired && !installPromptDismissed.value) {
showInstallPrompt.value = true;
if (status.isIos) {
installHelpMessage.value =
"На iPhone и iPad push-уведомления работают только после установки приложения на экран «Домой». Откройте меню Safari и выберите «На экран Домой».";
} else if (!status.canInstall) {
installHelpMessage.value =
"Если кнопки установки нет, откройте меню браузера на телефоне и выберите «Установить приложение» или «Добавить на главный экран».";
}
}
if (!status.supported) {
return;
}
if (status.permission === "denied") {
promptMessage.value = "Уведомления запрещены в браузере. Их можно включить в настройках сайта.";
return;
}
if (!showInstallPrompt.value) {
showPushPrompt.value = !promptDismissed.value && (!status.hasBrowserSubscription || !status.hasServerSubscription);
}
};
const enableNotifications = async () => {
promptLoading.value = true;
promptMessage.value = "";
try {
await ensurePushSubscription();
showPushPrompt.value = false;
promptDismissed.value = true;
promptMessage.value = "Уведомления подключены";
} catch (error) {
promptMessage.value = formatPushError(error);
} finally {
promptLoading.value = false;
}
};
const installApplication = async () => {
installLoading.value = true;
installHelpMessage.value = "";
try {
const accepted = await triggerInstallPrompt();
if (!accepted) {
installHelpMessage.value = "Установка была отменена. Можно вернуться к этому позже.";
return;
}
installPromptDismissed.value = true;
showInstallPrompt.value = false;
promptMessage.value = "Приложение установлено. Теперь можно включить push-уведомления.";
await refreshPromptState();
} catch (error) {
installHelpMessage.value = error instanceof Error ? error.message : "Не удалось открыть окно установки";
} finally {
installLoading.value = false;
}
};
const openUpdateUrl = async () => {
if (!process.client || !updateUrl.value) {
return;
}
updateLoading.value = true;
try {
window.location.assign(updateUrl.value);
} finally {
updateLoading.value = false;
}
};
const dismissPrompt = () => {
promptDismissed.value = true;
showPushPrompt.value = false;
};
const dismissInstallPrompt = () => {
installPromptDismissed.value = true;
showInstallPrompt.value = false;
};
const dismissUpdatePrompt = () => {
if (isUpdateRequired.value) {
return;
}
showUpdatePrompt.value = false;
};
onMounted(async () => {
initializeTheme();
initializeWebPushRouting();
void refreshPromptState();
void consumePendingPushRoute();
void checkAppVersionThrottled(true);
if (!process.client) {
return;
}
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("focus", handleWindowFocus);
if (isNativeApp()) {
appStateChangeListener = await CapacitorApp.addListener("appStateChange", handleAppStateChange);
}
updateCheckIntervalId = window.setInterval(() => {
void checkAppVersionThrottled();
}, UPDATE_CHECK_INTERVAL_MS);
});
onBeforeUnmount(() => {
if (!process.client) {
return;
}
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("focus", handleWindowFocus);
if (updateCheckIntervalId !== null) {
window.clearInterval(updateCheckIntervalId);
updateCheckIntervalId = null;
}
void appStateChangeListener?.remove();
appStateChangeListener = null;
});
const handleAppStateChange = ({ isActive }: { isActive: boolean }) => {
if (!isActive) {
return;
}
void checkAppVersionThrottled(true);
};
const handleVisibilityChange = () => {
if (document.visibilityState !== "visible") {
return;
}
void checkAppVersionThrottled();
};
const handleWindowFocus = () => {
void checkAppVersionThrottled();
};
watch([user, loading], () => {
promptMessage.value = "";
void refreshPromptState();
void consumePendingPushRoute();
});
</script>
<template>
<NuxtLayout>
<div v-if="showUpdatePrompt" :class="isUpdateRequired ? 'app-update app-update--blocking' : 'push-consent app-update'">
<div class="push-consent__content">
<strong>{{ isUpdateRequired ? "Нужно обновить приложение" : "Доступно обновление" }}</strong>
<p>{{ updateMessage }}</p>
<p v-if="isUpdateRequired">Текущая версия больше не поддерживается. Обновите приложение, чтобы продолжить работу.</p>
</div>
<div class="push-consent__actions">
<button :disabled="updateLoading || !updateUrl" @click="openUpdateUrl">
{{ updateLoading ? "Открываем..." : "Обновить приложение" }}
</button>
<button
v-if="!isUpdateRequired"
class="secondary"
:disabled="updateLoading"
@click="dismissUpdatePrompt"
>
Позже
</button>
</div>
</div>
<div v-if="showInstallPrompt" class="push-consent push-consent--install">
<div class="push-consent__content">
<strong>Установить приложение на телефон?</strong>
<p>
Чтобы уведомления приходили на мобильном устройстве, откройте сервис как установленное PWA-приложение.
</p>
<p v-if="installHelpMessage" class="push-consent__hint">{{ installHelpMessage }}</p>
</div>
<div class="push-consent__actions">
<button v-if="canTriggerInstallPrompt()" :disabled="installLoading" @click="installApplication">
{{ installLoading ? "Открываем..." : "Установить" }}
</button>
<button class="secondary" :disabled="installLoading" @click="dismissInstallPrompt">Не сейчас</button>
</div>
</div>
<div v-if="showPushPrompt" class="push-consent">
<div class="push-consent__content">
<strong>Получать уведомления о новых матчах и сигналах?</strong>
<p>
При появлении новых матчей в списке мы будем отправлять push-уведомления в браузер и в установленное приложение.
</p>
</div>
<div class="push-consent__actions">
<button :disabled="promptLoading" @click="enableNotifications">
{{ promptLoading ? "Подключаем..." : "Да, включить" }}
</button>
<button class="secondary" :disabled="promptLoading" @click="dismissPrompt">Не сейчас</button>
</div>
</div>
<p
v-if="promptMessage && !isUpdateRequired"
class="push-consent__message"
:style="{ marginTop: 'calc(0.5rem + env(safe-area-inset-top, 0px))' }"
>
{{ promptMessage }}
</p>
<NuxtPage v-if="!isUpdateRequired" />
</NuxtLayout>
</template>