Extension: per-machine default tablet, skip asking unless busy
- Options page gains a 'Default tablet for this machine' picker, populated from the device list once base URL + terminal key are configured, auto-saved on change (small enough setting not to warrant its own Save button). - Popup pre-selects the default in its device dropdown but still shows the full picker — opening the popup is already a deliberate action, so staff can still override it there. - Injected buttons (row + banner) skip the dropdown entirely when a default is configured — one click pushes straight to it. Only falls back to the full picker if that specific push comes back busy (409), with a note explaining why it's asking despite a default being set. background.js's pushBooking() now returns the HTTP status alongside ok/error so this can be detected reliably rather than string-matching the error message. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
67def8ac19
commit
a37654cdc4
5 changed files with 139 additions and 21 deletions
|
|
@ -103,8 +103,11 @@ async function pushBooking(bookingId, deviceId) {
|
||||||
body: JSON.stringify({ booking_id: bookingId, device_id: deviceId }),
|
body: JSON.stringify({ booking_id: bookingId, device_id: deviceId }),
|
||||||
});
|
});
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) return { ok: false, error: body.error || `HTTP ${res.status}` };
|
// status included so callers can reliably tell "device busy" (409) apart
|
||||||
return { ok: true };
|
// from other failures — used by content-script.js's default-tablet
|
||||||
|
// fast-path to decide whether to fall back to the device picker.
|
||||||
|
if (!res.ok) return { ok: false, status: res.status, error: body.error || `HTTP ${res.status}` };
|
||||||
|
return { ok: true, status: res.status };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { ok: false, error: err.message };
|
return { ok: false, error: err.message };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,14 +89,68 @@ function makeSendButton(bookingId, guestName, variant = 'block') {
|
||||||
btn.addEventListener('click', e => {
|
btn.addEventListener('click', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
togglePanel(btn, bookingId, guestName, variant);
|
handleSendClick(btn, bookingId, guestName, variant);
|
||||||
});
|
});
|
||||||
return btn;
|
return btn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Injected buttons skip asking which tablet entirely when a default is
|
||||||
|
// configured for this machine (see options.js) — push straight to it. Only
|
||||||
|
// falls back to the full device-picker panel if that specific push comes
|
||||||
|
// back busy (409), or if no default is configured at all — see
|
||||||
|
// project_checkin memory's extension notes, this was an explicit UX request
|
||||||
|
// ("injected buttons don't need to ask which tablet, just use/send to
|
||||||
|
// default unless busy"). The popup is deliberately different — it always
|
||||||
|
// shows the picker, just pre-selected to the default (see popup.js) — since
|
||||||
|
// opening the popup is already a more deliberate action than a one-click
|
||||||
|
// inline button.
|
||||||
|
async function handleSendClick(anchorBtn, bookingId, guestName, variant) {
|
||||||
|
const { defaultDeviceId } = await chrome.storage.local.get('defaultDeviceId');
|
||||||
|
|
||||||
|
if (defaultDeviceId) {
|
||||||
|
const status = showInlineStatus(anchorBtn, variant, 'Sending…');
|
||||||
|
const result = await chrome.runtime.sendMessage({
|
||||||
|
action: 'tcpPush', bookingId, deviceId: defaultDeviceId,
|
||||||
|
});
|
||||||
|
if (result?.ok) {
|
||||||
|
status.textContent = 'Sent — the tablet should navigate now.';
|
||||||
|
status.style.color = '#166534';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result?.status === 409) {
|
||||||
|
status.remove();
|
||||||
|
// fall through to the picker below, with a note explaining why it's
|
||||||
|
// asking despite a default being configured
|
||||||
|
await togglePanel(anchorBtn, bookingId, guestName, variant, 'Default tablet is busy — choose another:');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status.textContent = `Failed: ${result?.error || 'unknown error'}`;
|
||||||
|
status.style.color = '#dc2626';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await togglePanel(anchorBtn, bookingId, guestName, variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small text-only status line for the default-tablet fast path — no
|
||||||
|
// dropdown, nothing to pick, just feedback on what happened.
|
||||||
|
function showInlineStatus(anchorBtn, variant, text) {
|
||||||
|
if (openPanel) { openPanel.remove(); openPanel = null; }
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const width = variant === 'block' ? 'width: 100%;' : '';
|
||||||
|
el.style.cssText = `
|
||||||
|
display: block; margin-top: 6px; ${width} font-size: 12px; color: #64748b;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
`;
|
||||||
|
el.textContent = text;
|
||||||
|
anchorBtn.insertAdjacentElement('afterend', el);
|
||||||
|
openPanel = el;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
let openPanel = null;
|
let openPanel = null;
|
||||||
|
|
||||||
async function togglePanel(anchorBtn, bookingId, guestName, variant = 'block') {
|
async function togglePanel(anchorBtn, bookingId, guestName, variant = 'block', note = '') {
|
||||||
if (openPanel) { openPanel.remove(); openPanel = null; }
|
if (openPanel) { openPanel.remove(); openPanel = null; }
|
||||||
|
|
||||||
const panel = document.createElement('div');
|
const panel = document.createElement('div');
|
||||||
|
|
@ -111,7 +165,7 @@ async function togglePanel(anchorBtn, bookingId, guestName, variant = 'block') {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
font-size: 12px; color: #1e293b;
|
font-size: 12px; color: #1e293b;
|
||||||
`;
|
`;
|
||||||
panel.textContent = 'Loading tablets…';
|
panel.textContent = note ? `${note} loading…` : 'Loading tablets…';
|
||||||
anchorBtn.insertAdjacentElement('afterend', panel);
|
anchorBtn.insertAdjacentElement('afterend', panel);
|
||||||
openPanel = panel;
|
openPanel = panel;
|
||||||
|
|
||||||
|
|
@ -151,6 +205,12 @@ async function togglePanel(anchorBtn, bookingId, guestName, variant = 'block') {
|
||||||
});
|
});
|
||||||
|
|
||||||
panel.innerHTML = '';
|
panel.innerHTML = '';
|
||||||
|
if (note) {
|
||||||
|
const noteEl = document.createElement('div');
|
||||||
|
noteEl.textContent = note;
|
||||||
|
noteEl.style.cssText = 'margin-bottom: 6px; color: #b45309; font-weight: 600;';
|
||||||
|
panel.appendChild(noteEl);
|
||||||
|
}
|
||||||
panel.appendChild(select);
|
panel.appendChild(select);
|
||||||
panel.appendChild(sendBtn);
|
panel.appendChild(sendBtn);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,16 @@
|
||||||
<button id="test">Test connection</button>
|
<button id="test">Test connection</button>
|
||||||
<p id="status"></p>
|
<p id="status"></p>
|
||||||
|
|
||||||
|
<div id="defaultTabletSection" style="display: none;">
|
||||||
|
<label for="defaultDevice">Default tablet for this machine</label>
|
||||||
|
<select id="defaultDevice"></select>
|
||||||
|
<p class="hint">
|
||||||
|
The popup pre-selects this tablet but still lets you pick a different one. The "Send to Tablet"
|
||||||
|
buttons injected into NewBook itself skip asking entirely and push straight to this tablet —
|
||||||
|
unless it's busy, in which case they fall back to asking which one to use.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="module" src="options.js"></script>
|
<script type="module" src="options.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,62 @@
|
||||||
const baseUrlInput = document.getElementById('baseUrl');
|
const baseUrlInput = document.getElementById('baseUrl');
|
||||||
const apiKeyInput = document.getElementById('apiKey');
|
const apiKeyInput = document.getElementById('apiKey');
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
|
const defaultSection = document.getElementById('defaultTabletSection');
|
||||||
|
const defaultDeviceSelect = document.getElementById('defaultDevice');
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const { baseUrl, apiKey } = await chrome.storage.local.get(['baseUrl', 'apiKey']);
|
const { baseUrl, apiKey, defaultDeviceId } = await chrome.storage.local.get(['baseUrl', 'apiKey', 'defaultDeviceId']);
|
||||||
if (baseUrl) baseUrlInput.value = baseUrl;
|
if (baseUrl) baseUrlInput.value = baseUrl;
|
||||||
if (apiKey) apiKeyInput.value = apiKey; // deliberately shown back here (options page,
|
if (apiKey) apiKeyInput.value = apiKey; // deliberately shown back here (options page,
|
||||||
// not the popup) so the value can be corrected without regenerating the key.
|
// not the popup) so the value can be corrected without regenerating the key.
|
||||||
|
if (baseUrl && apiKey) refreshDeviceList(defaultDeviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared by Test Connection and the default-tablet picker — one fetch, two
|
||||||
|
// uses, rather than hitting the API twice for what's the same data.
|
||||||
|
async function fetchDevices() {
|
||||||
|
const { baseUrl, apiKey } = await chrome.storage.local.get(['baseUrl', 'apiKey']);
|
||||||
|
if (!baseUrl || !apiKey) throw new Error('Set both fields and save first.');
|
||||||
|
const res = await fetch(`${baseUrl}/api/staff/devices`, {
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||||||
|
});
|
||||||
|
const body = await res.json().catch(() => ([]));
|
||||||
|
if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populates the "default tablet for this machine" picker — see project_checkin
|
||||||
|
// memory's extension notes: the popup pre-selects this but still lets staff
|
||||||
|
// override it; the injected buttons skip asking entirely and push straight
|
||||||
|
// to it unless it's busy. Selection auto-saves on change, no separate button
|
||||||
|
// — it's a small enough setting not to warrant one.
|
||||||
|
async function refreshDeviceList(preselectId) {
|
||||||
|
try {
|
||||||
|
const devices = await fetchDevices();
|
||||||
|
if (!devices.length) {
|
||||||
|
defaultSection.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
defaultDeviceSelect.innerHTML = devices.map(d =>
|
||||||
|
`<option value="${d.device_id}">${escapeHtml(d.name)}</option>`
|
||||||
|
).join('');
|
||||||
|
if (preselectId) defaultDeviceSelect.value = preselectId;
|
||||||
|
defaultSection.style.display = '';
|
||||||
|
} catch {
|
||||||
|
// Not configured/reachable yet — leave the section hidden rather than
|
||||||
|
// showing an empty/broken picker; Test Connection surfaces the real error.
|
||||||
|
defaultSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultDeviceSelect.addEventListener('change', () => {
|
||||||
|
chrome.storage.local.set({ defaultDeviceId: defaultDeviceSelect.value });
|
||||||
|
});
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = s;
|
||||||
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
// tablet-checkin's own base URL isn't known in advance (dev today, a
|
// tablet-checkin's own base URL isn't known in advance (dev today, a
|
||||||
|
|
@ -33,28 +83,17 @@ async function save() {
|
||||||
await chrome.storage.local.set({ baseUrl, apiKey });
|
await chrome.storage.local.set({ baseUrl, apiKey });
|
||||||
statusEl.textContent = 'Saved.';
|
statusEl.textContent = 'Saved.';
|
||||||
statusEl.style.color = '#16a34a';
|
statusEl.style.color = '#16a34a';
|
||||||
|
refreshDeviceList();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function test() {
|
async function test() {
|
||||||
const { baseUrl, apiKey } = await chrome.storage.local.get(['baseUrl', 'apiKey']);
|
|
||||||
if (!baseUrl || !apiKey) {
|
|
||||||
statusEl.textContent = 'Set both fields and save first.';
|
|
||||||
statusEl.style.color = '#dc2626';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
statusEl.textContent = 'Testing…';
|
statusEl.textContent = 'Testing…';
|
||||||
statusEl.style.color = '#64748b';
|
statusEl.style.color = '#64748b';
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${baseUrl}/api/staff/devices`, {
|
const devices = await fetchDevices();
|
||||||
headers: { Authorization: `Bearer ${apiKey}` },
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(body.error || `HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
const devices = await res.json();
|
|
||||||
statusEl.textContent = `Connected — found ${devices.length} tablet(s).`;
|
statusEl.textContent = `Connected — found ${devices.length} tablet(s).`;
|
||||||
statusEl.style.color = '#16a34a';
|
statusEl.style.color = '#16a34a';
|
||||||
|
refreshDeviceList();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
statusEl.textContent = `Failed: ${err.message}`;
|
statusEl.textContent = `Failed: ${err.message}`;
|
||||||
statusEl.style.color = '#dc2626';
|
statusEl.style.color = '#dc2626';
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ async function main() {
|
||||||
nbStatusEl.className = `nb-status ${ok ? 'ok' : 'no'}`;
|
nbStatusEl.className = `nb-status ${ok ? 'ok' : 'no'}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const { baseUrl, apiKey } = await chrome.storage.local.get(['baseUrl', 'apiKey']);
|
const { baseUrl, apiKey, defaultDeviceId } = await chrome.storage.local.get(['baseUrl', 'apiKey', 'defaultDeviceId']);
|
||||||
if (!baseUrl || !apiKey) {
|
if (!baseUrl || !apiKey) {
|
||||||
contentEl.innerHTML = `
|
contentEl.innerHTML = `
|
||||||
<p class="muted">Not set up yet — add the tablet-checkin URL and a terminal API key first.</p>
|
<p class="muted">Not set up yet — add the tablet-checkin URL and a terminal API key first.</p>
|
||||||
|
|
@ -72,6 +72,12 @@ async function main() {
|
||||||
${escapeHtml(d.name)}${d.status === 'busy' ? ' (busy)' : ''}
|
${escapeHtml(d.name)}${d.status === 'busy' ? ' (busy)' : ''}
|
||||||
</option>`
|
</option>`
|
||||||
).join('');
|
).join('');
|
||||||
|
// Pre-select this machine's configured default tablet — still just a
|
||||||
|
// starting point, staff can pick a different one from the dropdown.
|
||||||
|
// Setting .value to a disabled (busy) option is a no-op in every browser
|
||||||
|
// that matters here, so a busy default just falls through to whatever the
|
||||||
|
// browser's own first-enabled-option default would have been anyway.
|
||||||
|
if (defaultDeviceId) deviceSelect.value = defaultDeviceId;
|
||||||
sendBtn.disabled = false;
|
sendBtn.disabled = false;
|
||||||
|
|
||||||
sendBtn.addEventListener('click', async () => {
|
sendBtn.addEventListener('click', async () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue