383 lines
12 KiB
Vue
383 lines
12 KiB
Vue
<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>
|