' . '.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;}' . '}' . ''; } 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 ''; } 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|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 $result * @param array|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|null $result */ private function modalErrorHtml(?array $result): string { if ($result === null || !empty($result['ok']) || empty($result['errors']) || !is_array($result['errors'])) { return ''; } $html = '
'; $html .= 'Please fix the following:
    '; foreach ($result['errors'] as $error) { $html .= '
  • ' . htmlspecialchars((string) $error) . '
  • '; } $html .= '
'; return $html; } /** * @param callable():array $list * @param callable():array $save * @param callable():array $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 $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 ''; } }