feca-mailshots-plugin/feca_mailshots_plugin/src/Admin/AdminRequestHelpers.php

365 lines
21 KiB
PHP

<?php
declare(strict_types=1);
namespace FecaMailshots\Admin;
trait AdminRequestHelpers
{
private function renderAdminUiStyles(): string
{
return '<style id="feca-mailshots-admin-ui">'
. '.feca-mailshots-admin{--feca-space-1:6px;--feca-space-2:10px;--feca-space-3:14px;--feca-space-4:18px;--feca-radius:4px;}'
. '.feca-mailshots-admin h1{margin-bottom:var(--feca-space-3);}'
. '.feca-mailshots-admin h2{margin:var(--feca-space-4) 0 var(--feca-space-2) 0;line-height:1.3;}'
. '.feca-mailshots-admin h3{margin:var(--feca-space-3) 0 var(--feca-space-2) 0;line-height:1.3;}'
. '.feca-mailshots-admin p{line-height:1.45;}'
. '.feca-mailshots-admin .feca-panel{padding:var(--feca-space-3);border:1px solid #dcdcde;background:#fff;margin-bottom:var(--feca-space-3);border-radius:var(--feca-radius);}'
. '.feca-mailshots-admin .feca-panel-tight{margin-bottom:0;}'
. '.feca-mailshots-admin .feca-panel-flex{flex:1;margin:0;}'
. '.feca-mailshots-admin .feca-control-row{display:flex;gap:var(--feca-space-3);align-items:flex-end;flex-wrap:wrap;margin:var(--feca-space-2) 0;}'
. '.feca-mailshots-admin .feca-control{display:flex;align-items:center;gap:var(--feca-space-2);min-width:260px;padding:var(--feca-space-1) 0;}'
. '.feca-mailshots-admin .feca-control-min-140{min-width:140px;}'
. '.feca-mailshots-admin .feca-control-min-170{min-width:170px;}'
. '.feca-mailshots-admin .feca-control-min-220{min-width:220px;}'
. '.feca-mailshots-admin .feca-control-min-240{min-width:240px;}'
. '.feca-mailshots-admin .feca-control-min-260{min-width:260px;}'
. '.feca-mailshots-admin .feca-control-min-280{min-width:280px;}'
. '.feca-mailshots-admin .feca-control-min-320{min-width:320px;}'
. '.feca-mailshots-admin .feca-control-min-360{min-width:360px;}'
. '.feca-mailshots-admin .feca-control-min-380{min-width:380px;}'
. '.feca-mailshots-admin .feca-control-min-420{min-width:420px;}'
. '.feca-mailshots-admin .feca-minw-140{min-width:140px;}'
. '.feca-mailshots-admin .feca-minw-170{min-width:170px;}'
. '.feca-mailshots-admin .feca-minw-220{min-width:220px;}'
. '.feca-mailshots-admin .feca-minw-240{min-width:240px;}'
. '.feca-mailshots-admin .feca-control input,.feca-mailshots-admin .feca-control select,.feca-mailshots-admin .feca-control textarea{margin:0;}'
. '.feca-mailshots-admin .feca-control label{white-space:nowrap;padding-left:var(--feca-space-1);font-weight:600;}'
. '.feca-mailshots-admin .feca-inline-control-group{display:flex;align-items:flex-end;gap:var(--feca-space-2);flex-wrap:wrap;padding:8px 10px;border:1px solid #dcdcde;background:#f6f7f7;border-radius:var(--feca-radius);}'
. '.feca-mailshots-admin .feca-inline-control-group .feca-control{padding:0;}'
. '.feca-mailshots-admin .feca-button-row{display:flex;gap:var(--feca-space-2);align-items:center;flex-wrap:wrap;margin-top:var(--feca-space-3);margin-bottom:0;padding:0;}'
. '.feca-mailshots-admin .feca-button-row .button{margin:0 !important;}'
. '.feca-mailshots-admin .feca-two-line-button{height:auto;min-height:44px;line-height:1.25;white-space:normal;max-width:260px;text-align:center;padding-top:6px;padding-bottom:6px;}'
. '.feca-mailshots-admin .feca-button-danger{color:#b32d2e;border-color:#d63638;}'
. '.feca-mailshots-admin .feca-button-danger:hover{color:#8a2424;border-color:#8a2424;background:#fff5f5;}'
. '.feca-mailshots-admin .feca-banner{padding:var(--feca-space-2) var(--feca-space-3);border:1px solid #dcdcde;background:#fff;margin:var(--feca-space-3) 0;border-radius:var(--feca-radius);}'
. '.feca-mailshots-admin .feca-banner-success{border-color:#8bc34a;background:#f1f8e9;}'
. '.feca-mailshots-admin .feca-banner-error{border-color:#ef9a9a;background:#ffebee;}'
. '.feca-mailshots-admin .feca-banner-info{border-color:#c8d7e1;background:#f6fbff;}'
. '.feca-mailshots-admin .feca-banner-muted{background:#f9f9f9;}'
. '.feca-mailshots-admin .feca-banner-note{margin:var(--feca-space-1) 0 0 0;}'
. '.feca-mailshots-admin .feca-note-danger{color:#b32d2e;}'
. '.feca-mailshots-admin .feca-form{padding:var(--feca-space-3);border:1px solid #dcdcde;background:#fff;margin-bottom:var(--feca-space-3);border-radius:var(--feca-radius);}'
. '.feca-mailshots-admin .feca-form-max-860{max-width:860px;}'
. '.feca-mailshots-admin .feca-form .form-table{margin-top:0;}'
. '.feca-mailshots-admin .feca-form .form-table th,.feca-mailshots-admin .feca-form .form-table td{padding-top:12px;padding-bottom:12px;}'
. '.feca-mailshots-admin .feca-form .form-table th{width:220px;}'
. '.feca-mailshots-admin .feca-form .description{margin-top:var(--feca-space-1);}'
. '.feca-mailshots-admin .feca-password-control{display:flex;align-items:center;gap:8px;max-width:34rem;}'
. '.feca-mailshots-admin .feca-password-control .regular-text{flex:1;min-width:220px;margin:0;}'
. '.feca-mailshots-admin .feca-password-toggle{min-width:74px;text-align:center;}'
. '.feca-mailshots-admin .feca-inline-form{display:inline;}'
. '.feca-mailshots-admin .feca-hidden{display:none;}'
. '.feca-mailshots-admin .feca-meta-line{margin:4px 0 var(--feca-space-3) 0;color:#6b7280;font-size:12px;}'
. '.feca-mailshots-admin .feca-modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;}'
. '.feca-mailshots-admin .feca-modal-overlay-high{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:9999;}'
. '.feca-mailshots-admin .feca-modal-shell{max-width:980px;margin:30px auto;background:#fff;padding:var(--feca-space-3);max-height:88vh;overflow:auto;border-radius:var(--feca-radius);}'
. '.feca-mailshots-admin .feca-modal-shell-wide{max-width:1100px;}'
. '.feca-mailshots-admin .feca-modal-shell-narrow{max-width:900px;}'
. '.feca-mailshots-admin .feca-modal-title{margin-top:0;}'
. '.feca-mailshots-admin .feca-section-title{margin:0 0 8px 0;}'
. '.feca-mailshots-admin .feca-modal-subtitle{margin:0 0 8px 0;}'
. '.feca-mailshots-admin .feca-close-row{justify-content:flex-end;margin-top:0;}'
. '.feca-mailshots-admin .feca-truncate-220{max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}'
. '.feca-mailshots-admin .feca-truncate-360{max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}'
. '.feca-mailshots-admin .feca-truncate-420{max-width:420px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}'
. '.feca-mailshots-admin .feca-warning-inline{display:none;margin-top:8px;padding:8px;border-left:4px solid #dba617;background:#fff8e1;}'
. '.feca-mailshots-admin .feca-progress-box{display:none;margin-top:var(--feca-space-2);padding:var(--feca-space-2);border:1px solid #c8d7e1;background:#f6fbff;max-width:520px;border-radius:var(--feca-radius);}'
. '.feca-mailshots-admin .feca-builder-shell{max-width:980px;margin:30px auto;background:#fff;padding:14px;border-radius:4px;max-height:88vh;overflow:auto;}'
. '.feca-mailshots-admin .feca-builder-grid{display:flex;gap:14px;flex-wrap:wrap;}'
. '.feca-mailshots-admin .feca-builder-col-left{flex:1;min-width:260px;border:1px solid #dcdcde;padding:10px;}'
. '.feca-mailshots-admin .feca-builder-col-right{flex:2;min-width:340px;border:1px solid #dcdcde;padding:10px;}'
. '.feca-mailshots-admin .feca-builder-col-left label,.feca-mailshots-admin .feca-builder-col-right label{display:inline-flex;align-items:center;gap:6px;margin:4px 0;}'
. '.feca-mailshots-admin .feca-builder-col-left select,.feca-mailshots-admin .feca-builder-col-right select,.feca-mailshots-admin .feca-builder-col-left input,.feca-mailshots-admin .feca-builder-col-right input{margin:6px 0;}'
. '.feca-mailshots-admin .feca-builder-row{border:1px solid #dcdcde;padding:10px;margin-bottom:10px;}'
. '.feca-mailshots-admin .feca-builder-row > *{margin:6px 8px 6px 0;}'
. '.feca-mailshots-admin .feca-builder-filter-select{display:block;min-width:260px;}'
. '.feca-mailshots-admin .feca-builder-output{margin-top:12px;border:1px solid #dcdcde;padding:10px;background:#f9f9f9;}'
. '.feca-mailshots-admin .feca-error-text{color:#a00;margin:6px 0 0 0;}'
. '.feca-mailshots-admin .feca-template-panel{max-width:980px;margin:30px auto;background:#fff;padding:12px;height:88vh;max-height:88vh;display:flex;flex-direction:column;overflow:hidden;}'
. '.feca-mailshots-admin .feca-template-title{margin:0 0 8px 0;flex:0 0 auto;}'
. '.feca-mailshots-admin .feca-template-error{display:none;margin:8px 0;padding:10px;border:1px solid #ef9a9a;background:#ffebee;}'
. '.feca-mailshots-admin .feca-template-token-controls{margin-bottom:10px;}'
. '.feca-mailshots-admin .feca-template-assets{display:none;margin-bottom:10px;}'
. '.feca-mailshots-admin .feca-template-editor-wrap{flex:1 1 auto;min-height:320px;height:60vh;max-height:60vh;overflow:auto;}'
. '.feca-mailshots-admin .feca-template-editor{height:100%;}'
. '.feca-mailshots-admin .feca-template-actions{margin:10px 0 0 0;flex:0 0 auto;}'
. '.feca-mailshots-admin .feca-template-panel .jodit-container{height:100% !important;}'
. '.feca-mailshots-admin .feca-details{margin-top:8px;}'
. '.feca-mailshots-admin .feca-details pre{margin-top:8px;}'
. '.feca-mailshots-admin .feca-scroll-frame{border:1px solid #dcdcde;background:#fff;max-width:calc(100vw - 80px);border-radius:var(--feca-radius);margin-top:var(--feca-space-2);}'
. '.feca-mailshots-admin .feca-scroll-pane{overflow:scroll;max-height:68vh;scrollbar-gutter:stable both-edges;}'
. '.feca-mailshots-admin .feca-table-wide{min-width:100%;width:max-content;margin:0;border-collapse:separate;border-spacing:0;}'
. '.feca-mailshots-admin .feca-rr-head-cell{position:sticky;top:0;z-index:2;background:#f6f7f7;white-space:normal;vertical-align:bottom;max-width:170px;padding:8px;}'
. '.feca-mailshots-admin .feca-rr-head-wrap{line-height:1.2;}'
. '.feca-mailshots-admin .feca-rr-head-prefix{display:block;font-size:11px;color:#646970;word-break:break-word;}'
. '.feca-mailshots-admin .feca-rr-head-field{display:block;font-size:12px;font-weight:600;word-break:break-word;}'
. '.feca-mailshots-admin .feca-rr-data-cell{white-space:nowrap;max-width:20ch;width:20ch;min-width:20ch;overflow:hidden;text-overflow:ellipsis;}'
. '.feca-mailshots-admin .feca-rr-row-selected td{background:#e8f4ff;}'
. '.feca-mailshots-admin .feca-preview-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;}'
. '.feca-mailshots-admin .feca-preview-iframe{width:100%;height:320px;border:1px solid #dcdcde;background:#fff;}'
. '@media (max-width: 782px){'
. '.feca-mailshots-admin .feca-control-row{display:block;}'
. '.feca-mailshots-admin .feca-control{min-width:0;width:100%;}'
. '.feca-mailshots-admin .feca-inline-control-group{display:block;}'
. '}'
. '</style>';
}
private function requestMethod(): string
{
$method = isset($_SERVER['REQUEST_METHOD']) ? (string) $_SERVER['REQUEST_METHOD'] : 'GET';
return strtoupper(trim($method));
}
private function requestString(string $name, string $default = ''): string
{
return (string) ($this->wp->requestParam($name, $default) ?? $default);
}
private function requestInt(string $name, int $default = 0): int
{
return (int) ($this->wp->requestParam($name, (string) $default) ?? (string) $default);
}
private function requestOptionalInt(string $name = 'id'): ?int
{
$raw = trim($this->requestString($name, ''));
return $raw === '' ? null : (int) $raw;
}
private function enforceCapabilityOrJson(string $capability): bool
{
if ($this->wp->currentUserCan($capability)) {
return true;
}
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
return false;
}
private function enforcePostOrJson(): bool
{
if ($this->requestMethod() === 'POST') {
return true;
}
$this->wp->sendJson(['ok' => false, 'error' => 'Method not allowed. Use POST.'], 405);
return false;
}
private function enforceNonceOrJson(string $nonceAction): bool
{
$nonce = trim($this->requestString('_wpnonce', ''));
if ($nonce !== '' && $this->wp->verifyNonce($nonce, $nonceAction)) {
return true;
}
$this->wp->sendJson(['ok' => false, 'error' => 'Invalid or missing nonce.'], 403);
return false;
}
private function enforceMutationGuardOrJson(string $capability, string $nonceAction): bool
{
if ($this->mutationGuardValid($capability, $nonceAction)) {
return true;
}
if (!$this->wp->currentUserCan($capability)) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
return false;
}
if ($this->requestMethod() !== 'POST') {
$this->wp->sendJson(['ok' => false, 'error' => 'Method not allowed. Use POST.'], 405);
return false;
}
$this->wp->sendJson(['ok' => false, 'error' => 'Invalid or missing nonce.'], 403);
return false;
}
private function mutationGuardValid(string $capability, string $nonceAction): bool
{
if (!$this->wp->currentUserCan($capability)) {
return false;
}
if ($this->requestMethod() !== 'POST') {
return false;
}
$nonce = trim($this->requestString('_wpnonce', ''));
if ($nonce === '') {
return false;
}
return $this->wp->verifyNonce($nonce, $nonceAction);
}
private function hiddenNonceField(string $nonceAction): string
{
return '<input type="hidden" name="_wpnonce" value="'
. htmlspecialchars($this->wp->createNonce($nonceAction), ENT_QUOTES)
. '">';
}
private function redirectTo(string $url): void
{
if (!headers_sent()) {
header('Location: ' . $url, true, 302);
exit;
}
}
private function redirectToAdminPage(string $page, ?int $editId = null): void
{
$url = $this->wp->adminUrl('admin.php?page=' . $page . ($editId !== null && $editId > 0 ? '&edit_id=' . $editId : ''));
$this->redirectTo($url);
}
private function uploadedFileToBase64(string $fileField): ?string
{
if (!isset($_FILES[$fileField]) || !is_array($_FILES[$fileField])) {
return null;
}
$error = (int) ($_FILES[$fileField]['error'] ?? UPLOAD_ERR_NO_FILE);
if ($error === UPLOAD_ERR_NO_FILE) {
return null;
}
if ($error !== UPLOAD_ERR_OK) {
throw new \RuntimeException('File upload failed for "' . $fileField . '" with error code ' . $error . '.');
}
$tmp = (string) ($_FILES[$fileField]['tmp_name'] ?? '');
if ($tmp === '' || !is_file($tmp)) {
throw new \RuntimeException('Uploaded file for "' . $fileField . '" is not available.');
}
$bytes = file_get_contents($tmp);
if ($bytes === false) {
throw new \RuntimeException('Unable to read uploaded file for "' . $fileField . '".');
}
return base64_encode($bytes);
}
/** @return array<string,mixed>|null */
private function consumeOptionArray(string $optionKey): ?array
{
$raw = $this->wp->getOption($optionKey, null);
$this->wp->deleteOption($optionKey);
return is_array($raw) ? $raw : null;
}
/**
* Persists result banner payload and draft state in a consistent way:
* - always stores result payload
* - clears draft on success
* - stores draft on failure when provided
* @param array<string,mixed> $result
* @param array<string,mixed>|null $draft
*/
private function persistResultAndDraft(string $resultOptionKey, string $draftOptionKey, array $result, ?array $draft): void
{
$this->wp->updateOption($resultOptionKey, $result);
if (!empty($result['ok'])) {
$this->wp->deleteOption($draftOptionKey);
return;
}
if (is_array($draft)) {
$this->wp->updateOption($draftOptionKey, $draft);
}
}
/** @param array<string,mixed>|null $result */
private function modalErrorHtml(?array $result): string
{
if ($result === null || !empty($result['ok']) || empty($result['errors']) || !is_array($result['errors'])) {
return '';
}
$html = '<div class="feca-banner feca-banner-error">';
$html .= '<strong>Please fix the following:</strong><ul>';
foreach ($result['errors'] as $error) {
$html .= '<li>' . htmlspecialchars((string) $error) . '</li>';
}
$html .= '</ul></div>';
return $html;
}
/**
* @param callable():array<string,mixed> $list
* @param callable():array<string,mixed> $save
* @param callable():array<string,mixed> $delete
*/
private function handleBasicCrudApi(string $op, callable $list, callable $save, callable $delete): void
{
if ($op === 'list') {
$this->wp->sendJson($list());
return;
}
if ($op === 'save') {
$this->wp->sendJson($save());
return;
}
if ($op === 'delete') {
$this->wp->sendJson($delete());
return;
}
$this->wp->sendJson(['ok' => false, 'error' => 'Unknown operation'], 400);
}
/**
* @param list<string> $fieldIds
*/
private function modalEditorScript(
string $modalId,
string $openButtonId,
string $closeButtonId,
string $formId,
string $editorIdFieldId,
array $fieldIds,
bool $hasEdit,
bool $hasDraft,
bool $hasResult,
bool $resultOk,
?string $defaultSelectFieldId = null,
?string $defaultSelectValue = null,
?string $uploadFieldId = null,
?string $fileNameFieldId = null
): string {
$cfg = [
'modalId' => $modalId,
'openButtonId' => $openButtonId,
'closeButtonId' => $closeButtonId,
'formId' => $formId,
'editorIdFieldId' => $editorIdFieldId,
'fieldIds' => array_values($fieldIds),
'hasEdit' => $hasEdit,
'hasDraft' => $hasDraft,
'hasResult' => $hasResult,
'resultOk' => $resultOk,
'defaultSelectFieldId' => $defaultSelectFieldId,
'defaultSelectValue' => $defaultSelectValue,
'uploadFieldId' => $uploadFieldId,
'fileNameFieldId' => $fileNameFieldId,
];
return '<script>(function(){'
. 'var cfg=' . json_encode($cfg, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';'
. 'var modal=document.getElementById(cfg.modalId);'
. 'var openBtn=document.getElementById(cfg.openButtonId);'
. 'var closeBtn=document.getElementById(cfg.closeButtonId);'
. 'var form=document.getElementById(cfg.formId);'
. 'var isDirty=false;'
. 'function confirmDiscard(){if(!isDirty){return true;}return window.confirm("You have unsaved changes. Close without saving?");}'
. 'if(form){form.querySelectorAll("input,select,textarea").forEach(function(el){el.addEventListener("input",function(){isDirty=true;});el.addEventListener("change",function(){isDirty=true;});});form.addEventListener("submit",function(){isDirty=false;});}'
. 'if(cfg.uploadFieldId&&cfg.fileNameFieldId){var upload=document.getElementById(cfg.uploadFieldId);var fileNameInput=document.getElementById(cfg.fileNameFieldId);if(upload&&fileNameInput){upload.addEventListener("change",function(){var file=(upload.files&&upload.files[0])?upload.files[0]:null;if(!file||String(file.name||"").trim()===""){return;}if(String(fileNameInput.value||"").trim()===""){fileNameInput.value=String(file.name||"");isDirty=true;}});}}'
. 'if(openBtn&&modal){openBtn.addEventListener("click",function(ev){if(ev&&typeof ev.preventDefault==="function"){ev.preventDefault();}var id=document.getElementById(cfg.editorIdFieldId);if(id){id.value="";}(cfg.fieldIds||[]).forEach(function(x){var el=document.getElementById(String(x||""));if(el){el.value="";}});if(cfg.defaultSelectFieldId){var j=document.getElementById(cfg.defaultSelectFieldId);if(j){j.value=String(cfg.defaultSelectValue||"");}}isDirty=false;modal.style.display="block";});}'
. 'if(closeBtn&&modal){closeBtn.addEventListener("click",function(ev){if(ev&&typeof ev.preventDefault==="function"){ev.preventDefault();}if(!confirmDiscard()){return;}modal.style.display="none";});}'
. 'if((cfg.hasEdit||cfg.hasDraft)&&modal&&(!cfg.hasResult||!cfg.resultOk)){modal.style.display="block";isDirty=false;}'
. '})();</script>';
}
}