init
This commit is contained in:
382
frontend/app.vue
Normal file
382
frontend/app.vue
Normal 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>
|
||||
Reference in New Issue
Block a user