From 205d476c705d956c0ee2840d56402ea47211f1c6 Mon Sep 17 00:00:00 2001 From: Max Edell Date: Thu, 16 Apr 2026 15:11:30 -0700 Subject: [PATCH 01/13] feat: productbus admin tool --- tools/productbus-admin/admins.js | 144 +++++ tools/productbus-admin/api.js | 67 +++ tools/productbus-admin/catalog.js | 324 +++++++++++ tools/productbus-admin/config.js | 164 ++++++ tools/productbus-admin/customers.js | 193 ++++++ tools/productbus-admin/index.html | 30 + tools/productbus-admin/indices.js | 156 +++++ tools/productbus-admin/login.js | 111 ++++ tools/productbus-admin/orders.js | 275 +++++++++ tools/productbus-admin/productbus-admin.css | 614 ++++++++++++++++++++ tools/productbus-admin/productbus-admin.js | 240 ++++++++ tools/productbus-admin/ui.js | 101 ++++ 12 files changed, 2419 insertions(+) create mode 100644 tools/productbus-admin/admins.js create mode 100644 tools/productbus-admin/api.js create mode 100644 tools/productbus-admin/catalog.js create mode 100644 tools/productbus-admin/config.js create mode 100644 tools/productbus-admin/customers.js create mode 100644 tools/productbus-admin/index.html create mode 100644 tools/productbus-admin/indices.js create mode 100644 tools/productbus-admin/login.js create mode 100644 tools/productbus-admin/orders.js create mode 100644 tools/productbus-admin/productbus-admin.css create mode 100644 tools/productbus-admin/productbus-admin.js create mode 100644 tools/productbus-admin/ui.js diff --git a/tools/productbus-admin/admins.js b/tools/productbus-admin/admins.js new file mode 100644 index 00000000..a304ade8 --- /dev/null +++ b/tools/productbus-admin/admins.js @@ -0,0 +1,144 @@ +/** + * ProductBus Admin - Admins page (superuser only) + */ + +import { apiFetch } from './api.js'; +import { showToast, createModal } from './ui.js'; + +function renderTable(container, admins, ctx) { + const tableWrap = container.querySelector('#admins-table'); + if (admins.length === 0) { + tableWrap.innerHTML = ` +
+

No admins found

+

Add your first admin to get started

+
+ `; + return; + } + + tableWrap.innerHTML = ` + + + + + + + + + + + ${admins.map((a) => ` + + + + + + + `).join('')} + +
EmailDate AddedAdded ByActions
${a.email}${a.dateAdded ? new Date(a.dateAdded).toLocaleDateString() : '—'}${a.addedBy || '—'} +
+ +
+
+ `; + + tableWrap.querySelectorAll('[data-action="delete"]').forEach((btn) => { + btn.addEventListener('click', async () => { + const { email } = btn.dataset; + // eslint-disable-next-line no-alert + // eslint-disable-next-line no-restricted-globals, no-alert + if (!confirm(`Remove admin ${email}?`)) return; + try { + await apiFetch(ctx.org, ctx.site, `auth/admins/${encodeURIComponent(email)}`, { method: 'DELETE' }); + showToast('Admin removed'); + // eslint-disable-next-line no-use-before-define + render(container, ctx); + } catch (err) { + showToast(`Failed to remove: ${err.message}`, 'error'); + } + }); + }); +} + +function openAddModal(ctx, onAdded) { + const content = document.createElement('form'); + content.id = 'add-admin-form'; + content.innerHTML = ` +
+ + +
+ `; + + const footer = document.createElement('div'); + footer.innerHTML = ` + + + `; + + const dialog = createModal('Add Admin', content, footer); + + dialog.querySelector('.cancel-btn').addEventListener('click', () => { + dialog.close(); + dialog.remove(); + }); + + content.addEventListener('submit', async (e) => { + e.preventDefault(); + const saveBtn = dialog.querySelector('.save-btn'); + saveBtn.disabled = true; + saveBtn.textContent = 'Adding...'; + + try { + const email = content.querySelector('#admin-email').value; + await apiFetch(ctx.org, ctx.site, `auth/admins/${encodeURIComponent(email)}`, { method: 'PUT' }); + showToast('Admin added'); + dialog.close(); + dialog.remove(); + onAdded(); + } catch (err) { + showToast(`Failed to add admin: ${err.message}`, 'error'); + saveBtn.disabled = false; + saveBtn.textContent = 'Add Admin'; + } + }); +} + +export async function render(container, ctx) { + container.innerHTML = ` + +
+ + +
+
+

Loading admins...

+
+ `; + + try { + const resp = await apiFetch(ctx.org, ctx.site, 'auth/admins', { method: 'GET' }); + const data = await resp.json(); + const admins = data.admins || data || []; + + renderTable(container, admins, ctx); + + container.querySelector('#search-admins').addEventListener('input', (e) => { + const q = e.target.value.toLowerCase(); + const filtered = admins.filter((a) => a.email.toLowerCase().includes(q)); + renderTable(container, filtered, ctx); + }); + + container.querySelector('#add-admin-btn').addEventListener('click', () => { + openAddModal(ctx, () => render(container, ctx)); + }); + } catch (err) { + container.querySelector('#admins-table').innerHTML = `

Failed to load admins: ${err.message}

`; + } +} + +export function destroy() {} diff --git a/tools/productbus-admin/api.js b/tools/productbus-admin/api.js new file mode 100644 index 00000000..0d577397 --- /dev/null +++ b/tools/productbus-admin/api.js @@ -0,0 +1,67 @@ +/** + * ProductBus Admin API client + * Shared fetch wrapper with auth and error handling + */ + +import { showToast } from './ui.js'; + +const DEFAULT_API_BASE = 'https://api.adobecommerce.live'; + +export function getApiBase() { + return localStorage.getItem('productbus-api-url') || DEFAULT_API_BASE; +} + +export function getAuthState(org, site) { + try { + const data = sessionStorage.getItem(`pbus-auth-${org}-${site}`); + return data ? JSON.parse(data) : null; + } catch (e) { + return null; + } +} + +export function setAuthState(org, site, state) { + sessionStorage.setItem(`pbus-auth-${org}-${site}`, JSON.stringify(state)); +} + +export function clearAuthState(org, site) { + sessionStorage.removeItem(`pbus-auth-${org}-${site}`); +} + +export async function apiFetch(org, site, path, options = {}) { + const base = getApiBase(); + const url = `${base}/${org}/sites/${site}/${path}`; + const auth = getAuthState(org, site); + + const headers = { + 'Content-Type': 'application/json', + ...options.headers, + }; + + if (auth?.token) { + headers.Authorization = `Bearer ${auth.token}`; + } + + const response = await fetch(url, { + ...options, + headers, + credentials: 'include', + }); + + if (response.status === 401) { + clearAuthState(org, site); + const params = new URLSearchParams(window.location.search); + params.set('page', 'login'); + params.set('redirect', window.location.href); + window.location.href = `${window.location.pathname}?${params.toString()}`; + throw new Error('Unauthorized'); + } + + if (response.status === 403) { + const errorMsg = response.headers.get('x-error') || 'Forbidden'; + showToast(`${errorMsg} (${response.status})`, 'error'); + throw new Error(errorMsg); + } + + return response; +} diff --git a/tools/productbus-admin/catalog.js b/tools/productbus-admin/catalog.js new file mode 100644 index 00000000..fd6e4649 --- /dev/null +++ b/tools/productbus-admin/catalog.js @@ -0,0 +1,324 @@ +/** + * ProductBus Admin - Catalog page + */ + +import { apiFetch } from './api.js'; +import { showToast, createModal, createFormField } from './ui.js'; + +const AVAILABILITY_OPTIONS = [ + 'BackOrder', 'Discontinued', 'InStock', 'InStoreOnly', + 'LimitedAvailability', 'OnlineOnly', 'OutOfStock', + 'PreOrder', 'PreSale', 'SoldOut', +]; + +const CONDITION_OPTIONS = [ + 'NewCondition', 'DamagedCondition', 'RefurbishedCondition', 'UsedCondition', +]; + +const PATH_PATTERN = /^\/[a-z0-9-/]+$/; + +function renderTable(container, products, ctx) { + const tableWrap = container.querySelector('#catalog-table'); + if (products.length === 0) { + tableWrap.innerHTML = ` +
+

No products found

+

Create your first product to get started

+
+ `; + return; + } + + tableWrap.innerHTML = ` + + + + + + + + + + + ${products.map((p) => ` + + + + + + + `).join('')} + +
SKUNamePathActions
${p.sku}${p.name || '—'}${p.path || '—'} +
+ + +
+
+ `; + + tableWrap.querySelectorAll('[data-action="edit"]').forEach((btn) => { + btn.addEventListener('click', async () => { + const { path } = btn.dataset; + try { + const jsonPath = path.endsWith('.json') ? path : `${path}.json`; + const resp = await apiFetch(ctx.org, ctx.site, `catalog${jsonPath}`, { method: 'GET' }); + const product = await resp.json(); + // eslint-disable-next-line no-use-before-define + openProductModal(ctx, product, () => render(container, ctx)); + } catch (err) { + showToast(`Failed to load product: ${err.message}`, 'error'); + } + }); + }); + + tableWrap.querySelectorAll('[data-action="delete"]').forEach((btn) => { + btn.addEventListener('click', async () => { + const { path } = btn.dataset; + const jsonPath = path.endsWith('.json') ? path : `${path}.json`; + // eslint-disable-next-line no-restricted-globals, no-alert + if (!confirm(`Delete product at ${path}?`)) return; + try { + await apiFetch(ctx.org, ctx.site, `catalog${jsonPath}`, { method: 'DELETE' }); + showToast('Product deleted'); + // eslint-disable-next-line no-use-before-define + render(container, ctx); + } catch (err) { + showToast(`Failed to delete: ${err.message}`, 'error'); + } + }); + }); +} + +function buildFormHTML(product) { + const p = product || {}; + const price = p.price || {}; + + function opt(options, selected) { + return options.map((o) => ``).join(''); + } + + return ` +
+

Basic Information

+
+ ${createFormField('sku', 'SKU', 'text', { required: true, value: p.sku || '' }).outerHTML} + ${createFormField('name', 'Name', 'text', { required: true, value: p.name || '' }).outerHTML} +
+
+ ${createFormField('path', 'Path', 'text', { required: true, value: p.path || '', placeholder: '/products/my-product' }).outerHTML} + ${createFormField('url', 'URL', 'text', { value: p.url || '' }).outerHTML} +
+
+ ${createFormField('brand', 'Brand', 'text', { value: p.brand || '' }).outerHTML} + ${createFormField('type', 'Type', 'text', { value: p.type || '' }).outerHTML} +
+
+ + +
+
+ ${createFormField('metaTitle', 'Meta Title', 'text', { value: p.metaTitle || '' }).outerHTML} + ${createFormField('metaDescription', 'Meta Description', 'text', { value: p.metaDescription || '' }).outerHTML} +
+
+ ${createFormField('gtin', 'GTIN', 'text', { value: p.gtin || '' }).outerHTML} +
+ + +
+
+
+ + +
+ +

Price

+
+ ${createFormField('price.currency', 'Currency', 'text', { value: price.currency || 'USD' }).outerHTML} + ${createFormField('price.regular', 'Regular', 'number', { value: price.regular ?? '' }).outerHTML} +
+
+ ${createFormField('price.final', 'Final', 'number', { value: price.final ?? '' }).outerHTML} +
+ +

Images

+
+ ${(p.images || []).map((img, i) => ` +
+ ${createFormField(`images.${i}.url`, 'URL', 'text', { value: img.url || '' }).outerHTML} + ${createFormField(`images.${i}.label`, 'Label', 'text', { value: img.label || '' }).outerHTML} +
+ `).join('') || '

No images. Use JSON view to add images, variants, options, and custom fields.

'} +
+
+ `; +} + +function openProductModal(ctx, existing, onSaved) { + const isNew = !existing; + const title = isNew ? 'Create Product' : `Edit: ${existing.sku}`; + + const content = document.createElement('div'); + let currentView = 'form'; + + function renderView() { + if (currentView === 'form') { + content.innerHTML = ` +
+ + +
+ ${buildFormHTML(existing)} + `; + } else { + const jsonData = existing ? { ...existing } : { + sku: '', + name: '', + path: '', + description: '', + price: { + currency: 'USD', + regular: 0, + final: 0, + }, + images: [], + }; + // Remove read-only internal fields for editing + delete jsonData.internal; + + content.innerHTML = ` +
+ + +
+ + `; + } + + content.querySelectorAll('.view-switcher button').forEach((btn) => { + btn.addEventListener('click', () => { + currentView = btn.dataset.view; + renderView(); + }); + }); + } + + renderView(); + + const footer = document.createElement('div'); + footer.innerHTML = ` + + + `; + + const dialog = createModal(title, content, footer); + + dialog.querySelector('.cancel-btn').addEventListener('click', () => { + dialog.close(); + dialog.remove(); + }); + + dialog.querySelector('.save-btn').addEventListener('click', async () => { + const saveBtn = dialog.querySelector('.save-btn'); + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + + try { + let productData; + if (currentView === 'json') { + productData = JSON.parse(dialog.querySelector('#json-editor').value); + } else { + const form = dialog.querySelector('#product-form'); + const fd = new FormData(form); + productData = { price: {}, images: [] }; + + fd.forEach((val, key) => { + if (key.startsWith('price.')) { + const field = key.split('.')[1]; + productData.price[field] = field === 'currency' ? val : parseFloat(val) || 0; + } else if (key.startsWith('images.')) { + const parts = key.split('.'); + const idx = parseInt(parts[1], 10); + if (!productData.images[idx]) productData.images[idx] = {}; + productData.images[idx][parts[2]] = val; + } else if (val !== '') { + productData[key] = val; + } + }); + + // Clean empty images + productData.images = productData.images.filter((img) => img && img.url); + } + + // Validate path + let { path } = productData; + if (!path) throw new Error('Path is required'); + if (path.endsWith('.json')) path = path.slice(0, -5); + if (!PATH_PATTERN.test(path)) { + throw new Error('Path must start with / and contain only lowercase letters, numbers, hyphens, and slashes'); + } + + const jsonPath = `${path}.json`; + productData.path = path; + + await apiFetch(ctx.org, ctx.site, `catalog${jsonPath}`, { + method: 'PUT', + body: JSON.stringify(productData), + }); + showToast(isNew ? 'Product created' : 'Product updated'); + dialog.close(); + dialog.remove(); + onSaved(); + } catch (err) { + showToast(`Failed to save: ${err.message}`, 'error'); + saveBtn.disabled = false; + saveBtn.textContent = isNew ? 'Create' : 'Update'; + } + }); +} + +export async function render(container, ctx) { + container.innerHTML = ` + +
+ + +
+
+

Loading catalog...

+
+ `; + + try { + const resp = await apiFetch(ctx.org, ctx.site, 'catalog', { method: 'GET' }); + const data = await resp.json(); + const products = data.products || []; + + renderTable(container, products, ctx); + + container.querySelector('#search-catalog').addEventListener('input', (e) => { + const q = e.target.value.toLowerCase(); + const filtered = products.filter((p) => (p.sku || '').toLowerCase().includes(q) + || (p.name || '').toLowerCase().includes(q) + || (p.path || '').toLowerCase().includes(q)); + renderTable(container, filtered, ctx); + }); + + container.querySelector('#add-product-btn').addEventListener('click', () => { + openProductModal(ctx, null, () => render(container, ctx)); + }); + } catch (err) { + container.querySelector('#catalog-table').innerHTML = `

Failed to load catalog: ${err.message}

`; + } +} + +export function destroy() {} diff --git a/tools/productbus-admin/config.js b/tools/productbus-admin/config.js new file mode 100644 index 00000000..dd78c8b9 --- /dev/null +++ b/tools/productbus-admin/config.js @@ -0,0 +1,164 @@ +/** + * ProductBus Admin - Config page + */ + +import { apiFetch } from './api.js'; +import { showToast, createFormField } from './ui.js'; + +function buildFormHTML(config) { + const c = config || {}; + return ` +
+
+ +
+ ${createFormField('authOrigins', 'Auth Origins (comma-separated)', 'text', { + value: Array.isArray(c.authOrigins) ? c.authOrigins.join(', ') : (c.authOrigins || ''), + }).outerHTML} + ${createFormField('otpEmailSender', 'OTP Email Sender', 'email', { + value: c.otpEmailSender || '', + }).outerHTML} + ${createFormField('otpEmailSubject', 'OTP Email Subject', 'text', { + value: c.otpEmailSubject || '', maxLength: '255', + }).outerHTML} +
+ + +
+ ${createFormField('otpEmailBodyUrl', 'OTP Email Body URL', 'text', { + value: c.otpEmailBodyUrl || '', maxLength: '1024', + }).outerHTML} +
+ `; +} + +function getFormConfig(form) { + const fd = new FormData(form); + const config = {}; + + config.authEnabled = form.querySelector('#authEnabled').checked; + + const origins = fd.get('authOrigins'); + if (origins) { + config.authOrigins = origins.split(',').map((s) => s.trim()).filter(Boolean); + } + + ['otpEmailSender', 'otpEmailSubject', 'otpEmailBodyTemplate', 'otpEmailBodyUrl'].forEach((key) => { + const val = fd.get(key); + if (val) config[key] = val; + }); + + return config; +} + +export async function render(container, ctx) { + container.innerHTML = ` + +
+

Loading configuration...

+
+ `; + + let config = {}; + try { + const resp = await apiFetch(ctx.org, ctx.site, 'config', { method: 'GET' }); + if (resp.ok) { + config = await resp.json(); + } + } catch (err) { + // Config may not exist yet + if (!err.message.includes('Forbidden')) { + config = {}; + } else { + container.querySelector('#config-content').innerHTML = `

Failed to load config: ${err.message}

`; + return; + } + } + + let currentView = 'form'; + + function renderView() { + const content = container.querySelector('#config-content'); + if (currentView === 'form') { + content.innerHTML = ` +
+ + +
+ ${buildFormHTML(config)} +
+ + +
+ `; + } else { + content.innerHTML = ` +
+ + +
+ +
+ + +
+ `; + } + + content.querySelectorAll('.view-switcher button').forEach((btn) => { + btn.addEventListener('click', () => { + currentView = btn.dataset.view; + renderView(); + }); + }); + + content.querySelector('#save-config-btn').addEventListener('click', async () => { + const saveBtn = content.querySelector('#save-config-btn'); + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + + try { + let configData; + if (currentView === 'json') { + configData = JSON.parse(content.querySelector('#json-editor').value); + } else { + configData = getFormConfig(content.querySelector('#config-form')); + } + + await apiFetch(ctx.org, ctx.site, 'config', { + method: 'POST', + body: JSON.stringify(configData), + }); + config = configData; + showToast('Config saved'); + } catch (err) { + showToast(`Failed to save: ${err.message}`, 'error'); + } + saveBtn.disabled = false; + saveBtn.textContent = 'Save Config'; + }); + + content.querySelector('#delete-config-btn').addEventListener('click', async () => { + // eslint-disable-next-line no-alert + // eslint-disable-next-line no-restricted-globals, no-alert + if (!confirm('Delete configuration? This cannot be undone.')) return; + try { + await apiFetch(ctx.org, ctx.site, 'config', { method: 'DELETE' }); + config = {}; + showToast('Config deleted'); + renderView(); + } catch (err) { + showToast(`Failed to delete: ${err.message}`, 'error'); + } + }); + } + + renderView(); +} + +export function destroy() {} diff --git a/tools/productbus-admin/customers.js b/tools/productbus-admin/customers.js new file mode 100644 index 00000000..801f0104 --- /dev/null +++ b/tools/productbus-admin/customers.js @@ -0,0 +1,193 @@ +/** + * ProductBus Admin - Customers page + */ + +import { apiFetch } from './api.js'; +import { showToast, createModal, createFormField } from './ui.js'; + +function renderTable(container, customers, ctx) { + const tableWrap = container.querySelector('#customers-table'); + if (customers.length === 0) { + tableWrap.innerHTML = ` +
+

No customers found

+

Create your first customer to get started

+
+ `; + return; + } + + tableWrap.innerHTML = ` + + + + + + + + + + + + + ${customers.map((c) => ` + + + + + + + + + `).join('')} + +
EmailFirst NameLast NamePhoneCreatedActions
${c.email}${c.firstName || '—'}${c.lastName || '—'}${c.phone || '—'}${c.createdAt ? new Date(c.createdAt).toLocaleDateString() : '—'} +
+ +
+
+ `; + + tableWrap.querySelectorAll('[data-action="delete"]').forEach((btn) => { + btn.addEventListener('click', async () => { + const { email } = btn.dataset; + // eslint-disable-next-line no-alert + // eslint-disable-next-line no-restricted-globals, no-alert + if (!confirm(`Delete customer ${email}? This will also remove their orders and addresses.`)) return; + try { + await apiFetch(ctx.org, ctx.site, `customers/${encodeURIComponent(email)}`, { method: 'DELETE' }); + showToast('Customer deleted'); + // eslint-disable-next-line no-use-before-define + render(container, ctx); + } catch (err) { + showToast(`Failed to delete: ${err.message}`, 'error'); + } + }); + }); +} + +function openCreateModal(ctx, onCreated) { + const content = document.createElement('div'); + let currentView = 'form'; + + function renderView() { + if (currentView === 'form') { + content.innerHTML = ` +
+ + +
+
+
+ ${createFormField('firstName', 'First Name', 'text', { required: true }).outerHTML} + ${createFormField('lastName', 'Last Name', 'text', { required: true }).outerHTML} +
+
+ ${createFormField('email', 'Email', 'email', { required: true }).outerHTML} + ${createFormField('phone', 'Phone').outerHTML} +
+
+ `; + } else { + content.innerHTML = ` +
+ + +
+ + `; + } + + content.querySelectorAll('.view-switcher button').forEach((btn) => { + btn.addEventListener('click', () => { + currentView = btn.dataset.view; + renderView(); + }); + }); + } + + renderView(); + + const footer = document.createElement('div'); + footer.innerHTML = ` + + + `; + + const dialog = createModal('Create Customer', content, footer); + + dialog.querySelector('.cancel-btn').addEventListener('click', () => { + dialog.close(); + dialog.remove(); + }); + + dialog.querySelector('.save-btn').addEventListener('click', async () => { + const saveBtn = dialog.querySelector('.save-btn'); + saveBtn.disabled = true; + saveBtn.textContent = 'Creating...'; + + try { + let customerData; + if (currentView === 'json') { + customerData = JSON.parse(dialog.querySelector('#json-editor').value); + } else { + const form = dialog.querySelector('#customer-form'); + customerData = Object.fromEntries(new FormData(form)); + } + + await apiFetch(ctx.org, ctx.site, 'customers', { + method: 'POST', + body: JSON.stringify(customerData), + }); + showToast('Customer created'); + dialog.close(); + dialog.remove(); + onCreated(); + } catch (err) { + showToast(`Failed to create customer: ${err.message}`, 'error'); + saveBtn.disabled = false; + saveBtn.textContent = 'Create Customer'; + } + }); +} + +export async function render(container, ctx) { + container.innerHTML = ` + +
+ + +
+
+

Loading customers...

+
+ `; + + try { + const resp = await apiFetch(ctx.org, ctx.site, 'customers', { method: 'GET' }); + const data = await resp.json(); + const customers = data.customers || data || []; + + renderTable(container, customers, ctx); + + container.querySelector('#search-customers').addEventListener('input', (e) => { + const q = e.target.value.toLowerCase(); + const filtered = customers.filter((c) => c.email.toLowerCase().includes(q) + || (c.firstName || '').toLowerCase().includes(q) + || (c.lastName || '').toLowerCase().includes(q)); + renderTable(container, filtered, ctx); + }); + + container.querySelector('#add-customer-btn').addEventListener('click', () => { + openCreateModal(ctx, () => render(container, ctx)); + }); + } catch (err) { + container.querySelector('#customers-table').innerHTML = `

Failed to load customers: ${err.message}

`; + } +} + +export function destroy() {} diff --git a/tools/productbus-admin/index.html b/tools/productbus-admin/index.html new file mode 100644 index 00000000..5a64b8a7 --- /dev/null +++ b/tools/productbus-admin/index.html @@ -0,0 +1,30 @@ + + + + + + + + + ProductBus Admin + + + + + + + + + +
+
+
+
+ + + + diff --git a/tools/productbus-admin/indices.js b/tools/productbus-admin/indices.js new file mode 100644 index 00000000..f09a04ac --- /dev/null +++ b/tools/productbus-admin/indices.js @@ -0,0 +1,156 @@ +/** + * ProductBus Admin - Indices page + */ + +import { apiFetch } from './api.js'; +import { showToast, createModal, createFormField } from './ui.js'; + +const DIR_PATH_PATTERN = /^\/[a-z0-9-/]+$/; + +function renderTable(container, indices, ctx) { + const tableWrap = container.querySelector('#indices-table'); + if (indices.length === 0) { + tableWrap.innerHTML = ` +
+

No indices found

+

Create your first index to get started

+
+ `; + return; + } + + tableWrap.innerHTML = ` + + + + + + + + + + ${indices.map((idx) => ` + + + + + + `).join('')} + +
PathLast ModifiedActions
${idx.path}${idx.lastModified ? new Date(idx.lastModified).toLocaleDateString() : '—'} +
+ +
+
+ `; + + tableWrap.querySelectorAll('[data-action="delete"]').forEach((btn) => { + btn.addEventListener('click', async () => { + const { path } = btn.dataset; + // eslint-disable-next-line no-restricted-globals, no-alert + if (!confirm(`Delete index at ${path}?`)) return; + try { + await apiFetch(ctx.org, ctx.site, `index${path}`, { method: 'DELETE' }); + showToast('Index deleted'); + // eslint-disable-next-line no-use-before-define + render(container, ctx); + } catch (err) { + showToast(`Failed to delete: ${err.message}`, 'error'); + } + }); + }); +} + +function openCreateModal(ctx, onCreated) { + const content = document.createElement('form'); + content.id = 'index-form'; + content.innerHTML = ` + ${createFormField('path', 'Path', 'text', { + required: true, + placeholder: '/products/index.json', + }).outerHTML} +

Must start with /, contain only lowercase letters, numbers, hyphens, and slashes, and end with /index.json

+ `; + + const footer = document.createElement('div'); + footer.innerHTML = ` + + + `; + + const dialog = createModal('Create Index', content, footer); + + dialog.querySelector('.cancel-btn').addEventListener('click', () => { + dialog.close(); + dialog.remove(); + }); + + content.addEventListener('submit', async (e) => { + e.preventDefault(); + const saveBtn = dialog.querySelector('.save-btn'); + saveBtn.disabled = true; + saveBtn.textContent = 'Creating...'; + + try { + let path = content.querySelector('#path').value.trim(); + + // Normalize: strip trailing /index.json if provided, validate base path + if (path.endsWith('/index.json')) { + path = path.slice(0, -'/index.json'.length); + } + path = path.replace(/\/+$/, ''); + + if (!DIR_PATH_PATTERN.test(path)) { + throw new Error('Invalid path. Must start with / and contain only lowercase letters, numbers, hyphens, and slashes.'); + } + + const indexPath = `${path}/index.json`; + await apiFetch(ctx.org, ctx.site, `index${indexPath}`, { method: 'POST' }); + showToast('Index created'); + dialog.close(); + dialog.remove(); + onCreated(); + } catch (err) { + showToast(`Failed to create index: ${err.message}`, 'error'); + saveBtn.disabled = false; + saveBtn.textContent = 'Create Index'; + } + }); +} + +export async function render(container, ctx) { + container.innerHTML = ` + +
+ + +
+
+

Loading indices...

+
+ `; + + try { + const resp = await apiFetch(ctx.org, ctx.site, 'index', { method: 'GET' }); + const data = await resp.json(); + const indices = data.indices || []; + + renderTable(container, indices, ctx); + + container.querySelector('#search-indices').addEventListener('input', (e) => { + const q = e.target.value.toLowerCase(); + const filtered = indices.filter((idx) => idx.path.toLowerCase().includes(q)); + renderTable(container, filtered, ctx); + }); + + container.querySelector('#add-index-btn').addEventListener('click', () => { + openCreateModal(ctx, () => render(container, ctx)); + }); + } catch (err) { + container.querySelector('#indices-table').innerHTML = `

Failed to load indices: ${err.message}

`; + } +} + +export function destroy() {} diff --git a/tools/productbus-admin/login.js b/tools/productbus-admin/login.js new file mode 100644 index 00000000..e45077c8 --- /dev/null +++ b/tools/productbus-admin/login.js @@ -0,0 +1,111 @@ +/** + * ProductBus Admin - Login page (OTP flow) + */ + +import { apiFetch, setAuthState } from './api.js'; +import { showToast } from './ui.js'; + +export async function render(container, ctx) { + const { org, site } = ctx; + const params = new URLSearchParams(window.location.search); + const redirect = params.get('redirect') || ''; + + container.innerHTML = ` +
+

ProductBus Admin Login

+

Enter your email to receive a verification code

+ +
+ `; + + const form = document.getElementById('login-form'); + let loginState = null; + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const submitBtn = form.querySelector('button[type="submit"]'); + submitBtn.disabled = true; + submitBtn.textContent = 'Please wait...'; + + try { + if (!loginState) { + // Step 1: Request OTP + const email = form.querySelector('#login-email').value; + const resp = await apiFetch(org, site, 'auth/login', { + method: 'POST', + body: JSON.stringify({ email }), + }); + loginState = await resp.json(); + loginState.email = email; + + // Switch to OTP entry + form.innerHTML = ` +
+ + +
+
+ + +

Enter the 6-digit code sent to your email

+
+
+ + +
+ `; + + form.querySelector('#login-back-btn').addEventListener('click', () => { + render(container, ctx); + }); + form.querySelector('#otp-code').focus(); + } else { + // Step 2: Verify OTP + const code = form.querySelector('#otp-code').value; + const resp = await apiFetch(org, site, 'auth/callback', { + method: 'POST', + body: JSON.stringify({ + email: loginState.email, + code, + hash: loginState.hash, + exp: loginState.exp, + }), + }); + const result = await resp.json(); + + setAuthState(org, site, { + token: result.token, + email: result.email, + roles: result.roles, + org: result.org, + site: result.site, + }); + + if (redirect) { + window.location.href = redirect; + } else { + const p = new URLSearchParams(window.location.search); + p.set('page', 'orders'); + p.delete('redirect'); + window.location.href = `${window.location.pathname}?${p.toString()}`; + } + } + } catch (error) { + showToast(error.message || 'Login failed', 'error'); + submitBtn.disabled = false; + submitBtn.textContent = loginState ? 'Verify' : 'Send Code'; + } + }); +} + +export function destroy() {} diff --git a/tools/productbus-admin/orders.js b/tools/productbus-admin/orders.js new file mode 100644 index 00000000..8b8c6afd --- /dev/null +++ b/tools/productbus-admin/orders.js @@ -0,0 +1,275 @@ +/** + * ProductBus Admin - Orders page + */ + +import { apiFetch } from './api.js'; +import { showToast, createModal, createFormField } from './ui.js'; + +function renderTable(container, orders, ctx) { + const tableWrap = container.querySelector('#orders-table'); + if (orders.length === 0) { + tableWrap.innerHTML = ` +
+

No orders found

+

Create your first order to get started

+
+ `; + return; + } + + tableWrap.innerHTML = ` + + + + + + + + + + + + + ${orders.map((o) => ` + + + + + + + + + `).join('')} + +
Order IDStateCustomerItemsCreatedActions
${o.id}${o.state || 'pending'}${o.customer?.email || o.customMetadata?.customerEmail || 'N/A'}${o.items?.length ?? '—'}${o.createdAt ? new Date(o.createdAt).toLocaleDateString() : 'N/A'} +
+ +
+
+ `; + + tableWrap.querySelectorAll('[data-action="view"]').forEach((btn) => { + btn.addEventListener('click', async () => { + try { + const resp = await apiFetch(ctx.org, ctx.site, `orders/${btn.dataset.id}`, { method: 'GET' }); + const order = await resp.json(); + // eslint-disable-next-line no-use-before-define + viewOrderModal(order); + } catch (err) { + showToast(`Failed to load order: ${err.message}`, 'error'); + } + }); + }); +} + +function viewOrderModal(order) { + const pre = document.createElement('pre'); + pre.className = 'json-display'; + pre.textContent = JSON.stringify(order, null, 2); + createModal(`Order: ${order.id}`, pre); +} + +function openCreateModal(ctx, onCreated) { + const content = document.createElement('div'); + let currentView = 'form'; + + function renderView() { + if (currentView === 'form') { + content.innerHTML = ` +
+ + +
+
+

Customer

+
+ ${createFormField('customer.firstName', 'First Name', 'text', { required: true }).outerHTML} + ${createFormField('customer.lastName', 'Last Name', 'text', { required: true }).outerHTML} +
+
+ ${createFormField('customer.email', 'Email', 'email', { required: true }).outerHTML} + ${createFormField('customer.phone', 'Phone').outerHTML} +
+

Shipping

+
+ ${createFormField('shipping.name', 'Name', 'text', { required: true }).outerHTML} + ${createFormField('shipping.email', 'Email', 'email').outerHTML} +
+
+ ${createFormField('shipping.address1', 'Address 1', 'text', { required: true }).outerHTML} + ${createFormField('shipping.address2', 'Address 2').outerHTML} +
+
+ ${createFormField('shipping.city', 'City', 'text', { required: true }).outerHTML} + ${createFormField('shipping.region', 'State/Region', 'text', { required: true }).outerHTML} +
+
+ ${createFormField('shipping.postcode', 'Zip/Postal', 'text', { required: true }).outerHTML} + ${createFormField('shipping.country', 'Country', 'text', { required: true }).outerHTML} +
+

Items

+
+
+
+ ${createFormField('items.0.sku', 'SKU', 'text', { required: true }).outerHTML} + ${createFormField('items.0.name', 'Name', 'text', { required: true }).outerHTML} +
+
+ ${createFormField('items.0.urlKey', 'URL Key').outerHTML} + ${createFormField('items.0.quantity', 'Qty', 'number', { value: '1', required: true }).outerHTML} +
+
+ ${createFormField('items.0.price.currency', 'Currency', 'text', { value: 'USD' }).outerHTML} + ${createFormField('items.0.price.regular', 'Regular Price', 'number').outerHTML} +
+
+ ${createFormField('items.0.price.final', 'Final Price', 'number').outerHTML} +
+
+
+
+ `; + } else { + content.innerHTML = ` +
+ + +
+ + `; + } + + content.querySelectorAll('.view-switcher button').forEach((btn) => { + btn.addEventListener('click', () => { + currentView = btn.dataset.view; + renderView(); + }); + }); + } + + renderView(); + + const footer = document.createElement('div'); + footer.innerHTML = ` + + + `; + + const dialog = createModal('Create Order', content, footer); + + dialog.querySelector('.cancel-btn').addEventListener('click', () => { + dialog.close(); + dialog.remove(); + }); + + dialog.querySelector('.save-btn').addEventListener('click', async () => { + const saveBtn = dialog.querySelector('.save-btn'); + saveBtn.disabled = true; + saveBtn.textContent = 'Creating...'; + + try { + let orderData; + if (currentView === 'json') { + orderData = JSON.parse(dialog.querySelector('#json-editor').value); + } else { + const form = dialog.querySelector('#order-form'); + const fd = new FormData(form); + orderData = { customer: {}, shipping: {}, items: [{}] }; + fd.forEach((val, key) => { + const parts = key.split('.'); + if (parts[0] === 'items') { + const idx = parseInt(parts[1], 10); + if (!orderData.items[idx]) orderData.items[idx] = {}; + if (parts.length === 4) { + if (!orderData.items[idx][parts[2]]) orderData.items[idx][parts[2]] = {}; + orderData.items[idx][parts[2]][parts[3]] = val; + } else { + orderData.items[idx][parts[2]] = val; + } + } else if (parts.length === 2) { + orderData[parts[0]][parts[1]] = val; + } + }); + } + + await apiFetch(ctx.org, ctx.site, 'orders', { + method: 'POST', + body: JSON.stringify(orderData), + }); + showToast('Order created'); + dialog.close(); + dialog.remove(); + onCreated(); + } catch (err) { + showToast(`Failed to create order: ${err.message}`, 'error'); + saveBtn.disabled = false; + saveBtn.textContent = 'Create Order'; + } + }); +} + +export async function render(container, ctx) { + container.innerHTML = ` + +
+ + +
+
+

Loading orders...

+
+ `; + + try { + const resp = await apiFetch(ctx.org, ctx.site, 'orders', { method: 'GET' }); + const data = await resp.json(); + const orders = data.orders || data || []; + + renderTable(container, orders, ctx); + + container.querySelector('#search-orders').addEventListener('input', (e) => { + const q = e.target.value.toLowerCase(); + const filtered = orders.filter((o) => (o.id || '').toLowerCase().includes(q) + || (o.customer?.email || '').toLowerCase().includes(q) + || (o.state || '').toLowerCase().includes(q)); + renderTable(container, filtered, ctx); + }); + + container.querySelector('#add-order-btn').addEventListener('click', () => { + openCreateModal(ctx, () => render(container, ctx)); + }); + } catch (err) { + container.querySelector('#orders-table').innerHTML = `

Failed to load orders: ${err.message}

`; + } +} + +export function destroy() {} diff --git a/tools/productbus-admin/productbus-admin.css b/tools/productbus-admin/productbus-admin.css new file mode 100644 index 00000000..b616a56f --- /dev/null +++ b/tools/productbus-admin/productbus-admin.css @@ -0,0 +1,614 @@ +/* ProductBus Admin Styles */ + +/* Override AEM section styles to preserve grid layout */ +.productbus-admin main > .section { + margin: 0; +} + +/* stylelint-disable-next-line no-descending-specificity */ +.productbus-admin main > .section > div { + margin: 0; + padding: 0; +} + +/* App Layout */ +.productbus-admin #app-container { + display: grid; + grid-template-columns: 260px 1fr; + gap: var(--spacing-l); + min-height: 600px; +} + +/* Sidebar */ +.productbus-admin #sidebar { + padding: var(--spacing-m); + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--layer-elevated); + display: flex; + flex-direction: column; + gap: var(--spacing-m); +} + +.productbus-admin .sidebar-connect { + padding-bottom: var(--spacing-m); + border-bottom: 1px solid var(--color-border); +} + +.productbus-admin .sidebar-connect h2 { + margin: 0 0 var(--spacing-m) 0; + font-size: var(--heading-size-s); + color: var(--color-text); +} + +.productbus-admin .sidebar-connect form { + display: flex; + flex-direction: column; + gap: var(--spacing-s); +} + +.productbus-admin .sidebar-connect .connect-btn { + width: 100%; + justify-content: center; + margin-top: var(--spacing-xs); +} + +.productbus-admin .sidebar-nav { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + flex: 1; +} + +.productbus-admin .sidebar-nav a { + display: flex; + align-items: center; + gap: var(--spacing-s); + padding: var(--spacing-s) var(--spacing-m); + border-radius: 6px; + text-decoration: none; + color: var(--color-text); + transition: background-color 0.2s ease; +} + +.productbus-admin .sidebar-nav a:hover { + background: light-dark(var(--gray-100), var(--gray-700)); +} + +.productbus-admin .sidebar-nav a.active { + background: var(--blue-100); + color: var(--blue-700); + font-weight: 600; +} + +.productbus-admin .sidebar-footer { + padding-top: var(--spacing-m); + border-top: 1px solid var(--color-border); + display: flex; + flex-direction: column; + gap: var(--spacing-s); +} + +.productbus-admin .sidebar-email { + font-size: var(--body-size-s); + color: var(--color-font-grey); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.productbus-admin .sidebar-footer .button { + width: 100%; + justify-content: center; +} + +/* Main Content */ +.productbus-admin #main-content { + min-height: 400px; + min-width: 0; + width: 100%; +} + +.productbus-admin .page-header { + margin-bottom: var(--spacing-l); +} + +.productbus-admin .page-header h1 { + margin: 0 0 var(--spacing-xs) 0; + font-size: var(--heading-size-l); +} + +.productbus-admin .page-header p { + margin: 0; + color: var(--color-font-grey); +} + +/* Loading/Error States */ +.productbus-admin .loading, +.productbus-admin .error { + padding: var(--spacing-xl); + text-align: center; + color: var(--color-font-grey); + font-style: italic; +} + +.productbus-admin .error { + color: var(--red-600); +} + +/* Login Page */ +.productbus-admin .login-container { + max-width: 500px; + margin: var(--spacing-xxl) auto; + padding: var(--spacing-xl); + border: 1px solid var(--color-border); + border-radius: 12px; + background: var(--layer-elevated); + box-shadow: var(--shadow-default); +} + +.productbus-admin .login-container h1 { + margin: 0 0 var(--spacing-s) 0; + text-align: center; +} + +.productbus-admin .login-container .subtitle { + text-align: center; + color: var(--color-font-grey); + margin: 0 0 var(--spacing-l) 0; +} + +.productbus-admin .login-form { + display: flex; + flex-direction: column; + gap: var(--spacing-m); +} + +/* Forms */ +.productbus-admin .form-field { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.productbus-admin .form-field label { + font-weight: 600; + color: var(--color-text); + font-size: var(--body-size-s); +} + +.productbus-admin .form-field input, +.productbus-admin .form-field textarea, +.productbus-admin .form-field select { + padding: var(--spacing-s) var(--spacing-m); + border: 1px solid var(--color-border); + border-radius: 6px; + font-size: var(--body-size-m); + background: var(--color-background); + color: var(--color-text); + font-family: inherit; +} + +.productbus-admin .form-field input:focus, +.productbus-admin .form-field textarea:focus, +.productbus-admin .form-field select:focus { + outline: none; + border-color: var(--blue-500); + box-shadow: 0 0 0 2px var(--blue-100); +} + +.productbus-admin .form-field input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.productbus-admin .form-field input[type="checkbox"] { + width: auto; + margin-right: var(--spacing-xs); +} + +.productbus-admin .form-field .field-hint { + font-size: var(--body-size-s); + color: var(--color-font-grey); + margin: 0; +} + +.productbus-admin .button-group { + display: flex; + gap: var(--spacing-s); + margin-top: var(--spacing-m); +} + +.productbus-admin .button-group .button { + flex: 1; +} + +/* Data Table */ +.productbus-admin .data-table { + width: 100%; + border-collapse: collapse; + margin-top: var(--spacing-m); +} + +.productbus-admin .data-table th, +.productbus-admin .data-table td { + padding: var(--spacing-s) var(--spacing-m); + text-align: left; + border-bottom: 1px solid var(--color-border); +} + +.productbus-admin .data-table th { + background: var(--layer-elevated); + font-weight: 600; + color: var(--color-text); + font-size: var(--body-size-s); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.productbus-admin .data-table tr:hover { + background: light-dark(var(--gray-50), var(--gray-800)); +} + +.productbus-admin .data-table code { + font-family: var(--code-font-family); + font-size: var(--body-size-s); +} + +.productbus-admin .data-table .actions { + display: flex; + gap: var(--spacing-xs); + justify-content: flex-end; +} + +.productbus-admin .data-table .btn-icon { + background: none; + border: 1px solid var(--color-border); + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + color: var(--color-text); + font-size: var(--body-size-xs); + transition: all 0.15s; +} + +.productbus-admin .data-table .btn-icon:hover { + background: light-dark(var(--gray-200), var(--gray-700)); +} + +.productbus-admin .data-table .btn-icon.danger { + color: var(--red-700); + border-color: var(--red-700); +} + +.productbus-admin .data-table .btn-icon.danger:hover { + background: var(--red-700); + color: white; +} + +/* Page Actions */ +.productbus-admin .page-actions { + display: flex; + justify-content: space-between; + align-items: center; + margin: 0 var(--spacing-l) var(--spacing-l) var(--spacing-m); + gap: var(--spacing-m); +} + +.productbus-admin .page-actions .search-input { + padding: var(--spacing-s) var(--spacing-m); + border: 1px solid var(--color-border); + border-radius: 6px; + flex: 1; + max-width: 400px; + background: var(--color-background); + color: var(--color-text); +} + +.productbus-admin .page-actions .search-input:focus { + outline: none; + border-color: var(--blue-500); + box-shadow: 0 0 0 2px var(--blue-100); +} + +/* Modal Styles */ +dialog.productbus-modal { + max-width: 800px; + width: 90vw; + max-height: 90vh; + border: 1px solid var(--color-border); + border-radius: 12px; + padding: 0; + box-shadow: var(--shadow-hover); + background: var(--color-background); + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + margin: 0; +} + +dialog.productbus-modal::backdrop { + background: rgb(0 0 0 / 50%); +} + +dialog.productbus-modal .modal-content { + display: flex; + flex-direction: column; + max-height: 90vh; +} + +dialog.productbus-modal .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-m) var(--spacing-l); + border-bottom: 1px solid var(--color-border); + background: var(--layer-elevated); + border-radius: 12px 12px 0 0; +} + +dialog.productbus-modal .modal-header h3 { + margin: 0; + font-size: var(--heading-size-m); + color: var(--color-text); +} + +dialog.productbus-modal .modal-close { + background: none; + border: none; + font-size: 1.5rem; + line-height: 1; + color: var(--color-font-grey); + cursor: pointer; + padding: 0; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s ease; +} + +dialog.productbus-modal .modal-close:hover { + background: light-dark(var(--gray-200), var(--gray-700)); + color: var(--color-text); +} + +dialog.productbus-modal .modal-body { + padding: var(--spacing-l); + overflow-y: auto; + flex: 1; +} + +dialog.productbus-modal .modal-footer { + display: flex; + gap: var(--spacing-s); + justify-content: flex-end; + padding: var(--spacing-l); + border-top: 1px solid var(--color-border); +} + +/* View Switcher (Form/JSON toggle) */ +.productbus-admin .view-switcher, +dialog.productbus-modal .view-switcher { + display: flex; + gap: var(--spacing-xs); + margin-bottom: var(--spacing-m); + padding: 4px; + background: light-dark(var(--gray-100), var(--gray-800)); + border-radius: 6px; + width: fit-content; +} + +.productbus-admin .view-switcher button, +dialog.productbus-modal .view-switcher button { + padding: var(--spacing-xs) var(--spacing-m); + border: none; + background: transparent; + color: var(--color-text); + cursor: pointer; + border-radius: 4px; + font-size: var(--body-size-s); + font-weight: 500; + transition: background-color 0.2s ease; +} + +.productbus-admin .view-switcher button.active, +dialog.productbus-modal .view-switcher button.active { + background: var(--color-background); + box-shadow: var(--shadow-default); +} + +/* JSON Editor */ +.productbus-admin .json-editor, +dialog.productbus-modal .json-editor { + width: 100%; + min-height: 300px; + font-family: var(--code-font-family); + font-size: var(--body-size-s); + padding: var(--spacing-m); + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--layer-elevated); + color: var(--color-text); + resize: vertical; + box-sizing: border-box; +} + +.productbus-admin .json-editor:focus, +dialog.productbus-modal .json-editor:focus { + outline: none; + border-color: var(--blue-500); + box-shadow: 0 0 0 2px var(--blue-100); +} + +/* JSON Display (read-only) */ +.productbus-admin .json-display { + background: var(--layer-elevated); + padding: var(--spacing-m); + border-radius: 8px; + overflow-x: auto; + font-family: var(--code-font-family); + font-size: var(--body-size-s); + border: 1px solid var(--color-border); + white-space: pre-wrap; + overflow-wrap: break-word; +} + +/* Form View */ +.productbus-admin .form-view, +dialog.productbus-modal .form-view { + display: flex; + flex-direction: column; + gap: var(--spacing-m); +} + +.productbus-admin .form-row, +dialog.productbus-modal .form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-m); +} + +dialog.productbus-modal h4, +.productbus-admin .form-view h4 { + margin: var(--spacing-m) 0 0; + font-size: var(--body-size-l); + color: var(--color-text); + border-bottom: 1px solid var(--color-border); + padding-bottom: var(--spacing-xs); +} + +/* Toast Notifications */ +.productbus-toast { + position: fixed; + bottom: var(--spacing-l); + left: 50%; + transform: translateX(-50%) translateY(100px); + padding: var(--spacing-m) var(--spacing-l); + border-radius: 8px; + box-shadow: var(--shadow-hover); + z-index: 10000; + opacity: 0; + transition: all 0.3s ease; + max-width: 500px; + pointer-events: none; +} + +.productbus-toast.show { + transform: translateX(-50%) translateY(0); + opacity: 1; +} + +.productbus-toast.success { + background: var(--green-700); + color: white; +} + +.productbus-toast.error { + background: var(--red-800); + color: white; +} + +/* Badge */ +.productbus-admin .badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: var(--body-size-xs); + font-weight: 600; + text-transform: uppercase; +} + +.productbus-admin .badge.success { + background: var(--green-100); + color: var(--green-800); +} + +.productbus-admin .badge.info { + background: var(--blue-100); + color: var(--blue-800); +} + +/* Config Actions */ +.productbus-admin .config-actions { + display: flex; + gap: var(--spacing-s); + justify-content: flex-end; + margin-top: var(--spacing-m); +} + +/* Empty State */ +.productbus-admin .empty-state { + padding: var(--spacing-xxl); + text-align: center; +} + +/* stylelint-disable-next-line no-descending-specificity */ +.productbus-admin .empty-state h3 { + margin: 0 0 var(--spacing-s) 0; + color: var(--color-text); +} + +.productbus-admin .empty-state p { + margin: 0 0 var(--spacing-l) 0; + color: var(--color-font-grey); +} + +/* Danger Button */ +.productbus-admin .button.danger { + color: var(--red-700); + border-color: var(--red-700); +} + +.productbus-admin .button.danger:hover { + background: var(--red-700); + color: white; + border-color: var(--red-700); +} + +/* Responsive */ +@media (width < 600px) { + .productbus-admin #app-container { + grid-template-columns: 1fr; + } + + .productbus-admin #sidebar { + position: fixed; + left: -280px; + top: 0; + bottom: 0; + width: 260px; + z-index: 1000; + background: var(--color-background); + box-shadow: 2px 0 8px rgb(0 0 0 / 10%); + transition: left 0.3s ease; + border-radius: 0; + border: none; + border-right: 1px solid var(--color-border); + overflow-y: auto; + } + + .productbus-admin #sidebar.open { + left: 0; + } + + .productbus-admin .form-row, + dialog.productbus-modal .form-row { + grid-template-columns: 1fr; + } + + .productbus-admin .page-actions { + flex-direction: column; + align-items: stretch; + } + + .productbus-admin .page-actions .search-input { + max-width: none; + } +} diff --git a/tools/productbus-admin/productbus-admin.js b/tools/productbus-admin/productbus-admin.js new file mode 100644 index 00000000..5d5af003 --- /dev/null +++ b/tools/productbus-admin/productbus-admin.js @@ -0,0 +1,240 @@ +/** + * ProductBus Admin - Main entry + * Handles routing, sidebar, and auth guard + */ + +import { registerToolReady } from '../../scripts/scripts.js'; +import { getAuthState, clearAuthState, apiFetch } from './api.js'; + +// ============================================================================ +// Query Params +// ============================================================================ + +function getParams() { + const p = new URLSearchParams(window.location.search); + return { + page: p.get('page') || '', + org: p.get('org') || '', + site: p.get('site') || '', + redirect: p.get('redirect') || '', + }; +} + +function setParams(updates) { + const p = new URLSearchParams(window.location.search); + Object.entries(updates).forEach(([k, v]) => { + if (v) p.set(k, v); + else p.delete(k); + }); + window.history.pushState({}, '', `${window.location.pathname}?${p.toString()}`); +} + +// ============================================================================ +// Page modules map (dynamic imports to avoid cycles) +// ============================================================================ + +const PAGE_MODULES = { + login: () => import('./login.js'), + orders: () => import('./orders.js'), + customers: () => import('./customers.js'), + indices: () => import('./indices.js'), + catalog: () => import('./catalog.js'), + config: () => import('./config.js'), + admins: () => import('./admins.js'), +}; + +// ============================================================================ +// Sidebar +// ============================================================================ + +// Forward-declared so renderSidebar and bindConnectForm can reference renderApp +let renderApp; + +function bindConnectForm() { + const form = document.getElementById('connect-form'); + if (!form) return; + form.addEventListener('submit', (e) => { + e.preventDefault(); + const org = form.querySelector('#connect-org').value.trim(); + const site = form.querySelector('#connect-site').value.trim(); + if (!org || !site) return; + + setParams({ org, site }); + + const auth = getAuthState(org, site); + if (!auth) { + setParams({ page: 'login', org, site }); + } else { + const params = getParams(); + if (!params.page || params.page === 'login') { + setParams({ page: 'orders' }); + } + } + renderApp(); + }); +} + +function renderSidebar(org, site, currentPage) { + const sidebar = document.getElementById('sidebar'); + const auth = getAuthState(org, site); + + const connectForm = ` + + `; + + if (!auth || !org || !site) { + sidebar.innerHTML = connectForm; + bindConnectForm(); + return; + } + + const navLinks = [ + { id: 'orders', label: 'Orders' }, + { id: 'customers', label: 'Customers' }, + { id: 'indices', label: 'Indices' }, + { id: 'catalog', label: 'Catalog' }, + { id: 'config', label: 'Config' }, + ]; + + if (auth.roles && auth.roles.includes('superuser')) { + navLinks.push({ id: 'admins', label: 'Admins' }); + } + + sidebar.innerHTML = ` + ${connectForm} + + + `; + + bindConnectForm(); + + sidebar.querySelectorAll('a[data-page]').forEach((link) => { + link.addEventListener('click', (e) => { + e.preventDefault(); + setParams({ page: link.dataset.page }); + renderApp(); + }); + }); + + sidebar.querySelector('#logout-btn').addEventListener('click', async () => { + try { + await apiFetch(org, site, 'auth/logout', { method: 'POST' }); + } catch (e) { + // ignore + } + clearAuthState(org, site); + setParams({ page: 'login', redirect: '' }); + renderApp(); + }); +} + +// ============================================================================ +// Router +// ============================================================================ + +let currentModule = null; + +async function renderPage(page, org, site) { + const mainContent = document.getElementById('main-content'); + + if (currentModule && currentModule.destroy) { + currentModule.destroy(); + } + + const loader = PAGE_MODULES[page]; + if (!loader) { + mainContent.innerHTML = '

Page not found

'; + return; + } + + try { + currentModule = await loader(); + await currentModule.render(mainContent, { org, site }); + } catch (err) { + mainContent.innerHTML = `

Failed to load page: ${err.message}

`; + } +} + +// ============================================================================ +// Main render +// ============================================================================ + +renderApp = function renderAppFn() { + const { page, org, site } = getParams(); + + if (!org || !site) { + renderSidebar('', '', ''); + document.getElementById('main-content').innerHTML = ` +
+

Welcome to ProductBus Admin

+

Enter your organization and site in the sidebar to get started

+
+ `; + return; + } + + const auth = getAuthState(org, site); + + if (!auth && page !== 'login') { + const currentUrl = window.location.href; + setParams({ page: 'login', redirect: currentUrl }); + renderSidebar(org, site, 'login'); + renderPage('login', org, site); + return; + } + + const activePage = page || (auth ? 'orders' : 'login'); + if (!page) { + setParams({ page: activePage }); + } + + renderSidebar(org, site, activePage); + renderPage(activePage, org, site); +}; + +// ============================================================================ +// Init +// ============================================================================ + +async function init() { + const container = document.getElementById('app-container'); + + const sidebar = document.createElement('nav'); + sidebar.id = 'sidebar'; + + const mainContent = document.createElement('div'); + mainContent.id = 'main-content'; + + container.append(sidebar, mainContent); + + renderApp(); + window.addEventListener('popstate', () => renderApp()); +} + +registerToolReady(init()); diff --git a/tools/productbus-admin/ui.js b/tools/productbus-admin/ui.js new file mode 100644 index 00000000..54b5a8ae --- /dev/null +++ b/tools/productbus-admin/ui.js @@ -0,0 +1,101 @@ +/** + * ProductBus Admin - Shared UI utilities + * Separated to avoid circular imports between api.js and page modules + */ + +export function showToast(message, type = 'success') { + const existing = document.querySelector('.productbus-toast'); + if (existing) existing.remove(); + + const toast = document.createElement('div'); + toast.classList.add('productbus-toast', type); + toast.textContent = message; + document.body.appendChild(toast); + + setTimeout(() => toast.classList.add('show'), 10); + setTimeout(() => { + toast.classList.remove('show'); + setTimeout(() => toast.remove(), 300); + }, 3000); +} + +export function createModal(title, content, footer) { + const dialog = document.createElement('dialog'); + dialog.className = 'productbus-modal'; + + const titleEl = document.createElement('h3'); + titleEl.textContent = title; + + dialog.innerHTML = ` + + `; + + const modalBody = dialog.querySelector('.modal-body'); + if (typeof content === 'string') { + modalBody.innerHTML = content; + } else { + modalBody.appendChild(content); + } + + if (footer) { + const modalFooter = dialog.querySelector('.modal-footer'); + if (typeof footer === 'string') { + modalFooter.innerHTML = footer; + } else { + modalFooter.appendChild(footer); + } + } + + const closeModal = () => { + dialog.close(); + dialog.remove(); + }; + + dialog.addEventListener('cancel', closeModal); + dialog.querySelector('.modal-close').addEventListener('click', closeModal); + dialog.addEventListener('click', (e) => { + const rect = dialog.getBoundingClientRect(); + if (e.clientX < rect.left || e.clientX > rect.right + || e.clientY < rect.top || e.clientY > rect.bottom) { + closeModal(); + } + }); + + document.body.appendChild(dialog); + dialog.showModal(); + return dialog; +} + +export function createFormField(name, label, type = 'text', options = {}) { + const { + required = false, placeholder = '', value = '', disabled = false, maxLength = '', + } = options; + + const field = document.createElement('div'); + field.className = 'form-field'; + + const labelEl = document.createElement('label'); + labelEl.textContent = label; + labelEl.setAttribute('for', name); + + const input = document.createElement(type === 'textarea' ? 'textarea' : 'input'); + input.id = name; + input.name = name; + if (type !== 'textarea') input.type = type; + if (required) input.required = true; + if (placeholder) input.placeholder = placeholder; + if (value) input.value = value; + if (disabled) input.disabled = true; + if (maxLength) input.maxLength = maxLength; + + field.appendChild(labelEl); + field.appendChild(input); + return field; +} From 0b4b5074d4dcb4281071a2189f209a5442716dfa Mon Sep 17 00:00:00 2001 From: Max Edell Date: Thu, 16 Apr 2026 16:44:43 -0700 Subject: [PATCH 02/13] feat: add productbus admin tool --- tools/productbus-admin/admins.js | 21 +- tools/productbus-admin/catalog.js | 25 +- tools/productbus-admin/customers.js | 25 +- tools/productbus-admin/indices.js | 21 +- tools/productbus-admin/journals.js | 391 ++++++++++++++++++++ tools/productbus-admin/orders.js | 25 +- tools/productbus-admin/productbus-admin.css | 211 +++++++++++ tools/productbus-admin/productbus-admin.js | 16 +- tools/productbus-admin/service-tokens.js | 190 ++++++++++ tools/productbus-admin/ui.js | 18 + 10 files changed, 906 insertions(+), 37 deletions(-) create mode 100644 tools/productbus-admin/journals.js create mode 100644 tools/productbus-admin/service-tokens.js diff --git a/tools/productbus-admin/admins.js b/tools/productbus-admin/admins.js index a304ade8..0d001a1c 100644 --- a/tools/productbus-admin/admins.js +++ b/tools/productbus-admin/admins.js @@ -3,7 +3,9 @@ */ import { apiFetch } from './api.js'; -import { showToast, createModal } from './ui.js'; +import { + showToast, createModal, getUrlParam, setUrlParam, +} from './ui.js'; function renderTable(container, admins, ctx) { const tableWrap = container.querySelector('#admins-table'); @@ -107,12 +109,13 @@ function openAddModal(ctx, onAdded) { } export async function render(container, ctx) { + const initialQ = getUrlParam('q'); container.innerHTML = `
- +
@@ -120,17 +123,23 @@ export async function render(container, ctx) {
`; + function filterAdmins(admins, q) { + if (!q) return admins; + const needle = q.toLowerCase(); + return admins.filter((a) => a.email.toLowerCase().includes(needle)); + } + try { const resp = await apiFetch(ctx.org, ctx.site, 'auth/admins', { method: 'GET' }); const data = await resp.json(); const admins = data.admins || data || []; - renderTable(container, admins, ctx); + renderTable(container, filterAdmins(admins, initialQ), ctx); container.querySelector('#search-admins').addEventListener('input', (e) => { - const q = e.target.value.toLowerCase(); - const filtered = admins.filter((a) => a.email.toLowerCase().includes(q)); - renderTable(container, filtered, ctx); + const q = e.target.value; + setUrlParam('q', q); + renderTable(container, filterAdmins(admins, q), ctx); }); container.querySelector('#add-admin-btn').addEventListener('click', () => { diff --git a/tools/productbus-admin/catalog.js b/tools/productbus-admin/catalog.js index fd6e4649..3c60b190 100644 --- a/tools/productbus-admin/catalog.js +++ b/tools/productbus-admin/catalog.js @@ -3,7 +3,9 @@ */ import { apiFetch } from './api.js'; -import { showToast, createModal, createFormField } from './ui.js'; +import { + showToast, createModal, createFormField, getUrlParam, setUrlParam, +} from './ui.js'; const AVAILABILITY_OPTIONS = [ 'BackOrder', 'Discontinued', 'InStock', 'InStoreOnly', @@ -285,12 +287,13 @@ function openProductModal(ctx, existing, onSaved) { } export async function render(container, ctx) { + const initialQ = getUrlParam('q'); container.innerHTML = `
- +
@@ -298,19 +301,25 @@ export async function render(container, ctx) {
`; + function filterProducts(products, q) { + if (!q) return products; + const needle = q.toLowerCase(); + return products.filter((p) => (p.sku || '').toLowerCase().includes(needle) + || (p.name || '').toLowerCase().includes(needle) + || (p.path || '').toLowerCase().includes(needle)); + } + try { const resp = await apiFetch(ctx.org, ctx.site, 'catalog', { method: 'GET' }); const data = await resp.json(); const products = data.products || []; - renderTable(container, products, ctx); + renderTable(container, filterProducts(products, initialQ), ctx); container.querySelector('#search-catalog').addEventListener('input', (e) => { - const q = e.target.value.toLowerCase(); - const filtered = products.filter((p) => (p.sku || '').toLowerCase().includes(q) - || (p.name || '').toLowerCase().includes(q) - || (p.path || '').toLowerCase().includes(q)); - renderTable(container, filtered, ctx); + const q = e.target.value; + setUrlParam('q', q); + renderTable(container, filterProducts(products, q), ctx); }); container.querySelector('#add-product-btn').addEventListener('click', () => { diff --git a/tools/productbus-admin/customers.js b/tools/productbus-admin/customers.js index 801f0104..62630ea9 100644 --- a/tools/productbus-admin/customers.js +++ b/tools/productbus-admin/customers.js @@ -3,7 +3,9 @@ */ import { apiFetch } from './api.js'; -import { showToast, createModal, createFormField } from './ui.js'; +import { + showToast, createModal, createFormField, getUrlParam, setUrlParam, +} from './ui.js'; function renderTable(container, customers, ctx) { const tableWrap = container.querySelector('#customers-table'); @@ -154,12 +156,13 @@ function openCreateModal(ctx, onCreated) { } export async function render(container, ctx) { + const initialQ = getUrlParam('q'); container.innerHTML = `
- +
@@ -167,19 +170,25 @@ export async function render(container, ctx) {
`; + function filterCustomers(customers, q) { + if (!q) return customers; + const needle = q.toLowerCase(); + return customers.filter((c) => c.email.toLowerCase().includes(needle) + || (c.firstName || '').toLowerCase().includes(needle) + || (c.lastName || '').toLowerCase().includes(needle)); + } + try { const resp = await apiFetch(ctx.org, ctx.site, 'customers', { method: 'GET' }); const data = await resp.json(); const customers = data.customers || data || []; - renderTable(container, customers, ctx); + renderTable(container, filterCustomers(customers, initialQ), ctx); container.querySelector('#search-customers').addEventListener('input', (e) => { - const q = e.target.value.toLowerCase(); - const filtered = customers.filter((c) => c.email.toLowerCase().includes(q) - || (c.firstName || '').toLowerCase().includes(q) - || (c.lastName || '').toLowerCase().includes(q)); - renderTable(container, filtered, ctx); + const q = e.target.value; + setUrlParam('q', q); + renderTable(container, filterCustomers(customers, q), ctx); }); container.querySelector('#add-customer-btn').addEventListener('click', () => { diff --git a/tools/productbus-admin/indices.js b/tools/productbus-admin/indices.js index f09a04ac..0b526366 100644 --- a/tools/productbus-admin/indices.js +++ b/tools/productbus-admin/indices.js @@ -3,7 +3,9 @@ */ import { apiFetch } from './api.js'; -import { showToast, createModal, createFormField } from './ui.js'; +import { + showToast, createModal, createFormField, getUrlParam, setUrlParam, +} from './ui.js'; const DIR_PATH_PATTERN = /^\/[a-z0-9-/]+$/; @@ -119,12 +121,13 @@ function openCreateModal(ctx, onCreated) { } export async function render(container, ctx) { + const initialQ = getUrlParam('q'); container.innerHTML = `
- +
@@ -132,17 +135,23 @@ export async function render(container, ctx) {
`; + function filterIndices(indices, q) { + if (!q) return indices; + const needle = q.toLowerCase(); + return indices.filter((idx) => idx.path.toLowerCase().includes(needle)); + } + try { const resp = await apiFetch(ctx.org, ctx.site, 'index', { method: 'GET' }); const data = await resp.json(); const indices = data.indices || []; - renderTable(container, indices, ctx); + renderTable(container, filterIndices(indices, initialQ), ctx); container.querySelector('#search-indices').addEventListener('input', (e) => { - const q = e.target.value.toLowerCase(); - const filtered = indices.filter((idx) => idx.path.toLowerCase().includes(q)); - renderTable(container, filtered, ctx); + const q = e.target.value; + setUrlParam('q', q); + renderTable(container, filterIndices(indices, q), ctx); }); container.querySelector('#add-index-btn').addEventListener('click', () => { diff --git a/tools/productbus-admin/journals.js b/tools/productbus-admin/journals.js new file mode 100644 index 00000000..2661e07e --- /dev/null +++ b/tools/productbus-admin/journals.js @@ -0,0 +1,391 @@ +/** + * ProductBus Admin - Journals viewer (admin only) + */ + +import { apiFetch } from './api.js'; +import { showToast } from './ui.js'; + +const FILTER_DEBOUNCE_MS = 150; +const MAX_RANGE_MS = 12 * 60 * 60 * 1000; +const RANGE_ADJUST_MS = 15 * 60 * 1000; + +function escapeHtml(str) { + if (str === null || str === undefined) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function toDatetimeLocal(date) { + const pad = (n) => String(n).padStart(2, '0'); + const y = date.getFullYear(); + const mo = pad(date.getMonth() + 1); + const d = pad(date.getDate()); + const h = pad(date.getHours()); + const mi = pad(date.getMinutes()); + return `${y}-${mo}-${d}T${h}:${mi}`; +} + +function fromDatetimeLocal(value) { + if (!value) return null; + const d = new Date(value); + if (Number.isNaN(d.getTime())) return null; + return d.toISOString(); +} + +/** + * Parse a timestamp string. Accepts any format the Date constructor handles, + * plus partial ISO-like timestamps with dash or colon time separators + * (matching the filesystem-safe form used in journal buckets and order IDs): + * 2026-04-02T17-49-29.869Z + * 2026-04-02T17-49-29 + * 2026-04-02T17 + * 2026-04-02 + * Missing time components default to 0. Missing Z is treated as UTC. + */ +function parseFlexibleDate(input) { + const str = input.trim(); + let d = new Date(str); + if (!Number.isNaN(d.getTime())) return d; + const m = str.match(/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2})(?:[-:](\d{2})(?:[-:](\d{2})(\.\d+)?)?)?)?(Z)?$/); + if (m) { + const [, y, mo, dy, h = '00', mi = '00', s = '00', ms = '', z = ''] = m; + const iso = `${y}-${mo}-${dy}T${h}:${mi}:${s}${ms}${z || 'Z'}`; + d = new Date(iso); + if (!Number.isNaN(d.getTime())) return d; + } + return null; +} + +function entryKey(entry) { + if (entry.id) return entry.id; + // Stable fallback: timestamp + event + any known identifier field. + return `${entry.timestamp || ''}|${entry.event || ''}|${entry.orderId || entry.entityId || ''}`; +} + +function summarizeEntry(entry) { + const ts = entry.timestamp ? new Date(entry.timestamp).toISOString().replace('T', ' ').replace('Z', '') : '—'; + const parts = []; + if (entry.event) parts.push(entry.event); + if (entry.orderId) parts.push(`order:${entry.orderId}`); + if (entry.type && entry.entityId) parts.push(`${entry.type}:${entry.entityId}`); + else if (entry.type) parts.push(entry.type); + if (entry.state) parts.push(`state:${entry.state}`); + if (entry.actor) parts.push(`by ${entry.actor}`); + return { ts, detail: parts.join(' · ') }; +} + +async function copyText(text, successMsg) { + try { + await navigator.clipboard.writeText(text); + showToast(successMsg); + } catch (err) { + showToast(`Copy failed: ${err.message}`, 'error'); + } +} + +export async function render(container, ctx) { + const urlParams = new URLSearchParams(window.location.search); + const initJournal = urlParams.get('journal') === 'orders' ? 'orders' : 'general'; + + function parseIsoParam(key, fallbackMs) { + const raw = urlParams.get(key); + if (raw) { + const d = new Date(raw); + if (!Number.isNaN(d.getTime())) return d.toISOString(); + } + return new Date(fallbackMs).toISOString(); + } + + const state = { + journal: initJournal, + orderId: urlParams.get('orderId') || '', + filter: urlParams.get('filter') || '', + sinceIso: parseIsoParam('since', Date.now() - 15 * 60 * 1000), + untilIso: parseIsoParam('until', Date.now()), + allEntries: [], + expanded: new Set(), + loading: false, + }; + + function syncUrl() { + const url = new URL(window.location.href); + url.searchParams.set('journal', state.journal); + url.searchParams.set('since', state.sinceIso); + url.searchParams.set('until', state.untilIso); + if (state.orderId) url.searchParams.set('orderId', state.orderId); + else url.searchParams.delete('orderId'); + if (state.filter) url.searchParams.set('filter', state.filter); + else url.searchParams.delete('filter'); + window.history.replaceState({}, '', url); + } + + container.innerHTML = ` + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ `; + + syncUrl(); + + const typeSwitcher = container.querySelector('#journal-type'); + const sinceInput = container.querySelector('#journals-since'); + const untilInput = container.querySelector('#journals-until'); + const orderIdWrap = container.querySelector('.journals-orderid'); + const orderIdInput = container.querySelector('#journals-orderid'); + const filterInput = container.querySelector('#journals-filter'); + const refreshBtn = container.querySelector('#journals-refresh'); + const copyAllBtn = container.querySelector('#journals-copy-all'); + const listEl = container.querySelector('#journals-list'); + const statusEl = container.querySelector('#journals-status'); + + function getFilteredEntries() { + if (!state.filter) return state.allEntries; + const needle = state.filter.toLowerCase(); + return state.allEntries.filter((e) => JSON.stringify(e).toLowerCase().includes(needle)); + } + + function renderList() { + const visible = getFilteredEntries(); + const total = state.allEntries.length; + + if (state.loading) { + listEl.innerHTML = '

Loading entries…

'; + statusEl.textContent = ''; + return; + } + + if (visible.length === 0) { + listEl.innerHTML = ` +
+

No entries

+

${total === 0 ? 'No journal entries in this time range.' : 'No entries match your filter.'}

+
+ `; + } else { + listEl.innerHTML = visible.map((entry) => { + const { ts, detail } = summarizeEntry(entry); + const key = entryKey(entry); + const isExpanded = state.expanded.has(key); + const pretty = JSON.stringify(entry, null, 2); + return ` +
+
+ ${escapeHtml(ts)} + ${escapeHtml(detail)} + +
+
+
${escapeHtml(pretty)}
+
+
+ `; + }).join(''); + } + + statusEl.textContent = `${visible.length} of ${total} entries · ${new Date(state.sinceIso).toISOString()} → ${new Date(state.untilIso).toISOString()}`; + } + + async function fetchEntries() { + state.loading = true; + state.expanded.clear(); + renderList(); + + const params = new URLSearchParams(); + params.set('since', state.sinceIso); + params.set('until', state.untilIso); + if (state.journal === 'orders' && state.orderId) { + params.set('orderId', state.orderId); + } + const path = state.journal === 'orders' ? `orders/journal?${params}` : `journal?${params}`; + + try { + const resp = await apiFetch(ctx.org, ctx.site, path, { method: 'GET' }); + if (!resp.ok) { + const errMsg = resp.headers.get('x-error') || `HTTP ${resp.status}`; + if (resp.status === 404 && state.journal === 'orders' && state.orderId) { + showToast('Order not found', 'error'); + state.allEntries = []; + } else { + showToast(errMsg, 'error'); + state.allEntries = []; + } + } else { + const data = await resp.json(); + const entries = Array.isArray(data.entries) ? data.entries : []; + entries.sort((a, b) => { + const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0; + const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0; + return tb - ta; + }); + state.allEntries = entries; + } + } catch (err) { + showToast(`Failed to load journal: ${err.message}`, 'error'); + state.allEntries = []; + } finally { + state.loading = false; + renderList(); + } + } + + function updateOrderIdVisibility() { + orderIdWrap.hidden = state.journal !== 'orders'; + } + + typeSwitcher.addEventListener('click', (e) => { + const btn = e.target.closest('button[data-journal]'); + if (!btn) return; + const next = btn.dataset.journal; + if (next === state.journal) return; + state.journal = next; + typeSwitcher.querySelectorAll('button').forEach((b) => { + b.classList.toggle('active', b.dataset.journal === next); + }); + updateOrderIdVisibility(); + syncUrl(); + fetchEntries(); + }); + + function bindPasteToDatetimeLocal(input, kind) { + input.addEventListener('paste', (e) => { + const text = (e.clipboardData || window.clipboardData)?.getData('text'); + if (!text) return; + const d = parseFlexibleDate(text); + if (!d) return; + e.preventDefault(); + + input.value = toDatetimeLocal(d); + if (kind === 'since') state.sinceIso = d.toISOString(); + else state.untilIso = d.toISOString(); + + const sinceMs = new Date(state.sinceIso).getTime(); + const untilMs = new Date(state.untilIso).getTime(); + const diff = untilMs - sinceMs; + if (diff > MAX_RANGE_MS || diff < 0) { + if (kind === 'since') { + const newUntil = new Date(d.getTime() + RANGE_ADJUST_MS); + state.untilIso = newUntil.toISOString(); + untilInput.value = toDatetimeLocal(newUntil); + } else { + const newSince = new Date(d.getTime() - RANGE_ADJUST_MS); + state.sinceIso = newSince.toISOString(); + sinceInput.value = toDatetimeLocal(newSince); + } + } + syncUrl(); + }); + } + + sinceInput.addEventListener('change', () => { + const iso = fromDatetimeLocal(sinceInput.value); + if (iso) { + state.sinceIso = iso; + syncUrl(); + } + }); + untilInput.addEventListener('change', () => { + const iso = fromDatetimeLocal(untilInput.value); + if (iso) { + state.untilIso = iso; + syncUrl(); + } + }); + bindPasteToDatetimeLocal(sinceInput, 'since'); + bindPasteToDatetimeLocal(untilInput, 'until'); + + orderIdInput.addEventListener('change', () => { + state.orderId = orderIdInput.value.trim(); + syncUrl(); + }); + orderIdInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + state.orderId = orderIdInput.value.trim(); + syncUrl(); + fetchEntries(); + } + }); + + let filterTimer = null; + filterInput.addEventListener('input', () => { + clearTimeout(filterTimer); + filterTimer = setTimeout(() => { + state.filter = filterInput.value; + syncUrl(); + renderList(); + }, FILTER_DEBOUNCE_MS); + }); + + refreshBtn.addEventListener('click', () => { + const sinceIso = fromDatetimeLocal(sinceInput.value); + const untilIso = fromDatetimeLocal(untilInput.value); + if (sinceIso) state.sinceIso = sinceIso; + if (untilIso) state.untilIso = untilIso; + state.orderId = orderIdInput.value.trim(); + syncUrl(); + fetchEntries(); + }); + + copyAllBtn.addEventListener('click', () => { + const visible = getFilteredEntries(); + if (visible.length === 0) { + showToast('No entries to copy', 'error'); + return; + } + copyText(JSON.stringify(visible, null, 2), `Copied ${visible.length} entries`); + }); + + listEl.addEventListener('click', (e) => { + const copyBtn = e.target.closest('[data-action="copy"]'); + const entryEl = e.target.closest('.journals-entry'); + if (!entryEl) return; + const { key } = entryEl.dataset; + const entry = state.allEntries.find((en) => entryKey(en) === key); + if (copyBtn) { + e.stopPropagation(); + if (entry) copyText(JSON.stringify(entry, null, 2), 'Entry copied'); + return; + } + if (state.expanded.has(key)) state.expanded.delete(key); + else state.expanded.add(key); + entryEl.classList.toggle('expanded'); + }); + + updateOrderIdVisibility(); + fetchEntries(); +} + +export function destroy() {} diff --git a/tools/productbus-admin/orders.js b/tools/productbus-admin/orders.js index 8b8c6afd..ee95b35a 100644 --- a/tools/productbus-admin/orders.js +++ b/tools/productbus-admin/orders.js @@ -3,7 +3,9 @@ */ import { apiFetch } from './api.js'; -import { showToast, createModal, createFormField } from './ui.js'; +import { + showToast, createModal, createFormField, getUrlParam, setUrlParam, +} from './ui.js'; function renderTable(container, orders, ctx) { const tableWrap = container.querySelector('#orders-table'); @@ -236,12 +238,13 @@ function openCreateModal(ctx, onCreated) { } export async function render(container, ctx) { + const initialQ = getUrlParam('q'); container.innerHTML = `
- +
@@ -249,19 +252,25 @@ export async function render(container, ctx) {
`; + function filterOrders(orders, q) { + if (!q) return orders; + const needle = q.toLowerCase(); + return orders.filter((o) => (o.id || '').toLowerCase().includes(needle) + || (o.customer?.email || '').toLowerCase().includes(needle) + || (o.state || '').toLowerCase().includes(needle)); + } + try { const resp = await apiFetch(ctx.org, ctx.site, 'orders', { method: 'GET' }); const data = await resp.json(); const orders = data.orders || data || []; - renderTable(container, orders, ctx); + renderTable(container, filterOrders(orders, initialQ), ctx); container.querySelector('#search-orders').addEventListener('input', (e) => { - const q = e.target.value.toLowerCase(); - const filtered = orders.filter((o) => (o.id || '').toLowerCase().includes(q) - || (o.customer?.email || '').toLowerCase().includes(q) - || (o.state || '').toLowerCase().includes(q)); - renderTable(container, filtered, ctx); + const q = e.target.value; + setUrlParam('q', q); + renderTable(container, filterOrders(orders, q), ctx); }); container.querySelector('#add-order-btn').addEventListener('click', () => { diff --git a/tools/productbus-admin/productbus-admin.css b/tools/productbus-admin/productbus-admin.css index b616a56f..6ba254de 100644 --- a/tools/productbus-admin/productbus-admin.css +++ b/tools/productbus-admin/productbus-admin.css @@ -28,6 +28,11 @@ display: flex; flex-direction: column; gap: var(--spacing-m); + position: sticky; + top: var(--spacing-m); + align-self: start; + height: calc(100vh - 5 * var(--spacing-m)); + max-height: calc(100vh - 5 * var(--spacing-m)); } .productbus-admin .sidebar-connect { @@ -61,6 +66,8 @@ flex-direction: column; gap: var(--spacing-xs); flex: 1; + min-height: 0; + overflow-y: auto; } .productbus-admin .sidebar-nav a { @@ -473,6 +480,7 @@ dialog.productbus-modal .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-m); + align-items: flex-end; } dialog.productbus-modal h4, @@ -572,6 +580,194 @@ dialog.productbus-modal h4, border-color: var(--red-700); } +/* Journals View */ +.productbus-admin .journals-controls { + display: grid; + grid-template-columns: auto 1fr 1fr auto; + gap: var(--spacing-m); + align-items: end; + margin-bottom: var(--spacing-l); +} + +.productbus-admin .journals-controls .view-switcher { + margin-bottom: 0; + align-self: end; +} + +.productbus-admin .journals-controls .form-field { + margin: 0; +} + +.productbus-admin .journals-controls .journals-orderid, +.productbus-admin .journals-controls .journals-filter { + grid-column: 1 / -1; +} + +.productbus-admin .journals-actions { + display: flex; + gap: var(--spacing-s); + justify-content: flex-end; +} + +.productbus-admin .journals-entry { + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-background); + margin-bottom: var(--spacing-xs); + transition: background-color 0.15s ease; +} + +.productbus-admin .journals-entry:hover { + background: light-dark(var(--gray-50), var(--gray-800)); +} + +.productbus-admin .journals-entry-summary { + display: flex; + align-items: center; + gap: var(--spacing-m); + padding: var(--spacing-s) var(--spacing-m); + cursor: pointer; + min-width: 0; +} + +.productbus-admin .journals-entry-ts { + font-family: var(--code-font-family); + font-size: var(--body-size-s); + color: var(--color-font-grey); + white-space: nowrap; + flex: 0 0 auto; +} + +.productbus-admin .journals-entry-detail { + font-family: var(--code-font-family); + font-size: var(--body-size-s); + color: var(--color-text); + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.productbus-admin .journals-entry .journals-copy-btn { + flex: 0 0 auto; +} + +.productbus-admin .journals-entry-details { + display: none; + padding: 0 var(--spacing-m) var(--spacing-m); +} + +.productbus-admin .journals-entry.expanded .journals-entry-details { + display: block; +} + +.productbus-admin .journals-entry-details .json-display { + margin: 0; + max-height: 500px; + overflow: auto; +} + +.productbus-admin .journals-status { + margin-top: var(--spacing-m); + font-size: var(--body-size-s); + color: var(--color-font-grey); +} + +/* Service Tokens View */ +.productbus-admin .service-token-form { + display: flex; + flex-direction: column; + gap: var(--spacing-m); + max-width: 700px; +} + +.productbus-admin .service-token-form h4 { + margin: var(--spacing-s) 0 0; + font-size: var(--body-size-l); + border-bottom: 1px solid var(--color-border); + padding-bottom: var(--spacing-xs); +} + +.productbus-admin .permissions-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-xs) var(--spacing-m); +} + +.productbus-admin .permission-item { + display: flex; + align-items: center; + gap: var(--spacing-xs); + padding: var(--spacing-xs); + border-radius: 4px; + cursor: pointer; +} + +.productbus-admin .permission-item:hover { + background: light-dark(var(--gray-50), var(--gray-800)); +} + +.productbus-admin .permission-item input[type="checkbox"] { + width: auto; + margin: 0; +} + +.productbus-admin .field-hint-inline { + font-weight: 400; + color: var(--color-font-grey); + font-size: var(--body-size-s); +} + +.productbus-admin .token-result { + max-width: 800px; + display: flex; + flex-direction: column; + gap: var(--spacing-m); +} + +.productbus-admin .token-warning { + padding: var(--spacing-m); + border: 1px solid light-dark(var(--orange-400, #f59e0b), var(--orange-600, #d97706)); + background: light-dark(var(--orange-100, #fef3c7), rgb(245 158 11 / 15%)); + color: light-dark(var(--orange-800, #92400e), var(--orange-200, #fde68a)); + border-radius: 6px; + font-weight: 600; +} + +.productbus-admin .token-box { + display: flex; + align-items: flex-start; + gap: var(--spacing-s); + padding: var(--spacing-m); + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--layer-elevated); +} + +.productbus-admin .token-box code { + flex: 1; + font-family: var(--code-font-family); + font-size: var(--body-size-s); + word-break: break-all; + color: var(--color-text); +} + +.productbus-admin .token-meta { + font-size: var(--body-size-s); + color: var(--color-text); +} + +.productbus-admin .token-permissions { + margin: 0; + padding-left: var(--spacing-l); + font-size: var(--body-size-s); +} + +.productbus-admin .token-permissions code { + font-family: var(--code-font-family); +} + /* Responsive */ @media (width < 600px) { .productbus-admin #app-container { @@ -584,6 +780,9 @@ dialog.productbus-modal h4, top: 0; bottom: 0; width: 260px; + height: auto; + max-height: none; + align-self: auto; z-index: 1000; background: var(--color-background); box-shadow: 2px 0 8px rgb(0 0 0 / 10%); @@ -611,4 +810,16 @@ dialog.productbus-modal h4, .productbus-admin .page-actions .search-input { max-width: none; } + + .productbus-admin .journals-controls { + grid-template-columns: 1fr; + } + + .productbus-admin .journals-actions { + justify-content: stretch; + } + + .productbus-admin .permissions-grid { + grid-template-columns: 1fr; + } } diff --git a/tools/productbus-admin/productbus-admin.js b/tools/productbus-admin/productbus-admin.js index 5d5af003..988b3cee 100644 --- a/tools/productbus-admin/productbus-admin.js +++ b/tools/productbus-admin/productbus-admin.js @@ -40,6 +40,8 @@ const PAGE_MODULES = { indices: () => import('./indices.js'), catalog: () => import('./catalog.js'), config: () => import('./config.js'), + journals: () => import('./journals.js'), + 'service-tokens': () => import('./service-tokens.js'), admins: () => import('./admins.js'), }; @@ -109,6 +111,12 @@ function renderSidebar(org, site, currentPage) { { id: 'config', label: 'Config' }, ]; + const isAdmin = auth.roles && (auth.roles.includes('admin') || auth.roles.includes('superuser')); + if (isAdmin) { + navLinks.push({ id: 'journals', label: 'Journals' }); + navLinks.push({ id: 'service-tokens', label: 'Service Tokens' }); + } + if (auth.roles && auth.roles.includes('superuser')) { navLinks.push({ id: 'admins', label: 'Admins' }); } @@ -137,7 +145,13 @@ function renderSidebar(org, site, currentPage) { sidebar.querySelectorAll('a[data-page]').forEach((link) => { link.addEventListener('click', (e) => { e.preventDefault(); - setParams({ page: link.dataset.page }); + // Switching pages wipes view-specific query params so shared URLs only + // carry the state the target view itself writes back in. + const p = new URLSearchParams(); + if (org) p.set('org', org); + if (site) p.set('site', site); + p.set('page', link.dataset.page); + window.history.pushState({}, '', `${window.location.pathname}?${p.toString()}`); renderApp(); }); }); diff --git a/tools/productbus-admin/service-tokens.js b/tools/productbus-admin/service-tokens.js new file mode 100644 index 00000000..1ca687c2 --- /dev/null +++ b/tools/productbus-admin/service-tokens.js @@ -0,0 +1,190 @@ +/** + * ProductBus Admin - Service Tokens (admin only) + * Backend supports create + revoke only; this view is create-only. + * Token is shown once after creation — the API never returns it again. + */ + +import { apiFetch } from './api.js'; +import { showToast } from './ui.js'; + +const ALLOWED_PERMISSIONS = [ + 'catalog:read', + 'catalog:write', + 'orders:read', + 'orders:write', + 'orders:custom:write', + 'index:read', + 'index:write', + 'customers:read', + 'customers:write', + 'emails:send', +]; + +const MAX_TTL_SECONDS = 365 * 24 * 60 * 60; + +function escapeHtml(str) { + if (str === null || str === undefined) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function humanizeSeconds(total) { + if (total % 86400 === 0) return `${total / 86400} day${total === 86400 ? '' : 's'}`; + if (total % 3600 === 0) return `${total / 3600} hour${total === 3600 ? '' : 's'}`; + if (total % 60 === 0) return `${total / 60} minute${total === 60 ? '' : 's'}`; + return `${total} second${total === 1 ? '' : 's'}`; +} + +function renderForm(container, ctx) { + container.innerHTML = ` + +
+

Permissions

+
+ ${ALLOWED_PERMISSIONS.map((p) => ` + + `).join('')} +
+ +
+ + +

Comma-separated. Each becomes an emails:send:<pattern> permission entry.

+
+ +

Time to live

+
+
+ + +
+
+ + +
+
+

Maximum 365 days.

+ +
+ +
+
+ `; + + const form = container.querySelector('#service-token-form'); + const createBtn = container.querySelector('#create-btn'); + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + + const checked = Array.from(form.querySelectorAll('input[name="permission"]:checked')).map((c) => c.value); + const scopesRaw = form.querySelector('#email-scopes').value.trim(); + const ttlValue = Number(form.querySelector('#ttl-value').value); + const ttlUnit = Number(form.querySelector('#ttl-unit').value); + const ttl = ttlValue * ttlUnit; + + const scopePatterns = scopesRaw + ? scopesRaw.split(',').map((s) => s.trim()).filter(Boolean) + : []; + + const permissions = [...checked]; + if (scopePatterns.length > 0 && !permissions.includes('emails:send')) { + permissions.push('emails:send'); + } + scopePatterns.forEach((pat) => { + permissions.push(`emails:send:${pat}`); + }); + + if (permissions.length === 0) { + showToast('Select at least one permission', 'error'); + return; + } + if (!Number.isInteger(ttl) || ttl <= 0) { + showToast('TTL must be a positive integer', 'error'); + return; + } + if (ttl > MAX_TTL_SECONDS) { + showToast('TTL exceeds maximum of 365 days', 'error'); + return; + } + + createBtn.disabled = true; + createBtn.textContent = 'Creating…'; + try { + const resp = await apiFetch(ctx.org, ctx.site, 'auth/service_token', { + method: 'POST', + body: JSON.stringify({ permissions, ttl }), + }); + if (!resp.ok) { + const errMsg = resp.headers.get('x-error') || `HTTP ${resp.status}`; + showToast(errMsg, 'error'); + createBtn.disabled = false; + createBtn.textContent = 'Create Token'; + return; + } + const data = await resp.json(); + // eslint-disable-next-line no-use-before-define + renderResult(container, ctx, { token: data.token, ttl: data.ttl, permissions }); + } catch (err) { + showToast(`Failed to create token: ${err.message}`, 'error'); + createBtn.disabled = false; + createBtn.textContent = 'Create Token'; + } + }); +} + +function renderResult(container, ctx, { token, ttl, permissions }) { + container.innerHTML = ` + +
+
⚠ Copy this token now — it won't be shown again.
+
+ ${escapeHtml(token)} + +
+
Expires in: ${escapeHtml(humanizeSeconds(ttl))}
+
Permissions:
+
    + ${permissions.map((p) => `
  • ${escapeHtml(p)}
  • `).join('')} +
+
+ +
+
+ `; + + container.querySelector('#copy-token').addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(token); + showToast('Token copied'); + } catch (err) { + showToast(`Copy failed: ${err.message}`, 'error'); + } + }); + + container.querySelector('#create-another').addEventListener('click', () => { + renderForm(container, ctx); + }); +} + +export async function render(container, ctx) { + renderForm(container, ctx); +} + +export function destroy() {} diff --git a/tools/productbus-admin/ui.js b/tools/productbus-admin/ui.js index 54b5a8ae..0235b533 100644 --- a/tools/productbus-admin/ui.js +++ b/tools/productbus-admin/ui.js @@ -73,6 +73,24 @@ export function createModal(title, content, footer) { return dialog; } +/** + * Read a single query param from window.location.search. + */ +export function getUrlParam(key) { + return new URLSearchParams(window.location.search).get(key) || ''; +} + +/** + * Set or remove a query param via replaceState so the URL reflects + * current view state without pushing history entries. + */ +export function setUrlParam(key, value) { + const url = new URL(window.location.href); + if (value) url.searchParams.set(key, value); + else url.searchParams.delete(key); + window.history.replaceState({}, '', url); +} + export function createFormField(name, label, type = 'text', options = {}) { const { required = false, placeholder = '', value = '', disabled = false, maxLength = '', From 36dac3a32a29ef3c96c0de261a428a974ab9f298 Mon Sep 17 00:00:00 2001 From: Max Edell Date: Fri, 17 Apr 2026 10:33:32 -0700 Subject: [PATCH 03/13] chore: address comments --- tools/productbus-admin/admins.js | 11 +- tools/productbus-admin/catalog.js | 16 +- tools/productbus-admin/config.js | 11 +- tools/productbus-admin/customers.js | 11 +- tools/productbus-admin/indices.js | 10 +- tools/productbus-admin/login.js | 2 +- tools/productbus-admin/orders.js | 6 +- tools/productbus-admin/productbus-admin.css | 191 ++++++++++---------- tools/productbus-admin/productbus-admin.js | 1 + tools/productbus-admin/service-tokens.js | 4 +- tools/productbus-admin/ui.js | 57 +++++- 11 files changed, 197 insertions(+), 123 deletions(-) diff --git a/tools/productbus-admin/admins.js b/tools/productbus-admin/admins.js index 0d001a1c..1943bd0a 100644 --- a/tools/productbus-admin/admins.js +++ b/tools/productbus-admin/admins.js @@ -4,7 +4,7 @@ import { apiFetch } from './api.js'; import { - showToast, createModal, getUrlParam, setUrlParam, + showToast, createModal, getUrlParam, setUrlParam, confirmModal, } from './ui.js'; function renderTable(container, admins, ctx) { @@ -49,9 +49,12 @@ function renderTable(container, admins, ctx) { tableWrap.querySelectorAll('[data-action="delete"]').forEach((btn) => { btn.addEventListener('click', async () => { const { email } = btn.dataset; - // eslint-disable-next-line no-alert - // eslint-disable-next-line no-restricted-globals, no-alert - if (!confirm(`Remove admin ${email}?`)) return; + const ok = await confirmModal(`Remove admin ${email}?`, { + title: 'Remove admin', + confirmLabel: 'Remove', + destructive: true, + }); + if (!ok) return; try { await apiFetch(ctx.org, ctx.site, `auth/admins/${encodeURIComponent(email)}`, { method: 'DELETE' }); showToast('Admin removed'); diff --git a/tools/productbus-admin/catalog.js b/tools/productbus-admin/catalog.js index 3c60b190..1dae96a3 100644 --- a/tools/productbus-admin/catalog.js +++ b/tools/productbus-admin/catalog.js @@ -4,7 +4,7 @@ import { apiFetch } from './api.js'; import { - showToast, createModal, createFormField, getUrlParam, setUrlParam, + showToast, createModal, createFormField, getUrlParam, setUrlParam, confirmModal, } from './ui.js'; const AVAILABILITY_OPTIONS = [ @@ -78,8 +78,12 @@ function renderTable(container, products, ctx) { btn.addEventListener('click', async () => { const { path } = btn.dataset; const jsonPath = path.endsWith('.json') ? path : `${path}.json`; - // eslint-disable-next-line no-restricted-globals, no-alert - if (!confirm(`Delete product at ${path}?`)) return; + const ok = await confirmModal(`Delete product at ${path}?`, { + title: 'Delete product', + confirmLabel: 'Delete', + destructive: true, + }); + if (!ok) return; try { await apiFetch(ctx.org, ctx.site, `catalog${jsonPath}`, { method: 'DELETE' }); showToast('Product deleted'); @@ -102,7 +106,7 @@ function buildFormHTML(product) { return `
-

Basic Information

+

Basic Information

${createFormField('sku', 'SKU', 'text', { required: true, value: p.sku || '' }).outerHTML} ${createFormField('name', 'Name', 'text', { required: true, value: p.name || '' }).outerHTML} @@ -141,7 +145,7 @@ function buildFormHTML(product) {
-

Price

+

Price

${createFormField('price.currency', 'Currency', 'text', { value: price.currency || 'USD' }).outerHTML} ${createFormField('price.regular', 'Regular', 'number', { value: price.regular ?? '' }).outerHTML} @@ -150,7 +154,7 @@ function buildFormHTML(product) { ${createFormField('price.final', 'Final', 'number', { value: price.final ?? '' }).outerHTML}
-

Images

+

Images

${(p.images || []).map((img, i) => `
diff --git a/tools/productbus-admin/config.js b/tools/productbus-admin/config.js index dd78c8b9..cd6c4b7e 100644 --- a/tools/productbus-admin/config.js +++ b/tools/productbus-admin/config.js @@ -3,7 +3,7 @@ */ import { apiFetch } from './api.js'; -import { showToast, createFormField } from './ui.js'; +import { showToast, createFormField, confirmModal } from './ui.js'; function buildFormHTML(config) { const c = config || {}; @@ -144,9 +144,12 @@ export async function render(container, ctx) { }); content.querySelector('#delete-config-btn').addEventListener('click', async () => { - // eslint-disable-next-line no-alert - // eslint-disable-next-line no-restricted-globals, no-alert - if (!confirm('Delete configuration? This cannot be undone.')) return; + const ok = await confirmModal('Delete configuration? This cannot be undone.', { + title: 'Delete config', + confirmLabel: 'Delete', + destructive: true, + }); + if (!ok) return; try { await apiFetch(ctx.org, ctx.site, 'config', { method: 'DELETE' }); config = {}; diff --git a/tools/productbus-admin/customers.js b/tools/productbus-admin/customers.js index 62630ea9..d0f00474 100644 --- a/tools/productbus-admin/customers.js +++ b/tools/productbus-admin/customers.js @@ -4,7 +4,7 @@ import { apiFetch } from './api.js'; import { - showToast, createModal, createFormField, getUrlParam, setUrlParam, + showToast, createModal, createFormField, getUrlParam, setUrlParam, confirmModal, } from './ui.js'; function renderTable(container, customers, ctx) { @@ -53,9 +53,12 @@ function renderTable(container, customers, ctx) { tableWrap.querySelectorAll('[data-action="delete"]').forEach((btn) => { btn.addEventListener('click', async () => { const { email } = btn.dataset; - // eslint-disable-next-line no-alert - // eslint-disable-next-line no-restricted-globals, no-alert - if (!confirm(`Delete customer ${email}? This will also remove their orders and addresses.`)) return; + const ok = await confirmModal(`Delete customer ${email}? This will also remove their orders and addresses.`, { + title: 'Delete customer', + confirmLabel: 'Delete', + destructive: true, + }); + if (!ok) return; try { await apiFetch(ctx.org, ctx.site, `customers/${encodeURIComponent(email)}`, { method: 'DELETE' }); showToast('Customer deleted'); diff --git a/tools/productbus-admin/indices.js b/tools/productbus-admin/indices.js index 0b526366..482eadfe 100644 --- a/tools/productbus-admin/indices.js +++ b/tools/productbus-admin/indices.js @@ -4,7 +4,7 @@ import { apiFetch } from './api.js'; import { - showToast, createModal, createFormField, getUrlParam, setUrlParam, + showToast, createModal, createFormField, getUrlParam, setUrlParam, confirmModal, } from './ui.js'; const DIR_PATH_PATTERN = /^\/[a-z0-9-/]+$/; @@ -49,8 +49,12 @@ function renderTable(container, indices, ctx) { tableWrap.querySelectorAll('[data-action="delete"]').forEach((btn) => { btn.addEventListener('click', async () => { const { path } = btn.dataset; - // eslint-disable-next-line no-restricted-globals, no-alert - if (!confirm(`Delete index at ${path}?`)) return; + const ok = await confirmModal(`Delete index at ${path}?`, { + title: 'Delete index', + confirmLabel: 'Delete', + destructive: true, + }); + if (!ok) return; try { await apiFetch(ctx.org, ctx.site, `index${path}`, { method: 'DELETE' }); showToast('Index deleted'); diff --git a/tools/productbus-admin/login.js b/tools/productbus-admin/login.js index e45077c8..309f6dee 100644 --- a/tools/productbus-admin/login.js +++ b/tools/productbus-admin/login.js @@ -11,7 +11,7 @@ export async function render(container, ctx) { const redirect = params.get('redirect') || ''; container.innerHTML = ` -