mejoras UX pwa

This commit is contained in:
2025-08-28 13:47:54 -06:00
parent 1c50aa725f
commit 2bac7c246d
2 changed files with 204 additions and 56 deletions

View File

@@ -1,10 +1,30 @@
<template> <template>
<div class="lobby"> <div class="lobby">
<div class="lobby-container"> <div class="lobby-container">
<div class="lobby-header"> <div class="topbar">
<button @click="goToSelector" class="btn btn-back"> <div class="lobby-header">
UUIDs <button @click="goToSelector" class="btn btn-back"> UUIDs</button>
</button> </div>
<!-- Install banner (sutil, inline con UUIDs) -->
<div
v-if="showInstallBanner"
class="install-banner"
role="status"
aria-live="polite"
>
<div class="install-banner-content">
<img class="install-icon" src="/pwa_icons/icon-72x72.png" alt="SnatchGame" />
<div class="install-text">
<strong>Instalá SnatchGame</strong>
<span v-if="canPromptInstall">Acceso rápido desde tu pantalla de inicio.</span>
<span v-else>iOS: Compartir Añadir a pantalla de inicio.</span>
</div>
</div>
<div class="install-actions">
<button v-if="canPromptInstall" class="btn btn-install" @click="triggerInstall">Instalar</button>
<button class="btn btn-dismiss" @click="dismissBanner" aria-label="Cerrar"></button>
</div>
</div>
</div> </div>
<h1 class="title"> <h1 class="title">
<GameLogo size="large" /> Snatch Game <GameLogo size="large" /> Snatch Game
@@ -12,8 +32,9 @@
<div class="subtitle">Arena de intercambio social</div> <div class="subtitle">Arena de intercambio social</div>
<div class="player-section"> <div class="player-section">
<div class="name-input-group"> <div class="name-input-group" v-if="!nameConfirmed || editingName">
<input <input
ref="nameInputRef"
v-model="inputName" v-model="inputName"
@keyup.enter="updateName" @keyup.enter="updateName"
type="text" type="text"
@@ -23,8 +44,15 @@
/> />
<button @click="updateName" class="btn btn-secondary">Confirmar Nombre</button> <button @click="updateName" class="btn btn-secondary">Confirmar Nombre</button>
</div> </div>
<div class="current-name"> <div class="main-actions inline">
Jugando como: <span class="player-name">{{ playerName || 'invitado' }}</span> <button @click="handleQuickPlay" class="btn btn-primary btn-large" :disabled="isJoining || !nameConfirmed">
<span v-if="!isJoining">Jugar</span>
<span v-else>Buscando partida...</span>
</button>
<div v-if="!nameConfirmed" class="hint">Antes de jugar, presiona "Confirmar Nombre" para confirmar tu nombre.</div>
</div>
<div class="current-name" :class="{ clickable: nameConfirmed }" @click="nameConfirmed ? startEditName() : null" :title="nameConfirmed ? 'Editar nombre' : ''">
Jugando como: <span class="player-name">{{ playerName || 'invitado' }}</span><span v-if="nameConfirmed" class="edit-hint"> ()</span>
</div> </div>
<div class="color-picker"> <div class="color-picker">
<label class="color-label">Color:</label> <label class="color-label">Color:</label>
@@ -35,30 +63,17 @@
</div> </div>
</div> </div>
<div class="main-actions"> <div class="qr-section compact">
<button @click="handleQuickPlay" class="btn btn-primary btn-large" :disabled="isJoining || !nameConfirmed"> <div class="qr-container compact">
<span v-if="!isJoining">🧪 Jugar</span> <div class="qr-inline">
<span v-else>Buscando partida...</span> <canvas ref="qrCanvas" class="qr-canvas small"></canvas>
</button> <div class="qr-side">
<div v-if="!nameConfirmed" class="hint">Antes de jugar, presiona "Confirmar Nombre" para confirmar tu nombre.</div> <p class="url-display small" title="{{ gameUrl }}">{{ shortUrl }}</p>
<div v-else class="hint ok">Nombre confirmado </div> <div class="qr-actions tight">
</div> <button @click="copyUrl" class="btn btn-copy tiny">Copiar</button>
<button @click="shareQR" class="btn btn-share tiny">Compartir</button>
<div class="qr-section"> </div>
<h2>🎯 Tu Acceso al Juego</h2> <p class="uuid-display small">UUID: {{ routeUuid.substring(0, 8) }}...</p>
<div class="qr-container">
<div class="qr-header">
<h3>Enlace de Juego de {{ playerName || 'Invitado' }}</h3>
<p class="uuid-display">UUID: {{ routeUuid.substring(0, 8) }}...</p>
</div>
<div class="qr-code-wrapper">
<canvas ref="qrCanvas" class="qr-canvas"></canvas>
</div>
<div class="qr-footer">
<p class="url-display">{{ gameUrl }}</p>
<div class="qr-actions">
<button @click="copyUrl" class="btn btn-copy">📋 Copiar Enlace</button>
<button @click="shareQR" class="btn btn-share">📤 Compartir QR</button>
</div> </div>
</div> </div>
</div> </div>
@@ -83,6 +98,13 @@ const inputName = ref('');
const isJoining = ref(false); const isJoining = ref(false);
const colorInput = ref('#667eea'); const colorInput = ref('#667eea');
const qrCanvas = ref<HTMLCanvasElement>(); const qrCanvas = ref<HTMLCanvasElement>();
const showInstallBanner = ref(false);
const canPromptInstall = ref(false);
let deferredPrompt: any = null;
let onBipHandler: ((e: any) => void) | null = null;
let installTimer: any = null;
const editingName = ref(false);
const nameInputRef = ref<HTMLInputElement | null>(null);
// QR Code computed properties // QR Code computed properties
const gameUrl = computed(() => { const gameUrl = computed(() => {
@@ -90,6 +112,43 @@ const gameUrl = computed(() => {
return `${baseUrl}/${routeUuid.value}`; return `${baseUrl}/${routeUuid.value}`;
}); });
const shortUrl = computed(() => {
try {
const u = new URL(gameUrl.value);
// usar host corto + path uuid
return `${u.host}/${routeUuid.value}`;
} catch {
return gameUrl.value;
}
});
const isStandalone = () =>
(window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) ||
// iOS
(window as any).navigator.standalone === true;
const isIOS = () => /iphone|ipad|ipod/i.test(navigator.userAgent);
function maybeShowInstallBanner() {
const dismissed = localStorage.getItem('sg_install_banner_dismissed') === '1';
if (dismissed || isStandalone()) return;
if (deferredPrompt) {
canPromptInstall.value = true;
showInstallBanner.value = true;
if (installTimer) clearTimeout(installTimer);
installTimer = setTimeout(() => {
showInstallBanner.value = false;
}, 10000);
} else if (isIOS()) {
canPromptInstall.value = false;
showInstallBanner.value = true;
if (installTimer) clearTimeout(installTimer);
installTimer = setTimeout(() => {
showInstallBanner.value = false;
}, 12000);
}
}
const playerName = computed(() => colyseusService.playerName.value); const playerName = computed(() => colyseusService.playerName.value);
const nameConfirmed = computed(() => colyseusService.nameConfirmed.value); const nameConfirmed = computed(() => colyseusService.nameConfirmed.value);
const playerColor = computed(() => colyseusService.playerColor.value); const playerColor = computed(() => colyseusService.playerColor.value);
@@ -110,8 +169,17 @@ const onlinePlayers = ref<any[]>([]);
onMounted(async () => { onMounted(async () => {
try { try {
// install prompt listener
onBipHandler = (e: any) => {
e.preventDefault();
deferredPrompt = e;
maybeShowInstallBanner();
};
window.addEventListener('beforeinstallprompt', onBipHandler);
const room = await colyseusService.joinLobby(); const room = await colyseusService.joinLobby();
colorInput.value = colyseusService.playerColor.value || '#667eea'; colorInput.value = colyseusService.playerColor.value || '#667eea';
inputName.value = colyseusService.playerName.value || '';
let resumed = false; let resumed = false;
const guardResume = () => { if (resumed) return true; resumed = true; return false; }; const guardResume = () => { if (resumed) return true; resumed = true; return false; };
@@ -251,6 +319,9 @@ onMounted(async () => {
// Generate QR code after lobby is set up // Generate QR code after lobby is set up
await generateQRCode(); await generateQRCode();
// Show install banner if applicable (e.g., iOS or after BIP fired)
maybeShowInstallBanner();
} catch (error) { } catch (error) {
console.error('Failed to join lobby:', error); console.error('Failed to join lobby:', error);
} }
@@ -259,12 +330,15 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
// Don't leave the current room - it might be a game room now // Don't leave the current room - it might be a game room now
console.log('Lobby component unmounting...'); console.log('Lobby component unmounting...');
if (onBipHandler) window.removeEventListener('beforeinstallprompt', onBipHandler);
if (installTimer) clearTimeout(installTimer);
}); });
async function updateName() { async function updateName() {
// Send even if empty; server will assign a default unique name when empty // Send even if empty; server will assign a default unique name when empty
const name = inputName.value.trim(); const name = inputName.value.trim();
await colyseusService.setPlayerName(name); await colyseusService.setPlayerName(name);
editingName.value = false;
} }
async function updateColor() { async function updateColor() {
@@ -321,11 +395,11 @@ async function generateQRCode() {
try { try {
// Responsive QR size based on screen width // Responsive QR size based on screen width
const isMobile = window.innerWidth <= 767; const isMobile = window.innerWidth <= 767;
const qrSize = isMobile ? 200 : 256; const qrSize = isMobile ? 100 : 140; // aún más compacto
await QRCode.toCanvas(qrCanvas.value, gameUrl.value, { await QRCode.toCanvas(qrCanvas.value, gameUrl.value, {
width: qrSize, width: qrSize,
margin: 2, margin: 0,
color: { color: {
dark: '#000000', dark: '#000000',
light: '#FFFFFF' light: '#FFFFFF'
@@ -363,6 +437,32 @@ function shareQR() {
function goToSelector() { function goToSelector() {
router.push('/'); router.push('/');
} }
async function triggerInstall() {
try {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
dismissBanner();
}
deferredPrompt = null;
} catch {}
}
function dismissBanner() {
showInstallBanner.value = false;
localStorage.setItem('sg_install_banner_dismissed', '1');
if (installTimer) clearTimeout(installTimer);
}
function startEditName() {
editingName.value = true;
inputName.value = colyseusService.playerName.value || '';
nextTick(() => {
nameInputRef.value?.focus();
});
}
</script> </script>
<style scoped> <style scoped>
@@ -387,8 +487,30 @@ function goToSelector() {
padding: 40px; padding: 40px;
max-width: 800px; max-width: 800px;
width: 100%; width: 100%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
} }
.topbar { display:flex; align-items:center; gap: 8px; justify-content: space-between; margin-bottom: 10px; }
.topbar .install-banner { flex: 1; }
.install-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
background: #f7f9ff;
border: 1px solid #e2e8f0;
border-radius: 10px;
margin-bottom: 12px;
}
.install-banner-content { display: flex; align-items: center; gap: 10px; }
.install-icon { width: 24px; height: 24px; border-radius: 6px; }
.install-text { display:flex; flex-direction: column; line-height: 1.1; }
.install-text strong { font-size: 14px; color: #334155; }
.install-text span { font-size: 12px; color: #64748b; }
.install-actions { display:flex; align-items:center; gap: 6px; }
.btn-install { background:#111; color:#fff; padding: 6px 10px; border-radius: 6px; font-size: 12px; }
.btn-dismiss { background: transparent; color:#64748b; padding: 4px 6px; font-size: 14px; }
.lobby-header { .lobby-header {
display: flex; display: flex;
@@ -494,6 +616,10 @@ function goToSelector() {
font-size: 1.1rem; font-size: 1.1rem;
} }
.current-name.clickable { cursor: pointer; }
.current-name.clickable:hover .player-name { text-decoration: underline; }
.edit-hint { color: #94a3b8; font-size: 0.9rem; }
.player-name { .player-name {
color: #667eea; color: #667eea;
font-weight: bold; font-weight: bold;
@@ -541,6 +667,8 @@ function goToSelector() {
font-size: 20px; font-size: 20px;
} }
.btn.tiny { padding: 6px 10px; font-size: 12px; }
.btn:disabled { .btn:disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
@@ -551,6 +679,8 @@ function goToSelector() {
margin: 40px 0; margin: 40px 0;
} }
.main-actions.inline { margin: 12px 0; }
.rooms-section { .rooms-section {
margin: 40px 0; margin: 40px 0;
} }
@@ -682,6 +812,8 @@ function goToSelector() {
margin: 40px 0; margin: 40px 0;
} }
.qr-section.compact { margin: 20px 0; }
.qr-section h2 { .qr-section h2 {
color: #333; color: #333;
margin-bottom: 20px; margin-bottom: 20px;
@@ -697,6 +829,8 @@ function goToSelector() {
transition: all 0.3s; transition: all 0.3s;
} }
.qr-container.compact { padding: 8px; }
.qr-container:hover { .qr-container:hover {
border-color: #667eea; border-color: #667eea;
transform: translateY(-2px); transform: translateY(-2px);
@@ -713,8 +847,9 @@ function goToSelector() {
font-family: monospace; font-family: monospace;
color: #666; color: #666;
font-size: 14px; font-size: 14px;
margin: 0 0 20px 0; margin: 0 0 20px 0;
} }
.uuid-display.small { font-size: 11px; margin: 0; }
.qr-code-wrapper { .qr-code-wrapper {
display: inline-block; display: inline-block;
@@ -729,6 +864,10 @@ function goToSelector() {
display: block; display: block;
border-radius: 8px; border-radius: 8px;
} }
.qr-canvas.small { image-rendering: pixelated; }
.qr-inline { display:flex; align-items:center; gap: 8px; justify-content:center; }
.qr-side { display:flex; flex-direction: column; gap: 6px; align-items: flex-start; }
.qr-footer { .qr-footer {
margin-top: 20px; margin-top: 20px;
@@ -744,6 +883,7 @@ function goToSelector() {
border-radius: 8px; border-radius: 8px;
border: 1px solid #e0e0e0; border: 1px solid #e0e0e0;
} }
.url-display.small { font-size: 11px; margin: 0; padding: 4px 6px; }
.qr-actions { .qr-actions {
display: flex; display: flex;
@@ -752,6 +892,8 @@ function goToSelector() {
flex-wrap: wrap; flex-wrap: wrap;
} }
.qr-actions.tight { gap: 6px; }
.btn-copy, .btn-copy,
.btn-share { .btn-share {
padding: 8px 16px; padding: 8px 16px;
@@ -792,19 +934,9 @@ function goToSelector() {
margin: 0; margin: 0;
} }
.qr-container { .qr-container { padding: 14px; }
padding: 20px; .qr-code-wrapper { padding: 10px; margin: 10px 0; }
} .qr-canvas.small { width: 100px !important; height: 100px !important; }
.qr-code-wrapper {
padding: 15px;
margin: 15px 0;
}
.qr-canvas {
width: 200px !important;
height: 200px !important;
}
.qr-actions { .qr-actions {
flex-direction: column; flex-direction: column;

View File

@@ -153,6 +153,15 @@ const qrMode = ref(false);
const qrCanvas = ref<HTMLCanvasElement>(); const qrCanvas = ref<HTMLCanvasElement>();
const printContainer = ref<HTMLElement>(); const printContainer = ref<HTMLElement>();
// Detect PWA standalone mode
function isStandalone(): boolean {
return (
(window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) ||
// iOS standalone
(window as any).navigator?.standalone === true
);
}
// Context menu state // Context menu state
const contextMenu = ref({ const contextMenu = ref({
visible: false, visible: false,
@@ -231,9 +240,13 @@ function selectUuid(uuid: string) {
printQR(uuidInfo); printQR(uuidInfo);
} }
} else { } else {
// Open in new tab instead of redirecting if (isStandalone()) {
const url = `${window.location.origin}/${uuid}`; // En PWA, navegar usando ruta nombrada para mayor robustez
window.open(url, '_blank'); router.push({ name: 'LobbyWithUuid', params: { uuid } });
} else {
const url = `${window.location.origin}/${uuid}`;
window.open(url, '_blank');
}
} }
} }
@@ -244,9 +257,13 @@ function selectRandom() {
// Show QR modal if in QR mode // Show QR modal if in QR mode
printQR(randomUuidInfo); printQR(randomUuidInfo);
} else { } else {
// Open in new tab const uuid = randomUuidInfo.uuid;
const url = `${window.location.origin}/${randomUuidInfo.uuid}`; if (isStandalone()) {
window.open(url, '_blank'); router.push({ name: 'LobbyWithUuid', params: { uuid } });
} else {
const url = `${window.location.origin}/${uuid}`;
window.open(url, '_blank');
}
} }
} }
} }
@@ -1316,7 +1333,6 @@ async function copyToClipboard() {
.btn-leaderboard { .btn-leaderboard {
width: 100%; width: 100%;
} }
.print-modal-content { .print-modal-content {
width: 95%; width: 95%;
padding: 20px; padding: 20px;
@@ -1326,4 +1342,4 @@ async function copyToClipboard() {
flex-direction: column; flex-direction: column;
} }
} }
</style> </style>