Files
ssmithx f8b9bbfbe2 fix: Check In/Out button hanging forever with no feedback
Root cause, reproduced live against checkstr.atobitcoin.io with a real
browser: the default relays were bad. wss://relay.damus.io was returning
503 at the WS handshake, and wss://nos.lol accepted the connection but
silently never sent an OK for a brand-new pubkey's first event -- no
error, just permanent silence. Promise.any over both left the check-in
dialog frozen indefinitely with zero feedback.

- Swap default relays to three that were actually verified (via a raw
  WebSocket test, bypassing any client library) to send an explicit OK
  within a couple seconds for a fresh key: relay.primal.net, offchain.pub,
  relay.nostr.wirednet.jp.
- Add a hard client-side timeout (10s) around every publish, normalizing
  both failure paths (AggregateError from all-relays-rejected, or nothing
  responding at all) into one clear message instead of an indefinite hang.
- Disable the Check In/Check Out buttons and show a pending label while a
  publish is in flight, instead of giving no feedback at all.
2026-11-12 04:07:32 +00:00

183 lines
5.6 KiB
JavaScript

import {
state,
loginWithExtension,
loginWithDemoKey,
saveRelays,
savePropertyId,
subscribe,
checkIn,
checkOut,
} from './app.js';
const ROOMS_KEY_PREFIX = 'checkstr.rooms.';
const el = {
setup: document.getElementById('setup'),
relaysInput: document.getElementById('relays-input'),
propertyInput: document.getElementById('property-input'),
roomsInput: document.getElementById('rooms-input'),
loginExtension: document.getElementById('login-extension'),
loginDemo: document.getElementById('login-demo'),
authStatus: document.getElementById('auth-status'),
main: document.getElementById('main'),
propertyLabel: document.getElementById('property-label'),
roomGrid: document.getElementById('room-grid'),
logList: document.getElementById('log-list'),
changeSetup: document.getElementById('change-setup'),
checkinDialog: document.getElementById('checkin-dialog'),
checkinRoomLabel: document.getElementById('checkin-room-label'),
guestNameInput: document.getElementById('guest-name-input'),
checkinConfirm: document.getElementById('checkin-confirm'),
checkinCancel: document.getElementById('checkin-cancel'),
};
let pendingCheckinRoom = null;
function roomsForProperty(propertyId) {
const raw = localStorage.getItem(ROOMS_KEY_PREFIX + propertyId);
if (!raw) return [];
return raw.split(',').map((r) => r.trim()).filter(Boolean);
}
function saveRoomsForProperty(propertyId, rooms) {
localStorage.setItem(ROOMS_KEY_PREFIX + propertyId, rooms.join(','));
}
function renderRooms() {
const rooms = roomsForProperty(state.propertyId);
el.roomGrid.innerHTML = '';
if (!rooms.length) {
el.roomGrid.innerHTML = '<p class="muted">No rooms configured. Use "Edit setup" to add some.</p>';
return;
}
for (const roomId of rooms) {
const info = state.rooms.get(roomId);
const occupied = info?.status === 'occupied';
const card = document.createElement('div');
card.className = `room-card ${occupied ? 'occupied' : 'vacant'}`;
card.innerHTML = `
<div class="room-id">Room ${escapeHtml(roomId)}</div>
<div class="room-status">${occupied ? 'Occupied' : 'Vacant'}</div>
${occupied ? `<div class="room-guest">${escapeHtml(info.guest || '')}</div>` : ''}
${occupied && info.since ? `<div class="room-since">since ${new Date(info.since * 1000).toLocaleString()}</div>` : ''}
<button class="room-action" data-room="${escapeHtml(roomId)}" data-occupied="${occupied}">
${occupied ? 'Check Out' : 'Check In'}
</button>
`;
el.roomGrid.appendChild(card);
}
}
function renderLog() {
el.logList.innerHTML = state.log
.map(
(evt) =>
`<li><span class="log-time">${new Date(evt.created_at * 1000).toLocaleTimeString()}</span> ${escapeHtml(evt.content)}</li>`,
)
.join('');
}
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
window.addEventListener('checkstr:rooms-changed', renderRooms);
window.addEventListener('checkstr:log-changed', renderLog);
el.roomGrid.addEventListener('click', (e) => {
const btn = e.target.closest('button.room-action');
if (!btn) return;
const roomId = btn.dataset.room;
const occupied = btn.dataset.occupied === 'true';
if (occupied) {
const originalLabel = btn.textContent;
btn.disabled = true;
btn.textContent = 'Checking out…';
checkOut(roomId)
.catch((err) => alert(`Check-out failed: ${err.message}`))
.finally(() => {
btn.disabled = false;
btn.textContent = originalLabel;
});
} else {
pendingCheckinRoom = roomId;
el.checkinRoomLabel.textContent = roomId;
el.guestNameInput.value = '';
el.checkinDialog.showModal();
el.guestNameInput.focus();
}
});
el.checkinConfirm.addEventListener('click', async () => {
const guestName = el.guestNameInput.value.trim();
if (!guestName) return;
const originalLabel = el.checkinConfirm.textContent;
el.checkinConfirm.disabled = true;
el.checkinConfirm.textContent = 'Checking in…';
try {
await checkIn(pendingCheckinRoom, guestName);
el.checkinDialog.close();
} catch (err) {
alert(`Check-in failed: ${err.message}`);
} finally {
el.checkinConfirm.disabled = false;
el.checkinConfirm.textContent = originalLabel;
}
});
el.checkinCancel.addEventListener('click', () => el.checkinDialog.close());
function enterApp() {
el.setup.hidden = true;
el.main.hidden = false;
el.propertyLabel.textContent = state.propertyId;
subscribe();
renderRooms();
renderLog();
}
el.loginExtension.addEventListener('click', async () => {
try {
await loginWithExtension();
finishSetup();
} catch (err) {
el.authStatus.textContent = err.message;
}
});
el.loginDemo.addEventListener('click', () => {
loginWithDemoKey();
finishSetup();
});
function finishSetup() {
const relays = el.relaysInput.value
.split(',')
.map((r) => r.trim())
.filter(Boolean);
if (relays.length) saveRelays(relays);
const propertyId = el.propertyInput.value.trim();
if (!propertyId) {
el.authStatus.textContent = 'Property ID is required.';
return;
}
savePropertyId(propertyId);
const rooms = el.roomsInput.value
.split(',')
.map((r) => r.trim())
.filter(Boolean);
if (rooms.length) saveRoomsForProperty(propertyId, rooms);
enterApp();
}
el.changeSetup.addEventListener('click', () => {
el.main.hidden = true;
el.setup.hidden = false;
});
// Pre-fill setup fields from saved state on load.
el.relaysInput.value = state.relays.join(',');
el.propertyInput.value = state.propertyId;
el.roomsInput.value = roomsForProperty(state.propertyId).join(',');