Als App auf dem PC installierbar — mit gut sichtbarem Installations-Button
Die Icons/Manifeste waren schon vorbereitet, aber es gab keinen echten Weg, die Installation auch zu STARTEN, ohne das kleine, leicht zu übersehende Icon im Browser selbst zu suchen. Jetzt: - Ein Service Worker (public/sw.js) ist Voraussetzung dafür, dass Browser die Seite überhaupt als "installierbare App" erkennen. Bewusst minimal gehalten: strikt "network-first" — bei bestehender Verbindung wird IMMER zuerst das echte Netzwerk gefragt, der Cache dient nur als Rückfalllösung, wenn wirklich keine Verbindung besteht. Dadurch besteht KEIN Risiko, dass der Adminbereich veraltete Inhalte zeigt, während man online ist — als Bonus funktionieren bereits besuchte Seiten aber auch kurz offline (mit echtem Offline-Test geprüft). - Neuer "Installieren"-Button direkt in der Kopfzeile (Hauptseite) bzw. unten rechts (Adminbereich) — erscheint automatisch, sobald der Browser eine Installation wirklich anbietet, verschwindet danach wieder bzw. bleibt komplett versteckt, wenn schon als App installiert. Ein Klick reicht, kein Suchen im Browser-Menü mehr nötig. - Beide Bereiche (Hauptseite + Admin) nutzen dieselben, bereits vorhandenen Logo-Icons/Manifeste — landet also mit dem echten VanVan-Logo auf dem Startbildschirm/Desktop. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0ff6412e1e
commit
4aee6c7b98
@@ -65,6 +65,30 @@
|
|||||||
h1, h2, h3 {
|
h1, h2, h3 {
|
||||||
font-family: "Fraunces", "Playfair Display", Georgia, serif !important;
|
font-family: "Fraunces", "Playfair Display", Georgia, serif !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Schwebender "Installieren"-Button — nur sichtbar, wenn der Browser eine Installation
|
||||||
|
wirklich anbietet (siehe Script unten). position:fixed statt normaler Dokumentfluss,
|
||||||
|
damit er unabhängig davon funktioniert, wie Sveltia sein eigenes UI in <body> aufbaut. */
|
||||||
|
#install-btn {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.2rem;
|
||||||
|
right: 1.2rem;
|
||||||
|
z-index: 9999;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
|
padding: 0.7em 1.1em;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(135deg, #9b7fd6, #2e1b47);
|
||||||
|
border: 1px solid rgba(155, 127, 214, 0.6);
|
||||||
|
color: #f4f1f7;
|
||||||
|
font-family: "Inter", system-ui, sans-serif;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 10px 24px -8px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
#install-btn:hover { filter: brightness(1.1); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -72,5 +96,37 @@
|
|||||||
direkt im GitHub-Repo. Kein eigener Server nötig — siehe config.yml für die
|
direkt im GitHub-Repo. Kein eigener Server nötig — siehe config.yml für die
|
||||||
Feld-Definitionen (Produkte, Kategorien). -->
|
Feld-Definitionen (Produkte, Kategorien). -->
|
||||||
<script src="https://unpkg.com/@sveltia/cms/dist/sveltia-cms.js"></script>
|
<script src="https://unpkg.com/@sveltia/cms/dist/sveltia-cms.js"></script>
|
||||||
|
|
||||||
|
<button type="button" id="install-btn" title="Admin als App installieren">⬇️ Installieren</button>
|
||||||
|
<script>
|
||||||
|
// Gleiches Prinzip wie auf der Hauptseite: Button nur zeigen, wenn der Browser die
|
||||||
|
// Installation wirklich anbietet, und nicht mehr, sobald bereits als App installiert.
|
||||||
|
(function () {
|
||||||
|
var btn = document.getElementById("install-btn");
|
||||||
|
var laeuftBereitsAlsApp = window.matchMedia("(display-mode: standalone)").matches;
|
||||||
|
var promptEvent = null;
|
||||||
|
if (!laeuftBereitsAlsApp) {
|
||||||
|
window.addEventListener("beforeinstallprompt", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
promptEvent = e;
|
||||||
|
btn.style.display = "flex";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
if (!promptEvent) return;
|
||||||
|
btn.style.display = "none";
|
||||||
|
promptEvent.prompt();
|
||||||
|
promptEvent.userChoice.then(function () { promptEvent = null; });
|
||||||
|
});
|
||||||
|
window.addEventListener("appinstalled", function () {
|
||||||
|
btn.style.display = "none";
|
||||||
|
});
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
window.addEventListener("load", function () {
|
||||||
|
navigator.serviceWorker.register("/sw.js").catch(function () {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Minimaler, bewusst zurückhaltender Service Worker — NICHT für "volles Offline", sondern
|
||||||
|
// hauptsächlich damit der Browser die Seite überhaupt als installierbare App erkennt (die
|
||||||
|
// meisten Browser verlangen dafür einen registrierten Service Worker mit echtem
|
||||||
|
// fetch-Handler) und als kleiner Bonus: falls doch mal keine Verbindung besteht, können bereits
|
||||||
|
// besuchte Seiten trotzdem noch geöffnet werden.
|
||||||
|
//
|
||||||
|
// WICHTIG: strikt "network-first" — bei bestehender Verbindung wird IMMER zuerst das echte
|
||||||
|
// Netzwerk gefragt, der Cache dient nur als Rückfalllösung für den Offline-Fall. Das ist bewusst
|
||||||
|
// so gewählt, damit z.B. der Adminbereich (Sveltia CMS) NIE veraltete Inhalte aus dem Cache
|
||||||
|
// zeigt, solange eine Internetverbindung besteht.
|
||||||
|
|
||||||
|
const CACHE_NAME = "vandiy-shell-v1";
|
||||||
|
|
||||||
|
self.addEventListener("install", () => {
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))
|
||||||
|
).then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
if (event.request.method !== "GET") return;
|
||||||
|
// Cross-Origin-Requests (Google Fonts, PayPal-SDK, GitHub-API des Admin-Bereichs, ...)
|
||||||
|
// unangetastet lassen — nur die eigene Seite selbst wird für den Offline-Fall vorgehalten.
|
||||||
|
if (new URL(event.request.url).origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
const clone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)).catch(() => {});
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(event.request).then((cached) => cached || Response.error()))
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -23,6 +23,7 @@ export const ui = {
|
|||||||
onlyXLeft: (n: string) => `Nur noch ${n} auf Lager`, maxInCart: "Maximale verfügbare Menge ist bereits im Warenkorb", maxInCartShort: "Bereits im Warenkorb",
|
onlyXLeft: (n: string) => `Nur noch ${n} auf Lager`, maxInCart: "Maximale verfügbare Menge ist bereits im Warenkorb", maxInCartShort: "Bereits im Warenkorb",
|
||||||
browseShop: "Jetzt stöbern", viewAll: "Alle ansehen →", viewAllSale: "Alle Angebote →",
|
browseShop: "Jetzt stöbern", viewAll: "Alle ansehen →", viewAllSale: "Alle Angebote →",
|
||||||
toShop: "Zum Shop →", moreAboutMe: "Mehr über mich →", discoverNow: "Jetzt entdecken",
|
toShop: "Zum Shop →", moreAboutMe: "Mehr über mich →", discoverNow: "Jetzt entdecken",
|
||||||
|
installApp: "App installieren", installAppShort: "Installieren",
|
||||||
vatNote: "Gemäß §19 UStG wird keine Umsatzsteuer berechnet und ausgewiesen.",
|
vatNote: "Gemäß §19 UStG wird keine Umsatzsteuer berechnet und ausgewiesen.",
|
||||||
allPricesPlusShipping: "Alle Preise zzgl. Versand.", plusShipping: "zzgl. Versand",
|
allPricesPlusShipping: "Alle Preise zzgl. Versand.", plusShipping: "zzgl. Versand",
|
||||||
quantity: "Anzahl", photoSoon: "Produktfoto folgt", portraitSoon: "Porträtfoto von VanVan folgt",
|
quantity: "Anzahl", photoSoon: "Produktfoto folgt", portraitSoon: "Porträtfoto von VanVan folgt",
|
||||||
@@ -210,6 +211,7 @@ export const ui = {
|
|||||||
onlyXLeft: (n: string) => `Only ${n} left in stock`, maxInCart: "The maximum available quantity is already in your cart", maxInCartShort: "Already in cart",
|
onlyXLeft: (n: string) => `Only ${n} left in stock`, maxInCart: "The maximum available quantity is already in your cart", maxInCartShort: "Already in cart",
|
||||||
browseShop: "Browse now", viewAll: "View all →", viewAllSale: "View all offers →",
|
browseShop: "Browse now", viewAll: "View all →", viewAllSale: "View all offers →",
|
||||||
toShop: "To the shop →", moreAboutMe: "More about me →", discoverNow: "Discover now",
|
toShop: "To the shop →", moreAboutMe: "More about me →", discoverNow: "Discover now",
|
||||||
|
installApp: "Install app", installAppShort: "Install",
|
||||||
vatNote: "Under §19 UStG (German small-business rule) no VAT is charged or shown.",
|
vatNote: "Under §19 UStG (German small-business rule) no VAT is charged or shown.",
|
||||||
allPricesPlusShipping: "All prices plus shipping.", plusShipping: "plus shipping",
|
allPricesPlusShipping: "All prices plus shipping.", plusShipping: "plus shipping",
|
||||||
quantity: "Quantity", photoSoon: "Product photo coming soon", portraitSoon: "VanVan's portrait photo coming soon",
|
quantity: "Quantity", photoSoon: "Product photo coming soon", portraitSoon: "VanVan's portrait photo coming soon",
|
||||||
@@ -397,6 +399,7 @@ export const ui = {
|
|||||||
onlyXLeft: (n: string) => `Nume no ${n} a Lager`, maxInCart: "D'maximal verfügbari Mengi isch scho im Warenchorb", maxInCartShort: "Scho im Warenchorb",
|
onlyXLeft: (n: string) => `Nume no ${n} a Lager`, maxInCart: "D'maximal verfügbari Mengi isch scho im Warenchorb", maxInCartShort: "Scho im Warenchorb",
|
||||||
browseShop: "Jetzt use luege", viewAll: "Alles aluege →", viewAllSale: "Alli Aktione →",
|
browseShop: "Jetzt use luege", viewAll: "Alles aluege →", viewAllSale: "Alli Aktione →",
|
||||||
toShop: "Zum Shop →", moreAboutMe: "Meh über mich →", discoverNow: "Jetzt aluege",
|
toShop: "Zum Shop →", moreAboutMe: "Meh über mich →", discoverNow: "Jetzt aluege",
|
||||||
|
installApp: "App installiere", installAppShort: "Installiere",
|
||||||
vatNote: "Gemäss §19 UStG (dütsches Rächt) wird kei Umsatzsteuer berechnet und usgwiese.",
|
vatNote: "Gemäss §19 UStG (dütsches Rächt) wird kei Umsatzsteuer berechnet und usgwiese.",
|
||||||
allPricesPlusShipping: "Alli Pryse zzgl. Versand.", plusShipping: "zzgl. Versand",
|
allPricesPlusShipping: "Alli Pryse zzgl. Versand.", plusShipping: "zzgl. Versand",
|
||||||
quantity: "Aazahl", photoSoon: "Produktfoto chunnt bald", portraitSoon: "Porträtfoto vo VanVan chunnt bald",
|
quantity: "Aazahl", photoSoon: "Produktfoto chunnt bald", portraitSoon: "Porträtfoto vo VanVan chunnt bald",
|
||||||
@@ -584,6 +587,7 @@ export const ui = {
|
|||||||
onlyXLeft: (n: string) => `Plus que ${n} en stock`, maxInCart: "La quantité maximale disponible est déjà dans votre panier", maxInCartShort: "Déjà dans le panier",
|
onlyXLeft: (n: string) => `Plus que ${n} en stock`, maxInCart: "La quantité maximale disponible est déjà dans votre panier", maxInCartShort: "Déjà dans le panier",
|
||||||
browseShop: "Découvrir la boutique", viewAll: "Tout voir →", viewAllSale: "Toutes les offres →",
|
browseShop: "Découvrir la boutique", viewAll: "Tout voir →", viewAllSale: "Toutes les offres →",
|
||||||
toShop: "Vers la boutique →", moreAboutMe: "En savoir plus sur moi →", discoverNow: "Découvrir maintenant",
|
toShop: "Vers la boutique →", moreAboutMe: "En savoir plus sur moi →", discoverNow: "Découvrir maintenant",
|
||||||
|
installApp: "Installer l'app", installAppShort: "Installer",
|
||||||
vatNote: "Conformément à l'§19 UStG (droit allemand), aucune TVA n'est calculée ni indiquée.",
|
vatNote: "Conformément à l'§19 UStG (droit allemand), aucune TVA n'est calculée ni indiquée.",
|
||||||
allPricesPlusShipping: "Tous les prix hors frais de port.", plusShipping: "hors frais de port",
|
allPricesPlusShipping: "Tous les prix hors frais de port.", plusShipping: "hors frais de port",
|
||||||
quantity: "Quantité", photoSoon: "Photo du produit à venir", portraitSoon: "Photo de VanVan à venir",
|
quantity: "Quantité", photoSoon: "Photo du produit à venir", portraitSoon: "Photo de VanVan à venir",
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ const p = (href: string) => `${localePrefix(lang)}${href}`;
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<button type="button" id="install-btn" class="install-btn" style="display:none;" title={t.common.installApp}>
|
||||||
|
<span class="install-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 3a1 1 0 0 1 1 1v9.6l3-3a1 1 0 1 1 1.4 1.4l-4.7 4.7a1 1 0 0 1-1.4 0L6.6 12a1 1 0 1 1 1.4-1.4l3 3V4a1 1 0 0 1 1-1Zm-7 15a1 1 0 0 1 1-1h12a1 1 0 1 1 0 2H6a1 1 0 0 1-1-1Z"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="install-label">{t.common.installAppShort}</span>
|
||||||
|
</button>
|
||||||
<LanguageSwitcher lang={lang} path={path} legalOnly={legalOnly} />
|
<LanguageSwitcher lang={lang} path={path} legalOnly={legalOnly} />
|
||||||
<a href={p("/konto/")} class="account-btn">
|
<a href={p("/konto/")} class="account-btn">
|
||||||
<span class="account-icon" aria-hidden="true">
|
<span class="account-icon" aria-hidden="true">
|
||||||
@@ -166,6 +172,42 @@ const p = (href: string) => `${localePrefix(lang)}${href}`;
|
|||||||
const open = mobileNav?.classList.toggle("open");
|
const open = mobileNav?.classList.toggle("open");
|
||||||
hamburger.setAttribute("aria-expanded", String(!!open));
|
hamburger.setAttribute("aria-expanded", String(!!open));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// "Auf dem Gerät installieren" — zeigt einen echten Button in der Kopfzeile, sobald der
|
||||||
|
// Browser eine Installation anbietet (Chrome/Edge auf PC & Handy), statt sich darauf zu
|
||||||
|
// verlassen, dass jemand das winzige Icon im Browser selbst findet. Läuft bereits als
|
||||||
|
// installierte App? Dann gibt es sowieso nichts mehr zu installieren — Button bleibt versteckt.
|
||||||
|
const installBtn = document.getElementById("install-btn");
|
||||||
|
const laeuftBereitsAlsApp = window.matchMedia("(display-mode: standalone)").matches;
|
||||||
|
let installPromptEvent = null;
|
||||||
|
if (!laeuftBereitsAlsApp) {
|
||||||
|
window.addEventListener("beforeinstallprompt", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
installPromptEvent = e;
|
||||||
|
if (installBtn) installBtn.style.display = "flex";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
installBtn?.addEventListener("click", async () => {
|
||||||
|
if (!installPromptEvent) return;
|
||||||
|
installBtn.style.display = "none";
|
||||||
|
installPromptEvent.prompt();
|
||||||
|
await installPromptEvent.userChoice;
|
||||||
|
installPromptEvent = null;
|
||||||
|
});
|
||||||
|
window.addEventListener("appinstalled", () => {
|
||||||
|
if (installBtn) installBtn.style.display = "none";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Service Worker: rein "network-first" (versucht IMMER zuerst das echte Netzwerk, Cache
|
||||||
|
// dient nur als Rückfalllösung, wenn wirklich keine Verbindung besteht) — kein Risiko auf
|
||||||
|
// veraltete Inhalte im Adminbereich, macht die Seite aber gleichzeitig zu einer "echten" App
|
||||||
|
// (Installations-Voraussetzung in den meisten Browsern) und funktioniert notfalls auch kurz
|
||||||
|
// offline für bereits besuchte Seiten.
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style is:global>
|
<style is:global>
|
||||||
@@ -216,6 +258,32 @@ const p = (href: string) => `${localePrefix(lang)}${href}`;
|
|||||||
|
|
||||||
.header-actions { display: flex; align-items: center; gap: 0.8rem; }
|
.header-actions { display: flex; align-items: center; gap: 0.8rem; }
|
||||||
.icon-link { font-size: 1.25rem; position: relative; display: inline-flex; align-items: center; gap: 0.3rem; }
|
.icon-link { font-size: 1.25rem; position: relative; display: inline-flex; align-items: center; gap: 0.3rem; }
|
||||||
|
|
||||||
|
/* "Auf dem Gerät installieren"-Button — nur sichtbar, wenn der Browser das wirklich anbietet
|
||||||
|
(per JS über das beforeinstallprompt-Event ein-/ausgeblendet, siehe Script unten). Damit
|
||||||
|
muss niemand erst das kleine, leicht zu übersehende Installations-Icon im Browser selbst
|
||||||
|
suchen — der Button ist direkt in der Kopfzeile sichtbar. */
|
||||||
|
.install-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45em;
|
||||||
|
padding: 0.5em 0.9em 0.5em 0.7em;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(135deg, rgba(155, 127, 214, 0.22), rgba(46, 27, 71, 0.4));
|
||||||
|
border: 1px solid rgba(155, 127, 214, 0.4);
|
||||||
|
color: var(--c-text);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s var(--ease), transform 0.2s var(--ease), box-shadow 0.2s var(--ease);
|
||||||
|
}
|
||||||
|
.install-btn:hover {
|
||||||
|
border-color: var(--c-purple-glow);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 8px 20px -8px rgba(155, 127, 214, 0.5);
|
||||||
|
}
|
||||||
|
.install-icon { display: flex; }
|
||||||
|
@media (max-width: 640px) { .install-btn .install-label { display: none; } .install-btn { padding: 0.5em; } }
|
||||||
.cart-count {
|
.cart-count {
|
||||||
background: var(--c-accent);
|
background: var(--c-accent);
|
||||||
color: #08181c;
|
color: #08181c;
|
||||||
|
|||||||
Reference in New Issue
Block a user