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>
<div class="lobby">
<div class="lobby-container">
<div class="topbar">
<div class="lobby-header">
<button @click="goToSelector" class="btn btn-back">
UUIDs
</button>
<button @click="goToSelector" class="btn btn-back"> UUIDs</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>
<h1 class="title">
<GameLogo size="large" /> Snatch Game
@@ -12,8 +32,9 @@
<div class="subtitle">Arena de intercambio social</div>
<div class="player-section">
<div class="name-input-group">
<div class="name-input-group" v-if="!nameConfirmed || editingName">
<input
ref="nameInputRef"
v-model="inputName"
@keyup.enter="updateName"
type="text"
@@ -23,8 +44,15 @@
/>
<button @click="updateName" class="btn btn-secondary">Confirmar Nombre</button>
</div>
<div class="current-name">
Jugando como: <span class="player-name">{{ playerName || 'invitado' }}</span>
<div class="main-actions inline">
<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 class="color-picker">
<label class="color-label">Color:</label>
@@ -35,30 +63,17 @@
</div>
</div>
<div class="main-actions">
<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 v-else class="hint ok">Nombre confirmado </div>
<div class="qr-section compact">
<div class="qr-container compact">
<div class="qr-inline">
<canvas ref="qrCanvas" class="qr-canvas small"></canvas>
<div class="qr-side">
<p class="url-display small" title="{{ gameUrl }}">{{ shortUrl }}</p>
<div class="qr-actions tight">
<button @click="copyUrl" class="btn btn-copy tiny">Copiar</button>
<button @click="shareQR" class="btn btn-share tiny">Compartir</button>
</div>
<div class="qr-section">
<h2>🎯 Tu Acceso al Juego</h2>
<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>
<p class="uuid-display small">UUID: {{ routeUuid.substring(0, 8) }}...</p>
</div>
</div>
</div>
@@ -83,6 +98,13 @@ const inputName = ref('');
const isJoining = ref(false);
const colorInput = ref('#667eea');
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
const gameUrl = computed(() => {
@@ -90,6 +112,43 @@ const gameUrl = computed(() => {
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 nameConfirmed = computed(() => colyseusService.nameConfirmed.value);
const playerColor = computed(() => colyseusService.playerColor.value);
@@ -110,8 +169,17 @@ const onlinePlayers = ref<any[]>([]);
onMounted(async () => {
try {
// install prompt listener
onBipHandler = (e: any) => {
e.preventDefault();
deferredPrompt = e;
maybeShowInstallBanner();
};
window.addEventListener('beforeinstallprompt', onBipHandler);
const room = await colyseusService.joinLobby();
colorInput.value = colyseusService.playerColor.value || '#667eea';
inputName.value = colyseusService.playerName.value || '';
let resumed = 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
await generateQRCode();
// Show install banner if applicable (e.g., iOS or after BIP fired)
maybeShowInstallBanner();
} catch (error) {
console.error('Failed to join lobby:', error);
}
@@ -259,12 +330,15 @@ onMounted(async () => {
onUnmounted(() => {
// Don't leave the current room - it might be a game room now
console.log('Lobby component unmounting...');
if (onBipHandler) window.removeEventListener('beforeinstallprompt', onBipHandler);
if (installTimer) clearTimeout(installTimer);
});
async function updateName() {
// Send even if empty; server will assign a default unique name when empty
const name = inputName.value.trim();
await colyseusService.setPlayerName(name);
editingName.value = false;
}
async function updateColor() {
@@ -321,11 +395,11 @@ async function generateQRCode() {
try {
// Responsive QR size based on screen width
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, {
width: qrSize,
margin: 2,
margin: 0,
color: {
dark: '#000000',
light: '#FFFFFF'
@@ -363,6 +437,32 @@ function shareQR() {
function goToSelector() {
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>
<style scoped>
@@ -387,8 +487,30 @@ function goToSelector() {
padding: 40px;
max-width: 800px;
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 {
display: flex;
@@ -494,6 +616,10 @@ function goToSelector() {
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 {
color: #667eea;
font-weight: bold;
@@ -541,6 +667,8 @@ function goToSelector() {
font-size: 20px;
}
.btn.tiny { padding: 6px 10px; font-size: 12px; }
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
@@ -551,6 +679,8 @@ function goToSelector() {
margin: 40px 0;
}
.main-actions.inline { margin: 12px 0; }
.rooms-section {
margin: 40px 0;
}
@@ -682,6 +812,8 @@ function goToSelector() {
margin: 40px 0;
}
.qr-section.compact { margin: 20px 0; }
.qr-section h2 {
color: #333;
margin-bottom: 20px;
@@ -697,6 +829,8 @@ function goToSelector() {
transition: all 0.3s;
}
.qr-container.compact { padding: 8px; }
.qr-container:hover {
border-color: #667eea;
transform: translateY(-2px);
@@ -713,8 +847,9 @@ function goToSelector() {
font-family: monospace;
color: #666;
font-size: 14px;
margin: 0 0 20px 0;
margin: 0 0 20px 0;
}
.uuid-display.small { font-size: 11px; margin: 0; }
.qr-code-wrapper {
display: inline-block;
@@ -729,6 +864,10 @@ function goToSelector() {
display: block;
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 {
margin-top: 20px;
@@ -744,6 +883,7 @@ function goToSelector() {
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.url-display.small { font-size: 11px; margin: 0; padding: 4px 6px; }
.qr-actions {
display: flex;
@@ -752,6 +892,8 @@ function goToSelector() {
flex-wrap: wrap;
}
.qr-actions.tight { gap: 6px; }
.btn-copy,
.btn-share {
padding: 8px 16px;
@@ -792,19 +934,9 @@ function goToSelector() {
margin: 0;
}
.qr-container {
padding: 20px;
}
.qr-code-wrapper {
padding: 15px;
margin: 15px 0;
}
.qr-canvas {
width: 200px !important;
height: 200px !important;
}
.qr-container { padding: 14px; }
.qr-code-wrapper { padding: 10px; margin: 10px 0; }
.qr-canvas.small { width: 100px !important; height: 100px !important; }
.qr-actions {
flex-direction: column;

View File

@@ -153,6 +153,15 @@ const qrMode = ref(false);
const qrCanvas = ref<HTMLCanvasElement>();
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
const contextMenu = ref({
visible: false,
@@ -231,10 +240,14 @@ function selectUuid(uuid: string) {
printQR(uuidInfo);
}
} else {
// Open in new tab instead of redirecting
if (isStandalone()) {
// En PWA, navegar usando ruta nombrada para mayor robustez
router.push({ name: 'LobbyWithUuid', params: { uuid } });
} else {
const url = `${window.location.origin}/${uuid}`;
window.open(url, '_blank');
}
}
}
function selectRandom() {
@@ -244,11 +257,15 @@ function selectRandom() {
// Show QR modal if in QR mode
printQR(randomUuidInfo);
} else {
// Open in new tab
const url = `${window.location.origin}/${randomUuidInfo.uuid}`;
const uuid = randomUuidInfo.uuid;
if (isStandalone()) {
router.push({ name: 'LobbyWithUuid', params: { uuid } });
} else {
const url = `${window.location.origin}/${uuid}`;
window.open(url, '_blank');
}
}
}
}
function goToDashboard() {
@@ -1316,7 +1333,6 @@ async function copyToClipboard() {
.btn-leaderboard {
width: 100%;
}
.print-modal-content {
width: 95%;
padding: 20px;