refactor(cloudcp): split Pulse Account service runtime

This commit is contained in:
rcourtman 2026-03-26 01:59:11 +00:00
parent 9fe66fa5c1
commit 8f65a3dca6
6 changed files with 628 additions and 527 deletions

View file

@ -1,5 +1,5 @@
{
"source_hash": "276c1c649eee16c120f9bfe29e54582f9cc50c5a9c1c1b0acc99023b4dd8ac0f",
"source_hash": "33ad099dc74b695667a57e6d0ee611f46bb9f70d4b875e5eb84d192ea0173742",
"build_inputs": [
"package.json",
"tsconfig.json",
@ -10,6 +10,8 @@
"src/shell.ts",
"src/shell_view.ts",
"src/services.ts",
"src/services_controller.ts",
"src/services_view.ts",
"src/runtime.ts",
"src/types.ts",
"src/styles.css"

View file

@ -713,6 +713,183 @@
refreshBootstrap();
}
// src/services_view.ts
function getElement3(id) {
return document.getElementById(id);
}
function asHTMLElement3(target) {
return target instanceof HTMLElement ? target : null;
}
function escapeText(value) {
return String(value || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function escapeAttribute(value) {
return escapeText(value).replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
function readValue(id) {
var el = getElement3(id);
return el ? el.value.trim() : "";
}
function focusElement(id) {
var el = getElement3(id);
if (el) el.focus();
}
function setVisible(id, visible) {
var el = getElement3(id);
if (el) {
el.style.display = visible ? "block" : "none";
}
}
function setValue(id, value) {
var el = getElement3(id);
if (el) {
el.value = value;
}
}
function renderStatus(id, status) {
var el = getElement3(id);
if (!el) return;
if (!status.visible) {
el.textContent = "";
el.className = "service-status";
return;
}
el.textContent = status.message;
el.className = "service-status visible" + (status.error ? " error" : " success");
}
function renderButton(id, disabled, label) {
if (!id || !label) return;
var button = getElement3(id);
if (!button) return;
button.disabled = disabled;
button.textContent = label;
}
function renderOpenPanels(openPanelID) {
var panels = ["manage-service-panel", "retrieve-service-panel", "refund-service-panel", "data-service-panel"];
for (var i = 0; i < panels.length; i++) {
var panel = getElement3(panels[i]);
if (!panel) continue;
panel.classList.toggle("visible", panels[i] === openPanelID);
}
}
function renderRefundPanel(refundState, bootstrap) {
var root = getElement3("refund-service-root");
if (!root) return;
var refundSupportURL = (bootstrap.public_site_url || "") + "/refund.html?email=" + encodeURIComponent(refundState.emailValue || "");
root.innerHTML = '<h3>Refund requests</h3><p>Process an eligible self-serve refund for a self-hosted purchase. This revokes the associated license immediately.</p><div class="warning"><strong>Warning:</strong> completing a refund immediately revokes the affected license. This should only be used when the refund window and commercial contract allow it.</div><div class="form-group"><label for="refund-inline-email">Email address</label><input type="email" id="refund-inline-email" value="' + escapeAttribute(refundState.emailValue || "") + '" autocomplete="email" data-account-service-input="refund-email"></div><div class="form-group"><label for="refund-inline-token">License key</label><input type="text" id="refund-inline-token" value="' + escapeAttribute(refundState.tokenValue || "") + '" placeholder="pulse_xxxxx" data-account-service-input="refund-token"></div><div class="form-actions"><button class="btn-danger" type="button" id="refund-inline-submit" data-account-service-action="refund-inline-submit">Process Refund</button></div><div class="helper-text">If this purchase is not eligible for self-serve refund, use the public support path instead: <a href="' + escapeAttribute(refundSupportURL) + '">open refund support page</a>.</div><div class="service-status" id="refund-inline-status"></div>';
}
function renderManagePanel(flowState) {
var root = getElement3("manage-service-root");
if (!root) return;
root.innerHTML = '<h3>Manage subscriptions</h3><p>Request a verification code for the commercial email, then open the Stripe customer portal for billing changes, invoices, and subscription actions.</p><div id="manage-inline-step1"><div class="form-group"><label for="manage-inline-email">Email address</label><input type="email" id="manage-inline-email" value="' + escapeAttribute(flowState.emailValue || "") + '" autocomplete="email" data-account-service-input="manage-email"></div><div class="form-actions"><button class="btn-primary" type="button" id="manage-inline-request" data-account-service-action="manage-inline-request">Send Verification Code</button></div></div><div id="manage-inline-step2" style="display:' + (flowState.step2Visible ? "block" : "none") + '"><div class="form-group"><label for="manage-inline-code">Verification code</label><input type="text" id="manage-inline-code" value="' + escapeAttribute(flowState.codeValue || "") + '" inputmode="numeric" pattern="[0-9]{6}" placeholder="123456" data-account-service-input="manage-code"></div><div class="form-actions"><button class="btn-primary" type="button" id="manage-inline-confirm" data-account-service-action="manage-inline-confirm">Open Customer Portal</button></div><div class="helper-text">Need a new code? <a href="#" id="manage-inline-resend" data-account-service-action="manage-inline-resend">Send again</a></div></div><div class="service-status" id="manage-inline-status"></div>';
}
function renderRetrievePanel(flowState) {
var root = getElement3("retrieve-service-root");
if (!root) return;
var result = flowState.result;
var invoiceURL = result && result.invoice_url ? result.invoice_url : "#";
var invoiceDisplay = result && result.invoice_url ? "inline-block" : "none";
var copyDisplay = result ? "inline-block" : "none";
var resultDisplay = result ? "block" : "none";
root.innerHTML = '<h3>Retrieve licenses</h3><p>Request a verification code for the commercial email, then reveal the current active self-hosted license without leaving Pulse Account.</p><div id="retrieve-inline-step1"><div class="form-group"><label for="retrieve-inline-email">Email address</label><input type="email" id="retrieve-inline-email" value="' + escapeAttribute(flowState.emailValue || "") + '" autocomplete="email" data-account-service-input="retrieve-email"></div><div class="form-actions"><button class="btn-primary" type="button" id="retrieve-inline-request" data-account-service-action="retrieve-inline-request">Send Verification Code</button></div></div><div id="retrieve-inline-step2" style="display:' + (flowState.step2Visible ? "block" : "none") + '"><div class="form-group"><label for="retrieve-inline-code">Verification code</label><input type="text" id="retrieve-inline-code" value="' + escapeAttribute(flowState.codeValue || "") + '" inputmode="numeric" pattern="[0-9]{6}" placeholder="123456" data-account-service-input="retrieve-code"></div><div class="form-actions"><button class="btn-primary" type="button" id="retrieve-inline-confirm" data-account-service-action="retrieve-inline-confirm">Show License</button><button class="btn-secondary" type="button" id="retrieve-inline-copy" data-account-service-action="retrieve-inline-copy" style="display:' + copyDisplay + '">Copy License Key</button><a class="btn-secondary" id="retrieve-inline-invoice" href="' + escapeAttribute(invoiceURL) + '" target="_blank" rel="noopener" style="display:' + invoiceDisplay + '">View Invoice</a></div><div class="helper-text">Use the latest active self-hosted license for this commercial email.</div></div><div class="service-status" id="retrieve-inline-status"></div><div id="retrieve-inline-result" style="display:' + resultDisplay + '; margin-top:14px"><label for="retrieve-inline-token">License key</label><textarea id="retrieve-inline-token" readonly>' + escapeText(result ? result.token : "") + '</textarea><div class="result-grid"><div><div class="result-meta-label">Plan</div><div class="result-meta-value" id="retrieve-inline-tier">' + escapeText(result ? result.tier : "") + '</div></div><div><div class="result-meta-label">Issued</div><div class="result-meta-value" id="retrieve-inline-issued">' + escapeText(result ? new Date(result.issued_at).toLocaleString() : "") + '</div></div><div><div class="result-meta-label">Expires</div><div class="result-meta-value" id="retrieve-inline-expires">' + escapeText(result ? result.expires_at ? new Date(result.expires_at).toLocaleString() : "Does not expire" : "") + '</div></div><div><div class="result-meta-label">Purchase Email</div><div class="result-meta-value" id="retrieve-inline-email-value">' + escapeText(result ? result.email : "") + "</div></div></div></div>";
}
function renderExportPanel(flowState) {
var root = getElement3("data-export-root");
if (!root) return;
var resultDisplay = flowState.result ? "block" : "none";
root.innerHTML = '<h4>Export My Data</h4><div id="data-export-step1"><div class="form-group"><label for="data-export-email">Email address</label><input type="email" id="data-export-email" value="' + escapeAttribute(flowState.emailValue || "") + '" autocomplete="email" data-account-service-input="data-export-email"></div><div class="form-actions"><button class="btn-primary" type="button" id="data-export-request" data-account-service-action="data-export-request">Send Verification Code</button></div></div><div id="data-export-step2" style="display:' + (flowState.step2Visible ? "block" : "none") + '"><div class="form-group"><label for="data-export-code">Verification code</label><input type="text" id="data-export-code" value="' + escapeAttribute(flowState.codeValue || "") + '" inputmode="numeric" pattern="[0-9]{6}" placeholder="123456" data-account-service-input="data-export-code"></div><div class="form-actions"><button class="btn-primary" type="button" id="data-export-confirm" data-account-service-action="data-export-confirm">Export My Data</button></div><div class="helper-text">Need a new code? <a href="#" id="data-export-resend" data-account-service-action="data-export-resend">Send again</a></div></div><div class="service-status" id="data-export-status"></div><div id="data-export-result" style="display:' + resultDisplay + '; margin-top:14px"><label for="data-export-payload">Export payload</label><textarea id="data-export-payload" readonly>' + escapeText(flowState.result ? JSON.stringify(flowState.result, null, 2) : "") + "</textarea></div>";
}
function renderExportResult(result) {
setVisible("data-export-result", !!result);
setValue("data-export-payload", result ? JSON.stringify(result, null, 2) : "");
}
function renderDeletePanel(flowState) {
var root = getElement3("data-delete-root");
if (!root) return;
root.innerHTML = '<h4>Delete My Data</h4><div class="warning"><strong>Warning:</strong> deleting commercial data also revokes license records and cannot be undone.</div><div id="data-delete-step1"><div class="form-group"><label for="data-delete-email">Email address</label><input type="email" id="data-delete-email" value="' + escapeAttribute(flowState.emailValue || "") + '" autocomplete="email" data-account-service-input="data-delete-email"></div><div class="form-actions"><button class="btn-danger" type="button" id="data-delete-request" data-account-service-action="data-delete-request">Send Verification Code</button></div></div><div id="data-delete-step2" style="display:' + (flowState.step2Visible ? "block" : "none") + '"><div class="form-group"><label for="data-delete-code">Verification code</label><input type="text" id="data-delete-code" value="' + escapeAttribute(flowState.codeValue || "") + '" inputmode="numeric" pattern="[0-9]{6}" placeholder="123456" data-account-service-input="data-delete-code"></div><div class="checkbox-row"><input type="checkbox" id="data-delete-confirm-check"' + (flowState.checkboxChecked ? " checked" : "") + '><span>I understand this permanently deletes my commercial data and revokes associated licenses.</span></div><div class="form-actions"><button class="btn-danger" type="button" id="data-delete-confirm" data-account-service-action="data-delete-confirm">Delete My Data</button></div><div class="helper-text">Need a new code? <a href="#" id="data-delete-resend" data-account-service-action="data-delete-resend">Send again</a></div></div><div class="service-status" id="data-delete-status"></div>';
}
// src/services_controller.ts
function installServicesController(deps) {
document.addEventListener("click", function(event) {
var target = asHTMLElement3(event.target)?.closest("[data-account-service-action]");
if (!target) return;
var action = target.getAttribute("data-account-service-action") || "";
var panelID = target.getAttribute("data-account-service-panel") || "";
var focusID = target.getAttribute("data-account-service-focus") || "";
switch (action) {
case "open-service-panel":
event.preventDefault();
deps.toggleServicePanel(panelID);
deps.focusElement(focusID);
return;
case "manage-inline-request":
event.preventDefault();
deps.requestVerificationCode("manage");
return;
case "manage-inline-resend":
deps.resendVerificationCode("manage", event);
return;
case "manage-inline-confirm":
event.preventDefault();
deps.confirmVerificationCode("manage");
return;
case "retrieve-inline-request":
event.preventDefault();
deps.requestVerificationCode("retrieve");
return;
case "retrieve-inline-confirm":
event.preventDefault();
deps.confirmVerificationCode("retrieve");
return;
case "retrieve-inline-copy":
event.preventDefault();
deps.copyRetrievedLicense();
return;
case "refund-inline-submit":
event.preventDefault();
deps.submitRefund();
return;
case "data-export-request":
event.preventDefault();
deps.requestVerificationCode("export");
return;
case "data-export-resend":
deps.resendVerificationCode("export", event);
return;
case "data-export-confirm":
event.preventDefault();
deps.confirmVerificationCode("export");
return;
case "data-delete-request":
event.preventDefault();
deps.requestVerificationCode("delete");
return;
case "data-delete-resend":
deps.resendVerificationCode("delete", event);
return;
case "data-delete-confirm":
event.preventDefault();
deps.confirmVerificationCode("delete");
return;
default:
return;
}
});
document.addEventListener("input", function(event) {
var target = asHTMLElement3(event.target);
if (!target) return;
var inputKind = target.getAttribute("data-account-service-input") || "";
if (!inputKind) return;
deps.updateInputValue(inputKind, target.value);
});
document.addEventListener("change", function(event) {
var target = asHTMLElement3(event.target);
if (!target || target.id !== "data-delete-confirm-check") return;
deps.updateDeleteConfirmation(!!target.checked);
});
}
// src/services.ts
var serviceState = {
openPanelID: "",
@ -752,38 +929,6 @@
function getCommercialAPIBaseURL2() {
return getCommercialAPIBaseURL();
}
function getElement3(id) {
return document.getElementById(id);
}
function asHTMLElement3(target) {
return target instanceof HTMLElement ? target : null;
}
function escapeText(value) {
return String(value || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function escapeAttribute(value) {
return escapeText(value).replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
function readValue(id) {
var el = getElement3(id);
return el ? el.value.trim() : "";
}
function focusElement(id) {
var el = getElement3(id);
if (el) el.focus();
}
function setVisible(id, visible) {
var el = getElement3(id);
if (el) {
el.style.display = visible ? "block" : "none";
}
}
function setValue(id, value) {
var el = getElement3(id);
if (el) {
el.value = value;
}
}
function serviceFetch(path, body) {
return fetch(getCommercialAPIBaseURL2() + path, {
method: "POST",
@ -808,34 +953,9 @@
error: !!isError
};
}
function renderStatus(id, status) {
var el = getElement3(id);
if (!el) return;
if (!status.visible) {
el.textContent = "";
el.className = "service-status";
return;
}
el.textContent = status.message;
el.className = "service-status visible" + (status.error ? " error" : " success");
}
function renderButton(id, disabled, label) {
var button = getElement3(id);
if (!button) return;
button.disabled = disabled;
button.textContent = label;
}
function toggleServicePanel(panelID) {
serviceState.openPanelID = serviceState.openPanelID === panelID ? "" : panelID;
renderOpenPanels();
}
function renderOpenPanels() {
var panels = ["manage-service-panel", "retrieve-service-panel", "refund-service-panel", "data-service-panel"];
for (var i = 0; i < panels.length; i++) {
var panel = getElement3(panels[i]);
if (!panel) continue;
panel.classList.toggle("visible", panels[i] === serviceState.openPanelID);
}
renderOpenPanels(serviceState.openPanelID);
}
function renderFlow(flowID) {
var flow = verificationFlows[flowID];
@ -862,17 +982,10 @@
renderRefund();
}
function renderRefund() {
renderRefundPanel();
renderRefundPanel(serviceState.refund, getBootstrap());
renderButton("refund-inline-submit", serviceState.refund.submitting, serviceState.refund.submitting ? "Processing..." : "Process Refund");
renderStatus("refund-inline-status", serviceState.refund.status);
}
function renderRefundPanel() {
var root = getElement3("refund-service-root");
if (!root) return;
var bootstrap = getBootstrap();
var refundSupportURL = (bootstrap.public_site_url || "") + "/refund.html?email=" + encodeURIComponent(serviceState.refund.emailValue || "");
root.innerHTML = '<h3>Refund requests</h3><p>Process an eligible self-serve refund for a self-hosted purchase. This revokes the associated license immediately.</p><div class="warning"><strong>Warning:</strong> completing a refund immediately revokes the affected license. This should only be used when the refund window and commercial contract allow it.</div><div class="form-group"><label for="refund-inline-email">Email address</label><input type="email" id="refund-inline-email" value="' + escapeAttribute(serviceState.refund.emailValue || "") + '" autocomplete="email" data-account-service-input="refund-email"></div><div class="form-group"><label for="refund-inline-token">License key</label><input type="text" id="refund-inline-token" value="' + escapeAttribute(serviceState.refund.tokenValue || "") + '" placeholder="pulse_xxxxx" data-account-service-input="refund-token"></div><div class="form-actions"><button class="btn-danger" type="button" id="refund-inline-submit" data-account-service-action="refund-inline-submit">Process Refund</button></div><div class="helper-text">If this purchase is not eligible for self-serve refund, use the public support path instead: <a href="' + escapeAttribute(refundSupportURL) + '">open refund support page</a>.</div><div class="service-status" id="refund-inline-status"></div>';
}
function resetVerificationFlow(flowID) {
var flow = verificationFlows[flowID];
if (!flow) return;
@ -913,11 +1026,7 @@
onConfirmSuccess: function(data) {
window.location.href = data.url;
},
renderPanel: function(flowState) {
var root = getElement3("manage-service-root");
if (!root) return;
root.innerHTML = '<h3>Manage subscriptions</h3><p>Request a verification code for the commercial email, then open the Stripe customer portal for billing changes, invoices, and subscription actions.</p><div id="manage-inline-step1"><div class="form-group"><label for="manage-inline-email">Email address</label><input type="email" id="manage-inline-email" value="' + escapeAttribute(flowState.emailValue || "") + '" autocomplete="email" data-account-service-input="manage-email"></div><div class="form-actions"><button class="btn-primary" type="button" id="manage-inline-request" data-account-service-action="manage-inline-request">Send Verification Code</button></div></div><div id="manage-inline-step2" style="display:' + (flowState.step2Visible ? "block" : "none") + '"><div class="form-group"><label for="manage-inline-code">Verification code</label><input type="text" id="manage-inline-code" value="' + escapeAttribute(flowState.codeValue || "") + '" inputmode="numeric" pattern="[0-9]{6}" placeholder="123456" data-account-service-input="manage-code"></div><div class="form-actions"><button class="btn-primary" type="button" id="manage-inline-confirm" data-account-service-action="manage-inline-confirm">Open Customer Portal</button></div><div class="helper-text">Need a new code? <a href="#" id="manage-inline-resend" data-account-service-action="manage-inline-resend">Send again</a></div></div><div class="service-status" id="manage-inline-status"></div>';
}
renderPanel: renderManagePanel
},
retrieve: {
requestPath: "/v1/retrieve-license/request",
@ -951,19 +1060,7 @@
serviceState.flows.retrieve.codeValue = "";
setFlowStatus("retrieve", "License retrieved successfully.", false);
},
renderPanel: function(flowState) {
var root = getElement3("retrieve-service-root");
if (!root) return;
var result = flowState.result;
var invoiceURL = result && result.invoice_url ? result.invoice_url : "#";
var invoiceDisplay = result && result.invoice_url ? "inline-block" : "none";
var copyDisplay = result ? "inline-block" : "none";
var resultDisplay = result ? "block" : "none";
root.innerHTML = '<h3>Retrieve licenses</h3><p>Request a verification code for the commercial email, then reveal the current active self-hosted license without leaving Pulse Account.</p><div id="retrieve-inline-step1"><div class="form-group"><label for="retrieve-inline-email">Email address</label><input type="email" id="retrieve-inline-email" value="' + escapeAttribute(flowState.emailValue || "") + '" autocomplete="email" data-account-service-input="retrieve-email"></div><div class="form-actions"><button class="btn-primary" type="button" id="retrieve-inline-request" data-account-service-action="retrieve-inline-request">Send Verification Code</button></div></div><div id="retrieve-inline-step2" style="display:' + (flowState.step2Visible ? "block" : "none") + '"><div class="form-group"><label for="retrieve-inline-code">Verification code</label><input type="text" id="retrieve-inline-code" value="' + escapeAttribute(flowState.codeValue || "") + '" inputmode="numeric" pattern="[0-9]{6}" placeholder="123456" data-account-service-input="retrieve-code"></div><div class="form-actions"><button class="btn-primary" type="button" id="retrieve-inline-confirm" data-account-service-action="retrieve-inline-confirm">Show License</button><button class="btn-secondary" type="button" id="retrieve-inline-copy" data-account-service-action="retrieve-inline-copy" style="display:' + copyDisplay + '">Copy License Key</button><a class="btn-secondary" id="retrieve-inline-invoice" href="' + escapeAttribute(invoiceURL) + '" target="_blank" rel="noopener" style="display:' + invoiceDisplay + '">View Invoice</a></div><div class="helper-text">Use the latest active self-hosted license for this commercial email.</div></div><div class="service-status" id="retrieve-inline-status"></div><div id="retrieve-inline-result" style="display:' + resultDisplay + '; margin-top:14px"><label for="retrieve-inline-token">License key</label><textarea id="retrieve-inline-token" readonly>' + escapeText(result ? result.token : "") + '</textarea><div class="result-grid"><div><div class="result-meta-label">Plan</div><div class="result-meta-value" id="retrieve-inline-tier">' + escapeText(result ? result.tier : "") + '</div></div><div><div class="result-meta-label">Issued</div><div class="result-meta-value" id="retrieve-inline-issued">' + escapeText(result ? new Date(result.issued_at).toLocaleString() : "") + '</div></div><div><div class="result-meta-label">Expires</div><div class="result-meta-value" id="retrieve-inline-expires">' + escapeText(result ? result.expires_at ? new Date(result.expires_at).toLocaleString() : "Does not expire" : "") + '</div></div><div><div class="result-meta-label">Purchase Email</div><div class="result-meta-value" id="retrieve-inline-email-value">' + escapeText(result ? result.email : "") + "</div></div></div></div>";
},
renderResult: function(result) {
void result;
}
renderPanel: renderRetrievePanel
},
export: {
requestPath: "/v1/gdpr/request-export",
@ -999,16 +1096,8 @@
resetVerificationFlow("export");
serviceState.flows.export.result = data;
},
renderPanel: function(flowState) {
var root = getElement3("data-export-root");
if (!root) return;
var resultDisplay = flowState.result ? "block" : "none";
root.innerHTML = '<h4>Export My Data</h4><div id="data-export-step1"><div class="form-group"><label for="data-export-email">Email address</label><input type="email" id="data-export-email" value="' + escapeAttribute(flowState.emailValue || "") + '" autocomplete="email" data-account-service-input="data-export-email"></div><div class="form-actions"><button class="btn-primary" type="button" id="data-export-request" data-account-service-action="data-export-request">Send Verification Code</button></div></div><div id="data-export-step2" style="display:' + (flowState.step2Visible ? "block" : "none") + '"><div class="form-group"><label for="data-export-code">Verification code</label><input type="text" id="data-export-code" value="' + escapeAttribute(flowState.codeValue || "") + '" inputmode="numeric" pattern="[0-9]{6}" placeholder="123456" data-account-service-input="data-export-code"></div><div class="form-actions"><button class="btn-primary" type="button" id="data-export-confirm" data-account-service-action="data-export-confirm">Export My Data</button></div><div class="helper-text">Need a new code? <a href="#" id="data-export-resend" data-account-service-action="data-export-resend">Send again</a></div></div><div class="service-status" id="data-export-status"></div><div id="data-export-result" style="display:' + resultDisplay + '; margin-top:14px"><label for="data-export-payload">Export payload</label><textarea id="data-export-payload" readonly>' + escapeText(flowState.result ? JSON.stringify(flowState.result, null, 2) : "") + "</textarea></div>";
},
renderResult: function(result) {
setVisible("data-export-result", !!result);
setValue("data-export-payload", result ? JSON.stringify(result, null, 2) : "");
}
renderPanel: renderExportPanel,
renderResult: renderExportResult
},
delete: {
requestPath: "/v1/gdpr/request-delete",
@ -1050,11 +1139,7 @@
resetVerificationFlow("delete");
setFlowStatus("delete", data.deleted_count > 0 && data.stripe_reminder ? data.message + " " + data.stripe_reminder : data.message, false);
},
renderPanel: function(flowState) {
var root = getElement3("data-delete-root");
if (!root) return;
root.innerHTML = '<h4>Delete My Data</h4><div class="warning"><strong>Warning:</strong> deleting commercial data also revokes license records and cannot be undone.</div><div id="data-delete-step1"><div class="form-group"><label for="data-delete-email">Email address</label><input type="email" id="data-delete-email" value="' + escapeAttribute(flowState.emailValue || "") + '" autocomplete="email" data-account-service-input="data-delete-email"></div><div class="form-actions"><button class="btn-danger" type="button" id="data-delete-request" data-account-service-action="data-delete-request">Send Verification Code</button></div></div><div id="data-delete-step2" style="display:' + (flowState.step2Visible ? "block" : "none") + '"><div class="form-group"><label for="data-delete-code">Verification code</label><input type="text" id="data-delete-code" value="' + escapeAttribute(flowState.codeValue || "") + '" inputmode="numeric" pattern="[0-9]{6}" placeholder="123456" data-account-service-input="data-delete-code"></div><div class="checkbox-row"><input type="checkbox" id="data-delete-confirm-check"' + (flowState.checkboxChecked ? " checked" : "") + '><span>I understand this permanently deletes my commercial data and revokes associated licenses.</span></div><div class="form-actions"><button class="btn-danger" type="button" id="data-delete-confirm" data-account-service-action="data-delete-confirm">Delete My Data</button></div><div class="helper-text">Need a new code? <a href="#" id="data-delete-resend" data-account-service-action="data-delete-resend">Send again</a></div></div><div class="service-status" id="data-delete-status"></div>';
}
renderPanel: renderDeletePanel
}
};
async function requestVerificationCode(flowID) {
@ -1170,118 +1255,68 @@
}
function renderServiceRuntime() {
syncServiceStateFromBootstrap();
renderOpenPanels();
renderOpenPanels(serviceState.openPanelID);
renderAllFlows();
}
renderServiceRuntime();
subscribePortalRender(renderServiceRuntime);
document.addEventListener("click", function(event) {
var target = asHTMLElement3(event.target)?.closest("[data-account-service-action]");
if (!target) return;
var action = target.getAttribute("data-account-service-action") || "";
var panelID = target.getAttribute("data-account-service-panel") || "";
var focusID = target.getAttribute("data-account-service-focus") || "";
switch (action) {
case "open-service-panel":
event.preventDefault();
toggleServicePanel(panelID);
focusElement(focusID);
return;
case "manage-inline-request":
event.preventDefault();
requestVerificationCode("manage");
return;
case "manage-inline-resend":
resendVerificationCode("manage", event);
return;
case "manage-inline-confirm":
event.preventDefault();
confirmVerificationCode("manage");
return;
case "retrieve-inline-request":
event.preventDefault();
requestVerificationCode("retrieve");
return;
case "retrieve-inline-confirm":
event.preventDefault();
confirmVerificationCode("retrieve");
return;
case "retrieve-inline-copy":
event.preventDefault();
copyRetrievedLicense();
return;
case "refund-inline-submit":
event.preventDefault();
submitRefund();
return;
case "data-export-request":
event.preventDefault();
requestVerificationCode("export");
return;
case "data-export-resend":
resendVerificationCode("export", event);
return;
case "data-export-confirm":
event.preventDefault();
confirmVerificationCode("export");
return;
case "data-delete-request":
event.preventDefault();
requestVerificationCode("delete");
return;
case "data-delete-resend":
resendVerificationCode("delete", event);
return;
case "data-delete-confirm":
event.preventDefault();
confirmVerificationCode("delete");
return;
default:
return;
}
});
document.addEventListener("input", function(event) {
var target = asHTMLElement3(event.target);
if (!target) return;
var inputKind = target.getAttribute("data-account-service-input") || "";
function updateInputValue(inputKind, value) {
switch (inputKind) {
case "manage-email":
serviceState.flows.manage.emailValue = target.value;
serviceState.flows.manage.emailValue = value;
return;
case "manage-code":
serviceState.flows.manage.codeValue = target.value;
serviceState.flows.manage.codeValue = value;
return;
case "retrieve-email":
serviceState.flows.retrieve.emailValue = target.value;
serviceState.flows.retrieve.emailValue = value;
return;
case "retrieve-code":
serviceState.flows.retrieve.codeValue = target.value;
serviceState.flows.retrieve.codeValue = value;
return;
case "refund-email":
serviceState.refund.emailValue = target.value;
serviceState.refund.emailValue = value;
return;
case "refund-token":
serviceState.refund.tokenValue = target.value;
serviceState.refund.tokenValue = value;
return;
case "data-export-email":
serviceState.flows.export.emailValue = target.value;
serviceState.flows.export.emailValue = value;
return;
case "data-export-code":
serviceState.flows.export.codeValue = target.value;
serviceState.flows.export.codeValue = value;
return;
case "data-delete-email":
serviceState.flows.delete.emailValue = target.value;
serviceState.flows.delete.emailValue = value;
return;
case "data-delete-code":
serviceState.flows.delete.codeValue = target.value;
serviceState.flows.delete.codeValue = value;
return;
default:
return;
}
});
document.addEventListener("change", function(event) {
var target = asHTMLElement3(event.target);
if (!target || target.id !== "data-delete-confirm-check") return;
serviceState.flows.delete.checkboxChecked = !!target.checked;
}
installServicesController({
toggleServicePanel,
focusElement,
requestVerificationCode: function(flowID) {
void requestVerificationCode(flowID);
},
resendVerificationCode: function(flowID, event) {
void resendVerificationCode(flowID, event);
},
confirmVerificationCode: function(flowID) {
void confirmVerificationCode(flowID);
},
copyRetrievedLicense: function() {
void copyRetrievedLicense();
},
submitRefund: function() {
void submitRefund();
},
updateInputValue,
updateDeleteConfirmation: function(checked) {
serviceState.flows.delete.checkboxChecked = checked;
}
});
})();

View file

@ -23,6 +23,8 @@ const buildInputs = [
'src/shell.ts',
'src/shell_view.ts',
'src/services.ts',
'src/services_controller.ts',
'src/services_view.ts',
'src/runtime.ts',
'src/types.ts',
'src/styles.css',

View file

@ -1,4 +1,21 @@
import { getBootstrap, getCommercialAPIBaseURL as readCommercialAPIBaseURL, subscribePortalRender } from './runtime';
import { installServicesController } from './services_controller';
import {
focusElement,
getElement,
readValue,
renderButton,
renderDeletePanel,
renderExportPanel,
renderExportResult,
renderManagePanel,
renderOpenPanels,
renderRefundPanel,
renderRetrievePanel,
renderStatus,
setValue,
setVisible,
} from './services_view';
import type { RefundState, ServiceStatus, VerificationFlowState } from './types';
type FlowID = 'manage' | 'retrieve' | 'export' | 'delete';
@ -30,8 +47,6 @@ interface VerificationFlowDefinition {
renderResult?: (result: unknown) => void;
}
type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
var serviceState = {
openPanelID: '',
flows: {
@ -74,58 +89,6 @@ type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectEleme
return readCommercialAPIBaseURL();
}
function getElement<T extends HTMLElement = HTMLElement>(id): T | null {
return document.getElementById(id) as T | null;
}
function asHTMLElement(target: EventTarget | null): HTMLElement | null {
return target instanceof HTMLElement ? target : null;
}
function escapeText(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function escapeAttribute(value) {
return escapeText(value)
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function readValue(id) {
var el = getElement<FormValueElement>(id);
return el ? el.value.trim() : '';
}
function focusElement(id) {
var el = getElement<FormValueElement>(id);
if (el) el.focus();
}
function setVisible(id, visible) {
var el = getElement(id);
if (el) {
el.style.display = visible ? 'block' : 'none';
}
}
function setText(id, value) {
var el = getElement(id);
if (el) {
el.textContent = value;
}
}
function setValue(id, value) {
var el = getElement<FormValueElement>(id);
if (el) {
el.value = value;
}
}
function serviceFetch(path, body) {
return fetch(getCommercialAPIBaseURL() + path, {
method: 'POST',
@ -154,37 +117,9 @@ type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectEleme
};
}
function renderStatus(id, status) {
var el = getElement(id);
if (!el) return;
if (!status.visible) {
el.textContent = '';
el.className = 'service-status';
return;
}
el.textContent = status.message;
el.className = 'service-status visible' + (status.error ? ' error' : ' success');
}
function renderButton(id, disabled, label) {
var button = getElement<HTMLButtonElement>(id);
if (!button) return;
button.disabled = disabled;
button.textContent = label;
}
function toggleServicePanel(panelID) {
serviceState.openPanelID = serviceState.openPanelID === panelID ? '' : panelID;
renderOpenPanels();
}
function renderOpenPanels() {
var panels = ['manage-service-panel', 'retrieve-service-panel', 'refund-service-panel', 'data-service-panel'];
for (var i = 0; i < panels.length; i++) {
var panel = getElement(panels[i]);
if (!panel) continue;
panel.classList.toggle('visible', panels[i] === serviceState.openPanelID);
}
renderOpenPanels(serviceState.openPanelID);
}
function renderFlow(flowID) {
@ -214,35 +149,11 @@ type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectEleme
}
function renderRefund() {
renderRefundPanel();
renderRefundPanel(serviceState.refund, getBootstrap());
renderButton('refund-inline-submit', serviceState.refund.submitting, serviceState.refund.submitting ? 'Processing...' : 'Process Refund');
renderStatus('refund-inline-status', serviceState.refund.status);
}
function renderRefundPanel() {
var root = getElement('refund-service-root');
if (!root) return;
var bootstrap = getBootstrap();
var refundSupportURL = (bootstrap.public_site_url || '') + '/refund.html?email=' + encodeURIComponent(serviceState.refund.emailValue || '');
root.innerHTML = '' +
'<h3>Refund requests</h3>' +
'<p>Process an eligible self-serve refund for a self-hosted purchase. This revokes the associated license immediately.</p>' +
'<div class="warning"><strong>Warning:</strong> completing a refund immediately revokes the affected license. This should only be used when the refund window and commercial contract allow it.</div>' +
'<div class="form-group">' +
'<label for="refund-inline-email">Email address</label>' +
'<input type="email" id="refund-inline-email" value="' + escapeAttribute(serviceState.refund.emailValue || '') + '" autocomplete="email" data-account-service-input="refund-email">' +
'</div>' +
'<div class="form-group">' +
'<label for="refund-inline-token">License key</label>' +
'<input type="text" id="refund-inline-token" value="' + escapeAttribute(serviceState.refund.tokenValue || '') + '" placeholder="pulse_xxxxx" data-account-service-input="refund-token">' +
'</div>' +
'<div class="form-actions">' +
'<button class="btn-danger" type="button" id="refund-inline-submit" data-account-service-action="refund-inline-submit">Process Refund</button>' +
'</div>' +
'<div class="helper-text">If this purchase is not eligible for self-serve refund, use the public support path instead: <a href="' + escapeAttribute(refundSupportURL) + '">open refund support page</a>.</div>' +
'<div class="service-status" id="refund-inline-status"></div>';
}
function resetVerificationFlow(flowID: FlowID) {
var flow = verificationFlows[flowID];
if (!flow) return;
@ -283,33 +194,7 @@ type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectEleme
onConfirmSuccess: function(data) {
window.location.href = data.url;
},
renderPanel: function(flowState) {
var root = getElement('manage-service-root');
if (!root) return;
root.innerHTML = '' +
'<h3>Manage subscriptions</h3>' +
'<p>Request a verification code for the commercial email, then open the Stripe customer portal for billing changes, invoices, and subscription actions.</p>' +
'<div id="manage-inline-step1">' +
'<div class="form-group">' +
'<label for="manage-inline-email">Email address</label>' +
'<input type="email" id="manage-inline-email" value="' + escapeAttribute(flowState.emailValue || '') + '" autocomplete="email" data-account-service-input="manage-email">' +
'</div>' +
'<div class="form-actions">' +
'<button class="btn-primary" type="button" id="manage-inline-request" data-account-service-action="manage-inline-request">Send Verification Code</button>' +
'</div>' +
'</div>' +
'<div id="manage-inline-step2" style="display:' + (flowState.step2Visible ? 'block' : 'none') + '">' +
'<div class="form-group">' +
'<label for="manage-inline-code">Verification code</label>' +
'<input type="text" id="manage-inline-code" value="' + escapeAttribute(flowState.codeValue || '') + '" inputmode="numeric" pattern="[0-9]{6}" placeholder="123456" data-account-service-input="manage-code">' +
'</div>' +
'<div class="form-actions">' +
'<button class="btn-primary" type="button" id="manage-inline-confirm" data-account-service-action="manage-inline-confirm">Open Customer Portal</button>' +
'</div>' +
'<div class="helper-text">Need a new code? <a href="#" id="manage-inline-resend" data-account-service-action="manage-inline-resend">Send again</a></div>' +
'</div>' +
'<div class="service-status" id="manage-inline-status"></div>';
}
renderPanel: renderManagePanel
},
retrieve: {
requestPath: '/v1/retrieve-license/request',
@ -343,60 +228,7 @@ type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectEleme
serviceState.flows.retrieve.codeValue = '';
setFlowStatus('retrieve', 'License retrieved successfully.', false);
},
renderPanel: function(flowState) {
var root = getElement('retrieve-service-root');
if (!root) return;
var result = flowState.result as {
invoice_url?: string;
token?: string;
tier?: string;
issued_at?: string;
expires_at?: string | null;
email?: string;
} | null;
var invoiceURL = result && result.invoice_url ? result.invoice_url : '#';
var invoiceDisplay = result && result.invoice_url ? 'inline-block' : 'none';
var copyDisplay = result ? 'inline-block' : 'none';
var resultDisplay = result ? 'block' : 'none';
root.innerHTML = '' +
'<h3>Retrieve licenses</h3>' +
'<p>Request a verification code for the commercial email, then reveal the current active self-hosted license without leaving Pulse Account.</p>' +
'<div id="retrieve-inline-step1">' +
'<div class="form-group">' +
'<label for="retrieve-inline-email">Email address</label>' +
'<input type="email" id="retrieve-inline-email" value="' + escapeAttribute(flowState.emailValue || '') + '" autocomplete="email" data-account-service-input="retrieve-email">' +
'</div>' +
'<div class="form-actions">' +
'<button class="btn-primary" type="button" id="retrieve-inline-request" data-account-service-action="retrieve-inline-request">Send Verification Code</button>' +
'</div>' +
'</div>' +
'<div id="retrieve-inline-step2" style="display:' + (flowState.step2Visible ? 'block' : 'none') + '">' +
'<div class="form-group">' +
'<label for="retrieve-inline-code">Verification code</label>' +
'<input type="text" id="retrieve-inline-code" value="' + escapeAttribute(flowState.codeValue || '') + '" inputmode="numeric" pattern="[0-9]{6}" placeholder="123456" data-account-service-input="retrieve-code">' +
'</div>' +
'<div class="form-actions">' +
'<button class="btn-primary" type="button" id="retrieve-inline-confirm" data-account-service-action="retrieve-inline-confirm">Show License</button>' +
'<button class="btn-secondary" type="button" id="retrieve-inline-copy" data-account-service-action="retrieve-inline-copy" style="display:' + copyDisplay + '">Copy License Key</button>' +
'<a class="btn-secondary" id="retrieve-inline-invoice" href="' + escapeAttribute(invoiceURL) + '" target="_blank" rel="noopener" style="display:' + invoiceDisplay + '">View Invoice</a>' +
'</div>' +
'<div class="helper-text">Use the latest active self-hosted license for this commercial email.</div>' +
'</div>' +
'<div class="service-status" id="retrieve-inline-status"></div>' +
'<div id="retrieve-inline-result" style="display:' + resultDisplay + '; margin-top:14px">' +
'<label for="retrieve-inline-token">License key</label>' +
'<textarea id="retrieve-inline-token" readonly>' + escapeText(result ? result.token : '') + '</textarea>' +
'<div class="result-grid">' +
'<div><div class="result-meta-label">Plan</div><div class="result-meta-value" id="retrieve-inline-tier">' + escapeText(result ? result.tier : '') + '</div></div>' +
'<div><div class="result-meta-label">Issued</div><div class="result-meta-value" id="retrieve-inline-issued">' + escapeText(result ? new Date(result.issued_at).toLocaleString() : '') + '</div></div>' +
'<div><div class="result-meta-label">Expires</div><div class="result-meta-value" id="retrieve-inline-expires">' + escapeText(result ? (result.expires_at ? new Date(result.expires_at).toLocaleString() : 'Does not expire') : '') + '</div></div>' +
'<div><div class="result-meta-label">Purchase Email</div><div class="result-meta-value" id="retrieve-inline-email-value">' + escapeText(result ? result.email : '') + '</div></div>' +
'</div>' +
'</div>';
},
renderResult: function(result) {
void result;
}
renderPanel: renderRetrievePanel
},
export: {
requestPath: '/v1/gdpr/request-export',
@ -432,41 +264,8 @@ type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectEleme
resetVerificationFlow('export');
serviceState.flows.export.result = data;
},
renderPanel: function(flowState) {
var root = getElement('data-export-root');
if (!root) return;
var resultDisplay = flowState.result ? 'block' : 'none';
root.innerHTML = '' +
'<h4>Export My Data</h4>' +
'<div id="data-export-step1">' +
'<div class="form-group">' +
'<label for="data-export-email">Email address</label>' +
'<input type="email" id="data-export-email" value="' + escapeAttribute(flowState.emailValue || '') + '" autocomplete="email" data-account-service-input="data-export-email">' +
'</div>' +
'<div class="form-actions">' +
'<button class="btn-primary" type="button" id="data-export-request" data-account-service-action="data-export-request">Send Verification Code</button>' +
'</div>' +
'</div>' +
'<div id="data-export-step2" style="display:' + (flowState.step2Visible ? 'block' : 'none') + '">' +
'<div class="form-group">' +
'<label for="data-export-code">Verification code</label>' +
'<input type="text" id="data-export-code" value="' + escapeAttribute(flowState.codeValue || '') + '" inputmode="numeric" pattern="[0-9]{6}" placeholder="123456" data-account-service-input="data-export-code">' +
'</div>' +
'<div class="form-actions">' +
'<button class="btn-primary" type="button" id="data-export-confirm" data-account-service-action="data-export-confirm">Export My Data</button>' +
'</div>' +
'<div class="helper-text">Need a new code? <a href="#" id="data-export-resend" data-account-service-action="data-export-resend">Send again</a></div>' +
'</div>' +
'<div class="service-status" id="data-export-status"></div>' +
'<div id="data-export-result" style="display:' + resultDisplay + '; margin-top:14px">' +
'<label for="data-export-payload">Export payload</label>' +
'<textarea id="data-export-payload" readonly>' + escapeText(flowState.result ? JSON.stringify(flowState.result, null, 2) : '') + '</textarea>' +
'</div>';
},
renderResult: function(result) {
setVisible('data-export-result', !!result);
setValue('data-export-payload', result ? JSON.stringify(result, null, 2) : '');
}
renderPanel: renderExportPanel,
renderResult: renderExportResult
},
delete: {
requestPath: '/v1/gdpr/request-delete',
@ -508,37 +307,7 @@ type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectEleme
resetVerificationFlow('delete');
setFlowStatus('delete', data.deleted_count > 0 && data.stripe_reminder ? data.message + ' ' + data.stripe_reminder : data.message, false);
},
renderPanel: function(flowState) {
var root = getElement('data-delete-root');
if (!root) return;
root.innerHTML = '' +
'<h4>Delete My Data</h4>' +
'<div class="warning"><strong>Warning:</strong> deleting commercial data also revokes license records and cannot be undone.</div>' +
'<div id="data-delete-step1">' +
'<div class="form-group">' +
'<label for="data-delete-email">Email address</label>' +
'<input type="email" id="data-delete-email" value="' + escapeAttribute(flowState.emailValue || '') + '" autocomplete="email" data-account-service-input="data-delete-email">' +
'</div>' +
'<div class="form-actions">' +
'<button class="btn-danger" type="button" id="data-delete-request" data-account-service-action="data-delete-request">Send Verification Code</button>' +
'</div>' +
'</div>' +
'<div id="data-delete-step2" style="display:' + (flowState.step2Visible ? 'block' : 'none') + '">' +
'<div class="form-group">' +
'<label for="data-delete-code">Verification code</label>' +
'<input type="text" id="data-delete-code" value="' + escapeAttribute(flowState.codeValue || '') + '" inputmode="numeric" pattern="[0-9]{6}" placeholder="123456" data-account-service-input="data-delete-code">' +
'</div>' +
'<div class="checkbox-row">' +
'<input type="checkbox" id="data-delete-confirm-check"' + (flowState.checkboxChecked ? ' checked' : '') + '>' +
'<span>I understand this permanently deletes my commercial data and revokes associated licenses.</span>' +
'</div>' +
'<div class="form-actions">' +
'<button class="btn-danger" type="button" id="data-delete-confirm" data-account-service-action="data-delete-confirm">Delete My Data</button>' +
'</div>' +
'<div class="helper-text">Need a new code? <a href="#" id="data-delete-resend" data-account-service-action="data-delete-resend">Send again</a></div>' +
'</div>' +
'<div class="service-status" id="data-delete-status"></div>';
}
renderPanel: renderDeletePanel
}
};
@ -661,122 +430,70 @@ type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectEleme
function renderServiceRuntime() {
syncServiceStateFromBootstrap();
renderOpenPanels();
renderOpenPanels(serviceState.openPanelID);
renderAllFlows();
}
renderServiceRuntime();
subscribePortalRender(renderServiceRuntime);
document.addEventListener('click', function(event) {
var target = asHTMLElement(event.target)?.closest('[data-account-service-action]');
if (!target) return;
var action = target.getAttribute('data-account-service-action') || '';
var panelID = target.getAttribute('data-account-service-panel') || '';
var focusID = target.getAttribute('data-account-service-focus') || '';
switch (action) {
case 'open-service-panel':
event.preventDefault();
toggleServicePanel(panelID);
focusElement(focusID);
return;
case 'manage-inline-request':
event.preventDefault();
requestVerificationCode('manage');
return;
case 'manage-inline-resend':
resendVerificationCode('manage', event);
return;
case 'manage-inline-confirm':
event.preventDefault();
confirmVerificationCode('manage');
return;
case 'retrieve-inline-request':
event.preventDefault();
requestVerificationCode('retrieve');
return;
case 'retrieve-inline-confirm':
event.preventDefault();
confirmVerificationCode('retrieve');
return;
case 'retrieve-inline-copy':
event.preventDefault();
copyRetrievedLicense();
return;
case 'refund-inline-submit':
event.preventDefault();
submitRefund();
return;
case 'data-export-request':
event.preventDefault();
requestVerificationCode('export');
return;
case 'data-export-resend':
resendVerificationCode('export', event);
return;
case 'data-export-confirm':
event.preventDefault();
confirmVerificationCode('export');
return;
case 'data-delete-request':
event.preventDefault();
requestVerificationCode('delete');
return;
case 'data-delete-resend':
resendVerificationCode('delete', event);
return;
case 'data-delete-confirm':
event.preventDefault();
confirmVerificationCode('delete');
return;
default:
return;
}
});
document.addEventListener('input', function(event) {
var target = asHTMLElement(event.target) as FormValueElement | null;
if (!target) return;
var inputKind = target.getAttribute('data-account-service-input') || '';
function updateInputValue(inputKind: string, value: string) {
switch (inputKind) {
case 'manage-email':
serviceState.flows.manage.emailValue = target.value;
serviceState.flows.manage.emailValue = value;
return;
case 'manage-code':
serviceState.flows.manage.codeValue = target.value;
serviceState.flows.manage.codeValue = value;
return;
case 'retrieve-email':
serviceState.flows.retrieve.emailValue = target.value;
serviceState.flows.retrieve.emailValue = value;
return;
case 'retrieve-code':
serviceState.flows.retrieve.codeValue = target.value;
serviceState.flows.retrieve.codeValue = value;
return;
case 'refund-email':
serviceState.refund.emailValue = target.value;
serviceState.refund.emailValue = value;
return;
case 'refund-token':
serviceState.refund.tokenValue = target.value;
serviceState.refund.tokenValue = value;
return;
case 'data-export-email':
serviceState.flows.export.emailValue = target.value;
serviceState.flows.export.emailValue = value;
return;
case 'data-export-code':
serviceState.flows.export.codeValue = target.value;
serviceState.flows.export.codeValue = value;
return;
case 'data-delete-email':
serviceState.flows.delete.emailValue = target.value;
serviceState.flows.delete.emailValue = value;
return;
case 'data-delete-code':
serviceState.flows.delete.codeValue = target.value;
serviceState.flows.delete.codeValue = value;
return;
default:
return;
}
});
}
document.addEventListener('change', function(event) {
var target = asHTMLElement(event.target) as HTMLInputElement | null;
if (!target || target.id !== 'data-delete-confirm-check') return;
serviceState.flows.delete.checkboxChecked = !!target.checked;
installServicesController({
toggleServicePanel,
focusElement,
requestVerificationCode: function(flowID) {
void requestVerificationCode(flowID);
},
resendVerificationCode: function(flowID, event) {
void resendVerificationCode(flowID, event);
},
confirmVerificationCode: function(flowID) {
void confirmVerificationCode(flowID);
},
copyRetrievedLicense: function() {
void copyRetrievedLicense();
},
submitRefund: function() {
void submitRefund();
},
updateInputValue,
updateDeleteConfirmation: function(checked) {
serviceState.flows.delete.checkboxChecked = checked;
},
});

View file

@ -0,0 +1,96 @@
import { asHTMLElement } from './services_view';
export interface ServicesControllerDeps {
toggleServicePanel: (panelID: string) => void;
focusElement: (id: string) => void;
requestVerificationCode: (flowID: 'manage' | 'retrieve' | 'export' | 'delete') => void;
resendVerificationCode: (flowID: 'manage' | 'export' | 'delete', event?: Event) => void;
confirmVerificationCode: (flowID: 'manage' | 'retrieve' | 'export' | 'delete') => void;
copyRetrievedLicense: () => void;
submitRefund: () => void;
updateInputValue: (inputKind: string, value: string) => void;
updateDeleteConfirmation: (checked: boolean) => void;
}
export function installServicesController(deps: ServicesControllerDeps): void {
document.addEventListener('click', function(event) {
var target = asHTMLElement(event.target)?.closest('[data-account-service-action]');
if (!target) return;
var action = target.getAttribute('data-account-service-action') || '';
var panelID = target.getAttribute('data-account-service-panel') || '';
var focusID = target.getAttribute('data-account-service-focus') || '';
switch (action) {
case 'open-service-panel':
event.preventDefault();
deps.toggleServicePanel(panelID);
deps.focusElement(focusID);
return;
case 'manage-inline-request':
event.preventDefault();
deps.requestVerificationCode('manage');
return;
case 'manage-inline-resend':
deps.resendVerificationCode('manage', event);
return;
case 'manage-inline-confirm':
event.preventDefault();
deps.confirmVerificationCode('manage');
return;
case 'retrieve-inline-request':
event.preventDefault();
deps.requestVerificationCode('retrieve');
return;
case 'retrieve-inline-confirm':
event.preventDefault();
deps.confirmVerificationCode('retrieve');
return;
case 'retrieve-inline-copy':
event.preventDefault();
deps.copyRetrievedLicense();
return;
case 'refund-inline-submit':
event.preventDefault();
deps.submitRefund();
return;
case 'data-export-request':
event.preventDefault();
deps.requestVerificationCode('export');
return;
case 'data-export-resend':
deps.resendVerificationCode('export', event);
return;
case 'data-export-confirm':
event.preventDefault();
deps.confirmVerificationCode('export');
return;
case 'data-delete-request':
event.preventDefault();
deps.requestVerificationCode('delete');
return;
case 'data-delete-resend':
deps.resendVerificationCode('delete', event);
return;
case 'data-delete-confirm':
event.preventDefault();
deps.confirmVerificationCode('delete');
return;
default:
return;
}
});
document.addEventListener('input', function(event) {
var target = asHTMLElement(event.target) as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | null;
if (!target) return;
var inputKind = target.getAttribute('data-account-service-input') || '';
if (!inputKind) return;
deps.updateInputValue(inputKind, target.value);
});
document.addEventListener('change', function(event) {
var target = asHTMLElement(event.target) as HTMLInputElement | null;
if (!target || target.id !== 'data-delete-confirm-check') return;
deps.updateDeleteConfirmation(!!target.checked);
});
}

View file

@ -0,0 +1,249 @@
import type { PortalBootstrapData, RefundState, ServiceStatus, VerificationFlowState } from './types';
type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
export function getElement<T extends HTMLElement = HTMLElement>(id: string): T | null {
return document.getElementById(id) as T | null;
}
export function asHTMLElement(target: EventTarget | null): HTMLElement | null {
return target instanceof HTMLElement ? target : null;
}
export function escapeText(value: unknown): string {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
export function escapeAttribute(value: unknown): string {
return escapeText(value)
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export function readValue(id: string): string {
var el = getElement<FormValueElement>(id);
return el ? el.value.trim() : '';
}
export function focusElement(id: string): void {
var el = getElement<FormValueElement>(id);
if (el) el.focus();
}
export function setVisible(id: string, visible: boolean): void {
var el = getElement(id);
if (el) {
el.style.display = visible ? 'block' : 'none';
}
}
export function setValue(id: string, value: string): void {
var el = getElement<FormValueElement>(id);
if (el) {
el.value = value;
}
}
export function renderStatus(id: string, status: ServiceStatus): void {
var el = getElement(id);
if (!el) return;
if (!status.visible) {
el.textContent = '';
el.className = 'service-status';
return;
}
el.textContent = status.message;
el.className = 'service-status visible' + (status.error ? ' error' : ' success');
}
export function renderButton(id: string | undefined, disabled: boolean, label: string | undefined): void {
if (!id || !label) return;
var button = getElement<HTMLButtonElement>(id);
if (!button) return;
button.disabled = disabled;
button.textContent = label;
}
export function renderOpenPanels(openPanelID: string): void {
var panels = ['manage-service-panel', 'retrieve-service-panel', 'refund-service-panel', 'data-service-panel'];
for (var i = 0; i < panels.length; i++) {
var panel = getElement(panels[i]);
if (!panel) continue;
panel.classList.toggle('visible', panels[i] === openPanelID);
}
}
export function renderRefundPanel(refundState: RefundState, bootstrap: PortalBootstrapData): void {
var root = getElement('refund-service-root');
if (!root) return;
var refundSupportURL = (bootstrap.public_site_url || '') + '/refund.html?email=' + encodeURIComponent(refundState.emailValue || '');
root.innerHTML = '' +
'<h3>Refund requests</h3>' +
'<p>Process an eligible self-serve refund for a self-hosted purchase. This revokes the associated license immediately.</p>' +
'<div class="warning"><strong>Warning:</strong> completing a refund immediately revokes the affected license. This should only be used when the refund window and commercial contract allow it.</div>' +
'<div class="form-group">' +
'<label for="refund-inline-email">Email address</label>' +
'<input type="email" id="refund-inline-email" value="' + escapeAttribute(refundState.emailValue || '') + '" autocomplete="email" data-account-service-input="refund-email">' +
'</div>' +
'<div class="form-group">' +
'<label for="refund-inline-token">License key</label>' +
'<input type="text" id="refund-inline-token" value="' + escapeAttribute(refundState.tokenValue || '') + '" placeholder="pulse_xxxxx" data-account-service-input="refund-token">' +
'</div>' +
'<div class="form-actions">' +
'<button class="btn-danger" type="button" id="refund-inline-submit" data-account-service-action="refund-inline-submit">Process Refund</button>' +
'</div>' +
'<div class="helper-text">If this purchase is not eligible for self-serve refund, use the public support path instead: <a href="' + escapeAttribute(refundSupportURL) + '">open refund support page</a>.</div>' +
'<div class="service-status" id="refund-inline-status"></div>';
}
export function renderManagePanel(flowState: VerificationFlowState): void {
var root = getElement('manage-service-root');
if (!root) return;
root.innerHTML = '' +
'<h3>Manage subscriptions</h3>' +
'<p>Request a verification code for the commercial email, then open the Stripe customer portal for billing changes, invoices, and subscription actions.</p>' +
'<div id="manage-inline-step1">' +
'<div class="form-group">' +
'<label for="manage-inline-email">Email address</label>' +
'<input type="email" id="manage-inline-email" value="' + escapeAttribute(flowState.emailValue || '') + '" autocomplete="email" data-account-service-input="manage-email">' +
'</div>' +
'<div class="form-actions">' +
'<button class="btn-primary" type="button" id="manage-inline-request" data-account-service-action="manage-inline-request">Send Verification Code</button>' +
'</div>' +
'</div>' +
'<div id="manage-inline-step2" style="display:' + (flowState.step2Visible ? 'block' : 'none') + '">' +
'<div class="form-group">' +
'<label for="manage-inline-code">Verification code</label>' +
'<input type="text" id="manage-inline-code" value="' + escapeAttribute(flowState.codeValue || '') + '" inputmode="numeric" pattern="[0-9]{6}" placeholder="123456" data-account-service-input="manage-code">' +
'</div>' +
'<div class="form-actions">' +
'<button class="btn-primary" type="button" id="manage-inline-confirm" data-account-service-action="manage-inline-confirm">Open Customer Portal</button>' +
'</div>' +
'<div class="helper-text">Need a new code? <a href="#" id="manage-inline-resend" data-account-service-action="manage-inline-resend">Send again</a></div>' +
'</div>' +
'<div class="service-status" id="manage-inline-status"></div>';
}
export function renderRetrievePanel(flowState: VerificationFlowState): void {
var root = getElement('retrieve-service-root');
if (!root) return;
var result = flowState.result as {
invoice_url?: string;
token?: string;
tier?: string;
issued_at?: string;
expires_at?: string | null;
email?: string;
} | null;
var invoiceURL = result && result.invoice_url ? result.invoice_url : '#';
var invoiceDisplay = result && result.invoice_url ? 'inline-block' : 'none';
var copyDisplay = result ? 'inline-block' : 'none';
var resultDisplay = result ? 'block' : 'none';
root.innerHTML = '' +
'<h3>Retrieve licenses</h3>' +
'<p>Request a verification code for the commercial email, then reveal the current active self-hosted license without leaving Pulse Account.</p>' +
'<div id="retrieve-inline-step1">' +
'<div class="form-group">' +
'<label for="retrieve-inline-email">Email address</label>' +
'<input type="email" id="retrieve-inline-email" value="' + escapeAttribute(flowState.emailValue || '') + '" autocomplete="email" data-account-service-input="retrieve-email">' +
'</div>' +
'<div class="form-actions">' +
'<button class="btn-primary" type="button" id="retrieve-inline-request" data-account-service-action="retrieve-inline-request">Send Verification Code</button>' +
'</div>' +
'</div>' +
'<div id="retrieve-inline-step2" style="display:' + (flowState.step2Visible ? 'block' : 'none') + '">' +
'<div class="form-group">' +
'<label for="retrieve-inline-code">Verification code</label>' +
'<input type="text" id="retrieve-inline-code" value="' + escapeAttribute(flowState.codeValue || '') + '" inputmode="numeric" pattern="[0-9]{6}" placeholder="123456" data-account-service-input="retrieve-code">' +
'</div>' +
'<div class="form-actions">' +
'<button class="btn-primary" type="button" id="retrieve-inline-confirm" data-account-service-action="retrieve-inline-confirm">Show License</button>' +
'<button class="btn-secondary" type="button" id="retrieve-inline-copy" data-account-service-action="retrieve-inline-copy" style="display:' + copyDisplay + '">Copy License Key</button>' +
'<a class="btn-secondary" id="retrieve-inline-invoice" href="' + escapeAttribute(invoiceURL) + '" target="_blank" rel="noopener" style="display:' + invoiceDisplay + '">View Invoice</a>' +
'</div>' +
'<div class="helper-text">Use the latest active self-hosted license for this commercial email.</div>' +
'</div>' +
'<div class="service-status" id="retrieve-inline-status"></div>' +
'<div id="retrieve-inline-result" style="display:' + resultDisplay + '; margin-top:14px">' +
'<label for="retrieve-inline-token">License key</label>' +
'<textarea id="retrieve-inline-token" readonly>' + escapeText(result ? result.token : '') + '</textarea>' +
'<div class="result-grid">' +
'<div><div class="result-meta-label">Plan</div><div class="result-meta-value" id="retrieve-inline-tier">' + escapeText(result ? result.tier : '') + '</div></div>' +
'<div><div class="result-meta-label">Issued</div><div class="result-meta-value" id="retrieve-inline-issued">' + escapeText(result ? new Date(result.issued_at).toLocaleString() : '') + '</div></div>' +
'<div><div class="result-meta-label">Expires</div><div class="result-meta-value" id="retrieve-inline-expires">' + escapeText(result ? (result.expires_at ? new Date(result.expires_at).toLocaleString() : 'Does not expire') : '') + '</div></div>' +
'<div><div class="result-meta-label">Purchase Email</div><div class="result-meta-value" id="retrieve-inline-email-value">' + escapeText(result ? result.email : '') + '</div></div>' +
'</div>' +
'</div>';
}
export function renderExportPanel(flowState: VerificationFlowState): void {
var root = getElement('data-export-root');
if (!root) return;
var resultDisplay = flowState.result ? 'block' : 'none';
root.innerHTML = '' +
'<h4>Export My Data</h4>' +
'<div id="data-export-step1">' +
'<div class="form-group">' +
'<label for="data-export-email">Email address</label>' +
'<input type="email" id="data-export-email" value="' + escapeAttribute(flowState.emailValue || '') + '" autocomplete="email" data-account-service-input="data-export-email">' +
'</div>' +
'<div class="form-actions">' +
'<button class="btn-primary" type="button" id="data-export-request" data-account-service-action="data-export-request">Send Verification Code</button>' +
'</div>' +
'</div>' +
'<div id="data-export-step2" style="display:' + (flowState.step2Visible ? 'block' : 'none') + '">' +
'<div class="form-group">' +
'<label for="data-export-code">Verification code</label>' +
'<input type="text" id="data-export-code" value="' + escapeAttribute(flowState.codeValue || '') + '" inputmode="numeric" pattern="[0-9]{6}" placeholder="123456" data-account-service-input="data-export-code">' +
'</div>' +
'<div class="form-actions">' +
'<button class="btn-primary" type="button" id="data-export-confirm" data-account-service-action="data-export-confirm">Export My Data</button>' +
'</div>' +
'<div class="helper-text">Need a new code? <a href="#" id="data-export-resend" data-account-service-action="data-export-resend">Send again</a></div>' +
'</div>' +
'<div class="service-status" id="data-export-status"></div>' +
'<div id="data-export-result" style="display:' + resultDisplay + '; margin-top:14px">' +
'<label for="data-export-payload">Export payload</label>' +
'<textarea id="data-export-payload" readonly>' + escapeText(flowState.result ? JSON.stringify(flowState.result, null, 2) : '') + '</textarea>' +
'</div>';
}
export function renderExportResult(result: unknown): void {
setVisible('data-export-result', !!result);
setValue('data-export-payload', result ? JSON.stringify(result, null, 2) : '');
}
export function renderDeletePanel(flowState: VerificationFlowState): void {
var root = getElement('data-delete-root');
if (!root) return;
root.innerHTML = '' +
'<h4>Delete My Data</h4>' +
'<div class="warning"><strong>Warning:</strong> deleting commercial data also revokes license records and cannot be undone.</div>' +
'<div id="data-delete-step1">' +
'<div class="form-group">' +
'<label for="data-delete-email">Email address</label>' +
'<input type="email" id="data-delete-email" value="' + escapeAttribute(flowState.emailValue || '') + '" autocomplete="email" data-account-service-input="data-delete-email">' +
'</div>' +
'<div class="form-actions">' +
'<button class="btn-danger" type="button" id="data-delete-request" data-account-service-action="data-delete-request">Send Verification Code</button>' +
'</div>' +
'</div>' +
'<div id="data-delete-step2" style="display:' + (flowState.step2Visible ? 'block' : 'none') + '">' +
'<div class="form-group">' +
'<label for="data-delete-code">Verification code</label>' +
'<input type="text" id="data-delete-code" value="' + escapeAttribute(flowState.codeValue || '') + '" inputmode="numeric" pattern="[0-9]{6}" placeholder="123456" data-account-service-input="data-delete-code">' +
'</div>' +
'<div class="checkbox-row">' +
'<input type="checkbox" id="data-delete-confirm-check"' + (flowState.checkboxChecked ? ' checked' : '') + '>' +
'<span>I understand this permanently deletes my commercial data and revokes associated licenses.</span>' +
'</div>' +
'<div class="form-actions">' +
'<button class="btn-danger" type="button" id="data-delete-confirm" data-account-service-action="data-delete-confirm">Delete My Data</button>' +
'</div>' +
'<div class="helper-text">Need a new code? <a href="#" id="data-delete-resend" data-account-service-action="data-delete-resend">Send again</a></div>' +
'</div>' +
'<div class="service-status" id="data-delete-status"></div>';
}