Files
antigol-service/frontend/app.vue
talorr cda36918e8 init
2026-03-27 03:36:08 +03:00

383 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>