Install PWA prompt on the front page

Closes #2988
This commit is contained in:
Kalle
2026-06-11 21:18:02 +03:00
parent e064dfb4e7
commit 8ccff04259
35 changed files with 444 additions and 16 deletions

View File

@@ -0,0 +1,62 @@
.banner {
/* needed for firefox to have a valid output */
--l: 50%;
position: relative;
background: linear-gradient(
to right,
var(--color-bg),
var(--l),
var(--color-bg-higher)
);
border: var(--border-style-high);
border-radius: var(--radius-box);
padding: var(--s-4);
overflow: hidden;
}
.bannerContent {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--s-2);
padding-inline-end: min(130px, 30%);
line-height: 1.15;
}
.bannerTitle {
font-size: var(--font-lg);
font-weight: var(--weight-bold);
margin: 0;
}
.homeScreenIcon {
position: absolute;
right: var(--s-6);
top: 50%;
translate: 0 -50%;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-1);
}
.homeScreenIconImg {
width: 56px;
border-radius: 22.5%;
box-shadow: 0 2px 6px rgb(0 0 0 / 25%);
}
.homeScreenIconLabel {
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--color-text);
}
.instructionsList {
display: flex;
flex-direction: column;
gap: var(--s-2);
margin: 0;
padding-inline-start: var(--s-6);
}

View File

@@ -0,0 +1,188 @@
import { Download } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import styles from "./PWAInstallBanner.module.css";
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<{ outcome: "accepted" | "dismissed" }>;
}
type InstallState = "hidden" | "native" | "ios" | "safari";
const SAFARI_MIN_INSTALL_VERSION = 17;
const BANNER_DISMISSED_KEY = "pwa-install-banner-dismissed";
let deferredInstallPrompt: BeforeInstallPromptEvent | null = null;
let bannerDismissed = false;
const installStateListeners = new Set<() => void>();
if (typeof window !== "undefined") {
try {
bannerDismissed = localStorage.getItem(BANNER_DISMISSED_KEY) === "true";
} catch {
// localStorage may be unavailable
}
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault();
deferredInstallPrompt = event as BeforeInstallPromptEvent;
notifyInstallStateChanged();
});
window.addEventListener("appinstalled", () => {
deferredInstallPrompt = null;
notifyInstallStateChanged();
});
}
export function PWAInstallBanner() {
const { t } = useTranslation(["front", "common"]);
const installState = useInstallState();
if (installState === "hidden") return null;
return (
<div className={styles.banner}>
<div className={styles.bannerContent}>
<h2 className={styles.bannerTitle}>
{t("front:install.banner.title")}
</h2>
<p className="text-xs text-lighter">
{t("front:install.banner.description")}
</p>
<div className="stack horizontal md items-center mt-4">
<InstallAction installState={installState} />
<SendouButton
variant="minimal-destructive"
size="small"
onPress={dismissBanner}
>
{t("common:actions.dismiss")}
</SendouButton>
</div>
</div>
<div className={styles.homeScreenIcon}>
<img
src="/static-assets/img/app-icon.png"
alt=""
className={styles.homeScreenIconImg}
draggable="false"
/>
<span className={styles.homeScreenIconLabel}>sendou.ink</span>
</div>
</div>
);
}
function InstallAction({
installState,
}: {
installState: Exclude<InstallState, "hidden">;
}) {
const { t } = useTranslation(["front"]);
const installButton = (
<SendouButton
size="small"
icon={<Download />}
onPress={
installState === "native"
? () => deferredInstallPrompt?.prompt()
: undefined
}
>
{t("front:install.button")}
</SendouButton>
);
if (installState === "native") {
return installButton;
}
return (
<SendouDialog
heading={t("front:install.header")}
trigger={installButton}
isDismissable
showCloseButton
>
<ol className={styles.instructionsList}>
<li>{t(`front:install.${installState}.step1`)}</li>
<li>{t(`front:install.${installState}.step2`)}</li>
<li>{t(`front:install.${installState}.step3`)}</li>
</ol>
</SendouDialog>
);
}
function useInstallState() {
return React.useSyncExternalStore(
subscribeToInstallState,
getInstallStateSnapshot,
getServerInstallStateSnapshot,
);
}
function subscribeToInstallState(callback: () => void) {
installStateListeners.add(callback);
return () => installStateListeners.delete(callback);
}
function dismissBanner() {
bannerDismissed = true;
try {
localStorage.setItem(BANNER_DISMISSED_KEY, "true");
} catch {
// localStorage may be unavailable
}
notifyInstallStateChanged();
}
function getInstallStateSnapshot(): InstallState {
if (bannerDismissed) return "hidden";
if (isStandalone()) return "hidden";
if (deferredInstallPrompt) return "native";
if (isIos()) return "ios";
if (isInstallCapableSafariDesktop()) return "safari";
return "hidden";
}
function getServerInstallStateSnapshot(): InstallState {
return "hidden";
}
function notifyInstallStateChanged() {
for (const listener of installStateListeners) {
listener();
}
}
function isStandalone() {
return (
window.matchMedia("(display-mode: standalone)").matches ||
window.matchMedia("(display-mode: tabbed)").matches ||
(navigator as { standalone?: boolean }).standalone === true
);
}
function isIos() {
return (
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.userAgent.includes("Mac") && navigator.maxTouchPoints > 1)
);
}
function isInstallCapableSafariDesktop() {
const userAgent = navigator.userAgent;
const isSafari =
userAgent.includes("Safari") &&
!userAgent.includes("Chrome") &&
!userAgent.includes("Chromium");
if (!isSafari || !userAgent.includes("Mac")) return false;
const versionMatch = userAgent.match(/Version\/(\d+)/);
if (!versionMatch) return false;
return Number(versionMatch[1]) >= SAFARI_MIN_INSTALL_VERSION;
}

View File

@@ -14,6 +14,7 @@ import { LocaleTimeRange } from "~/components/LocaleTimeRange";
import { navItems } from "~/components/layout/nav-items";
import { Main } from "~/components/Main";
import { TournamentCard } from "~/features/calendar/components/TournamentCard";
import { PWAInstallBanner } from "~/features/front-page/components/PWAInstallBanner";
import { SplatoonRotations } from "~/features/front-page/components/SplatoonRotations";
import type * as Changelog from "~/features/front-page/core/Changelog.server";
import * as Seasons from "~/features/mmr/core/Seasons";
@@ -309,6 +310,7 @@ function DiscoverFeatures() {
</Link>
))}
</nav>
<PWAInstallBanner />
</div>
);
}

View File

@@ -126,6 +126,7 @@
"actions.copyTimestampForDiscord": "",
"actions.create": "Skab",
"actions.close": "Luk",
"actions.dismiss": "",
"actions.cancel": "Annuller",
"actions.loading": "Indlæser...",
"actions.clear": "Ryd",

View File

@@ -40,5 +40,15 @@
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": "",
"discover.header": ""
"discover.header": "",
"install.button": "",
"install.header": "",
"install.banner.title": "",
"install.banner.description": "",
"install.ios.step1": "",
"install.ios.step2": "",
"install.ios.step3": "",
"install.safari.step1": "",
"install.safari.step2": "",
"install.safari.step3": ""
}

View File

@@ -126,6 +126,7 @@
"actions.copyTimestampForDiscord": "Discord-Zeitstempel kopieren",
"actions.create": "Erstellen",
"actions.close": "Schließen",
"actions.dismiss": "",
"actions.cancel": "Abbrechen",
"actions.loading": "Lädt...",
"actions.clear": "Leeren",

View File

@@ -40,5 +40,15 @@
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": "",
"discover.header": ""
"discover.header": "",
"install.button": "",
"install.header": "",
"install.banner.title": "",
"install.banner.description": "",
"install.ios.step1": "",
"install.ios.step2": "",
"install.ios.step3": "",
"install.safari.step1": "",
"install.safari.step2": "",
"install.safari.step3": ""
}

View File

@@ -126,6 +126,7 @@
"actions.copyTimestampForDiscord": "Copy Discord timestamp",
"actions.create": "Create",
"actions.close": "Close",
"actions.dismiss": "Dismiss",
"actions.cancel": "Cancel",
"actions.loading": "Loading...",
"actions.clear": "Clear",

View File

@@ -40,5 +40,15 @@
"rotations.nextLabel": "Next",
"rotations.credit": "Data from splatoon3.ink",
"rotations.filter.all": "All",
"discover.header": "Discover all the features"
"discover.header": "Discover all the features",
"install.button": "Install app",
"install.header": "Install sendou.ink",
"install.banner.title": "Get the sendou.ink app",
"install.banner.description": "Add sendou.ink to your home screen for quick access",
"install.ios.step1": "Tap the share button in your browser",
"install.ios.step2": "Select \"Add to Home Screen\"",
"install.ios.step3": "Tap \"Add\"",
"install.safari.step1": "Click the share button in Safari's toolbar",
"install.safari.step2": "Select \"Add to Dock\"",
"install.safari.step3": "Click \"Add\""
}

View File

@@ -126,6 +126,7 @@
"actions.copyTimestampForDiscord": "Copiar timestamp para Discord",
"actions.create": "Crear",
"actions.close": "Cerrar",
"actions.dismiss": "",
"actions.cancel": "Cancelar",
"actions.loading": "Cargando...",
"actions.clear": "Limpiar",

View File

@@ -40,5 +40,15 @@
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": "",
"discover.header": ""
"discover.header": "",
"install.button": "",
"install.header": "",
"install.banner.title": "",
"install.banner.description": "",
"install.ios.step1": "",
"install.ios.step2": "",
"install.ios.step3": "",
"install.safari.step1": "",
"install.safari.step2": "",
"install.safari.step3": ""
}

View File

@@ -126,6 +126,7 @@
"actions.copyTimestampForDiscord": "",
"actions.create": "Crear",
"actions.close": "Cerrar",
"actions.dismiss": "",
"actions.cancel": "Cancelar",
"actions.loading": "Cargando...",
"actions.clear": "Aclarar",

View File

@@ -40,5 +40,15 @@
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": "",
"discover.header": ""
"discover.header": "",
"install.button": "",
"install.header": "",
"install.banner.title": "",
"install.banner.description": "",
"install.ios.step1": "",
"install.ios.step2": "",
"install.ios.step3": "",
"install.safari.step1": "",
"install.safari.step2": "",
"install.safari.step3": ""
}

View File

@@ -126,6 +126,7 @@
"actions.copyTimestampForDiscord": "",
"actions.create": "Créer",
"actions.close": "Fermer",
"actions.dismiss": "",
"actions.cancel": "Annuler",
"actions.loading": "Chargement...",
"actions.clear": "Effacer",

View File

@@ -40,5 +40,15 @@
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": "",
"discover.header": ""
"discover.header": "",
"install.button": "",
"install.header": "",
"install.banner.title": "",
"install.banner.description": "",
"install.ios.step1": "",
"install.ios.step2": "",
"install.ios.step3": "",
"install.safari.step1": "",
"install.safari.step2": "",
"install.safari.step3": ""
}

View File

@@ -126,6 +126,7 @@
"actions.copyTimestampForDiscord": "",
"actions.create": "Créer",
"actions.close": "Fermer",
"actions.dismiss": "",
"actions.cancel": "Annuler le match",
"actions.loading": "Chargement...",
"actions.clear": "Effacer",

View File

@@ -40,5 +40,15 @@
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": "",
"discover.header": ""
"discover.header": "",
"install.button": "",
"install.header": "",
"install.banner.title": "",
"install.banner.description": "",
"install.ios.step1": "",
"install.ios.step2": "",
"install.ios.step3": "",
"install.safari.step1": "",
"install.safari.step2": "",
"install.safari.step3": ""
}

View File

@@ -126,6 +126,7 @@
"actions.copyTimestampForDiscord": "",
"actions.create": "יצירה",
"actions.close": "סגירה",
"actions.dismiss": "",
"actions.cancel": "ביטול",
"actions.loading": "טוען...",
"actions.clear": "ניקוי",

View File

@@ -40,5 +40,15 @@
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": "",
"discover.header": ""
"discover.header": "",
"install.button": "",
"install.header": "",
"install.banner.title": "",
"install.banner.description": "",
"install.ios.step1": "",
"install.ios.step2": "",
"install.ios.step3": "",
"install.safari.step1": "",
"install.safari.step2": "",
"install.safari.step3": ""
}

View File

@@ -126,6 +126,7 @@
"actions.copyTimestampForDiscord": "",
"actions.create": "Crea",
"actions.close": "Chiudi",
"actions.dismiss": "",
"actions.cancel": "Cancella",
"actions.loading": "Caricamento in corso...",
"actions.clear": "Svuota",

View File

@@ -40,5 +40,15 @@
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": "",
"discover.header": ""
"discover.header": "",
"install.button": "",
"install.header": "",
"install.banner.title": "",
"install.banner.description": "",
"install.ios.step1": "",
"install.ios.step2": "",
"install.ios.step3": "",
"install.safari.step1": "",
"install.safari.step2": "",
"install.safari.step3": ""
}

View File

@@ -126,6 +126,7 @@
"actions.copyTimestampForDiscord": "",
"actions.create": "作成",
"actions.close": "閉じる",
"actions.dismiss": "",
"actions.cancel": "キャンセル",
"actions.loading": "Loading...",
"actions.clear": "クリア",

View File

@@ -40,5 +40,15 @@
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": "",
"discover.header": ""
"discover.header": "",
"install.button": "",
"install.header": "",
"install.banner.title": "",
"install.banner.description": "",
"install.ios.step1": "",
"install.ios.step2": "",
"install.ios.step3": "",
"install.safari.step1": "",
"install.safari.step2": "",
"install.safari.step3": ""
}

View File

@@ -126,6 +126,7 @@
"actions.copyTimestampForDiscord": "",
"actions.create": "생성",
"actions.close": "닫기",
"actions.dismiss": "",
"actions.cancel": "취소",
"actions.loading": "로딩 중...",
"actions.clear": "비우기",

View File

@@ -40,5 +40,15 @@
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": "",
"discover.header": ""
"discover.header": "",
"install.button": "",
"install.header": "",
"install.banner.title": "",
"install.banner.description": "",
"install.ios.step1": "",
"install.ios.step2": "",
"install.ios.step3": "",
"install.safari.step1": "",
"install.safari.step2": "",
"install.safari.step3": ""
}

View File

@@ -126,6 +126,7 @@
"actions.copyTimestampForDiscord": "",
"actions.create": "",
"actions.close": "",
"actions.dismiss": "",
"actions.cancel": "",
"actions.loading": "",
"actions.clear": "",

View File

@@ -40,5 +40,15 @@
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": "",
"discover.header": ""
"discover.header": "",
"install.button": "",
"install.header": "",
"install.banner.title": "",
"install.banner.description": "",
"install.ios.step1": "",
"install.ios.step2": "",
"install.ios.step3": "",
"install.safari.step1": "",
"install.safari.step2": "",
"install.safari.step3": ""
}

View File

@@ -126,6 +126,7 @@
"actions.copyTimestampForDiscord": "",
"actions.create": "Stwórz",
"actions.close": "Zamknij",
"actions.dismiss": "",
"actions.cancel": "Anuluj",
"actions.loading": "Ładowanie...",
"actions.clear": "Wyczyść",

View File

@@ -40,5 +40,15 @@
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": "",
"discover.header": ""
"discover.header": "",
"install.button": "",
"install.header": "",
"install.banner.title": "",
"install.banner.description": "",
"install.ios.step1": "",
"install.ios.step2": "",
"install.ios.step3": "",
"install.safari.step1": "",
"install.safari.step2": "",
"install.safari.step3": ""
}

View File

@@ -126,6 +126,7 @@
"actions.copyTimestampForDiscord": "",
"actions.create": "Criar",
"actions.close": "Fechar",
"actions.dismiss": "",
"actions.cancel": "Cancelar",
"actions.loading": "Carregando...",
"actions.clear": "Limpar",

View File

@@ -40,5 +40,15 @@
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": "",
"discover.header": ""
"discover.header": "",
"install.button": "",
"install.header": "",
"install.banner.title": "",
"install.banner.description": "",
"install.ios.step1": "",
"install.ios.step2": "",
"install.ios.step3": "",
"install.safari.step1": "",
"install.safari.step2": "",
"install.safari.step3": ""
}

View File

@@ -126,6 +126,7 @@
"actions.copyTimestampForDiscord": "",
"actions.create": "Создать",
"actions.close": "Закрыть",
"actions.dismiss": "",
"actions.cancel": "Отменить",
"actions.loading": "Загрузка...",
"actions.clear": "Очистить",

View File

@@ -40,5 +40,15 @@
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": "",
"discover.header": ""
"discover.header": "",
"install.button": "",
"install.header": "",
"install.banner.title": "",
"install.banner.description": "",
"install.ios.step1": "",
"install.ios.step2": "",
"install.ios.step3": "",
"install.safari.step1": "",
"install.safari.step2": "",
"install.safari.step3": ""
}

View File

@@ -126,6 +126,7 @@
"actions.copyTimestampForDiscord": "",
"actions.create": "创建",
"actions.close": "关闭",
"actions.dismiss": "",
"actions.cancel": "取消",
"actions.loading": "加载中...",
"actions.clear": "清除",

View File

@@ -40,5 +40,15 @@
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": "",
"discover.header": ""
"discover.header": "",
"install.button": "",
"install.header": "",
"install.banner.title": "",
"install.banner.description": "",
"install.ios.step1": "",
"install.ios.step2": "",
"install.ios.step3": "",
"install.safari.step1": "",
"install.safari.step2": "",
"install.safari.step3": ""
}