mejoras UX pwa
This commit is contained in:
@@ -1,10 +1,30 @@
|
||||
<template>
|
||||
<div class="lobby">
|
||||
<div class="lobby-container">
|
||||
<div class="lobby-header">
|
||||
<button @click="goToSelector" class="btn btn-back">
|
||||
← UUIDs
|
||||
</button>
|
||||
<div class="topbar">
|
||||
<div class="lobby-header">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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;
|
||||
|
||||
@@ -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,9 +240,13 @@ function selectUuid(uuid: string) {
|
||||
printQR(uuidInfo);
|
||||
}
|
||||
} else {
|
||||
// Open in new tab instead of redirecting
|
||||
const url = `${window.location.origin}/${uuid}`;
|
||||
window.open(url, '_blank');
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,9 +257,13 @@ function selectRandom() {
|
||||
// Show QR modal if in QR mode
|
||||
printQR(randomUuidInfo);
|
||||
} else {
|
||||
// Open in new tab
|
||||
const url = `${window.location.origin}/${randomUuidInfo.uuid}`;
|
||||
window.open(url, '_blank');
|
||||
const uuid = randomUuidInfo.uuid;
|
||||
if (isStandalone()) {
|
||||
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 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.print-modal-content {
|
||||
width: 95%;
|
||||
padding: 20px;
|
||||
|
||||
Reference in New Issue
Block a user