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.
183 lines
5.6 KiB
JavaScript
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(',');
|