class-congress-admin.php
1 week ago
class-congress-api.php
1 week ago
class-congress-page.php
1 week ago
class-congress-repository.php
1 week ago
class-template-variables.php
1 week ago
class-congress-admin.php
398 lines
| 1 | <?php |
| 2 | if (!defined('ABSPATH')) exit; |
| 3 | |
| 4 | class sasoEventtickets_CongressAdmin { |
| 5 | |
| 6 | private sasoEventtickets $MAIN; |
| 7 | |
| 8 | public function __construct(sasoEventtickets $main) { |
| 9 | $this->MAIN = $main; |
| 10 | } |
| 11 | |
| 12 | public function registerMenu(): void { |
| 13 | // Register as hidden admin page (no sidebar entry) — accessible via the plugin's own nav button |
| 14 | add_submenu_page( |
| 15 | null, |
| 16 | __('Congresses', 'event-tickets-with-ticket-scanner'), |
| 17 | __('Congresses', 'event-tickets-with-ticket-scanner'), |
| 18 | 'manage_options', |
| 19 | 'saso-et-congresses', |
| 20 | [$this, 'renderPage'] |
| 21 | ); |
| 22 | } |
| 23 | |
| 24 | public function renderPage(): void { |
| 25 | if (!current_user_can('manage_options')) wp_die('Forbidden'); |
| 26 | wp_enqueue_script( |
| 27 | 'saso-et-congress-admin', |
| 28 | plugin_dir_url(dirname(dirname(__FILE__))) . 'js/congress-admin.js', |
| 29 | ['jquery', 'jquery-ui-sortable', 'wp-i18n'], |
| 30 | SASO_EVENTTICKETS_PLUGIN_VERSION, |
| 31 | true |
| 32 | ); |
| 33 | // congress-admin.css relies on the shared --et-* design tokens defined in |
| 34 | // styles_backend.css; load it first so borders/colors render on this standalone page too. |
| 35 | $backend_handle = $this->MAIN->getPrefix() . '_backendcss'; |
| 36 | wp_enqueue_style( |
| 37 | $backend_handle, |
| 38 | plugin_dir_url(dirname(dirname(__FILE__))) . 'css/styles_backend.css', |
| 39 | [], |
| 40 | SASO_EVENTTICKETS_PLUGIN_VERSION |
| 41 | ); |
| 42 | wp_set_script_translations( |
| 43 | 'saso-et-congress-admin', |
| 44 | 'event-tickets-with-ticket-scanner', |
| 45 | plugin_dir_path(dirname(dirname(__FILE__))) . 'languages' |
| 46 | ); |
| 47 | wp_enqueue_style( |
| 48 | 'saso-et-congress-admin', |
| 49 | plugin_dir_url(dirname(dirname(__FILE__))) . 'css/congress-admin.css', |
| 50 | [$backend_handle], |
| 51 | SASO_EVENTTICKETS_PLUGIN_VERSION |
| 52 | ); |
| 53 | require_once dirname(__FILE__) . '/class-template-variables.php'; |
| 54 | wp_localize_script('saso-et-congress-admin', 'sasoEtCongress', [ |
| 55 | 'ajaxUrl' => admin_url('admin-ajax.php'), |
| 56 | 'nonce' => wp_create_nonce('sasoEventtickets'), |
| 57 | 'variables' => sasoEventtickets_TemplateVariables::getList(), |
| 58 | ]); |
| 59 | echo '<div id="saso-et-congress-app"></div>'; |
| 60 | } |
| 61 | |
| 62 | public function handleAjax(): void { |
| 63 | check_ajax_referer('sasoEventtickets'); |
| 64 | if (!current_user_can('manage_options')) wp_send_json_error('forbidden', 403); |
| 65 | |
| 66 | $congress_action = sanitize_key($_POST['congress_action'] ?? ''); |
| 67 | $repo = $this->MAIN->getCongressRepository(); |
| 68 | |
| 69 | switch ($congress_action) { |
| 70 | |
| 71 | case 'list': |
| 72 | $all = $repo->getAll(); |
| 73 | $data = []; |
| 74 | foreach ($all as $c) { |
| 75 | $product_ids = $repo->getProductIds((int)$c['id']); |
| 76 | $expired = !empty($c['access_expires_at']) && strtotime($c['access_expires_at']) < time(); |
| 77 | $row = [ |
| 78 | 'id' => (int)$c['id'], |
| 79 | 'title' => esc_html($c['title']), |
| 80 | 'label' => esc_html($c['label'] ?? ''), |
| 81 | 'slug' => esc_html($c['slug']), |
| 82 | 'product_count' => count($product_ids), |
| 83 | 'updated_at' => esc_html($c['updated_at']), |
| 84 | 'access_expires_at' => esc_html($c['access_expires_at'] ?? ''), |
| 85 | 'status' => $expired ? 'expired' : 'active', |
| 86 | ]; |
| 87 | // Generic seam: premium can enrich each admin list row (e.g. add an export_url). |
| 88 | $row = apply_filters($this->MAIN->_add_filter_prefix.'congress_admin_list_row', $row, $c); |
| 89 | $data[] = $row; |
| 90 | } |
| 91 | // Raw JSON for DataTables server-side processing (no WP success wrapper) |
| 92 | wp_send_json(['draw' => 1, 'recordsTotal' => count($data), 'recordsFiltered' => count($data), 'data' => $data]); |
| 93 | break; |
| 94 | |
| 95 | case 'get': |
| 96 | $id = (int)($_POST['id'] ?? 0); |
| 97 | if (!$id) wp_send_json_error('missing_id'); |
| 98 | $congress = $repo->getById($id); |
| 99 | if (!$congress) wp_send_json_error('not_found'); |
| 100 | $congress['product_ids'] = $repo->getProductIds($id); |
| 101 | $congress['sections'] = $repo->getSections($id); |
| 102 | $congress['landing_cards'] = $repo->getCongressMeta($congress)['landing_cards']; |
| 103 | // Generic seam: premium can enrich the editor payload with its own fields. |
| 104 | $congress = apply_filters($this->MAIN->_add_filter_prefix.'congress_admin_get', $congress); |
| 105 | wp_send_json_success($congress); |
| 106 | break; |
| 107 | |
| 108 | case 'save': |
| 109 | $id = (int)($_POST['id'] ?? 0); |
| 110 | $slug = sanitize_title($_POST['slug'] ?? ''); |
| 111 | $title = sanitize_text_field($_POST['title'] ?? ''); |
| 112 | $expires = sanitize_text_field($_POST['access_expires_at'] ?? ''); |
| 113 | $event_start = sanitize_text_field($_POST['event_start_at'] ?? ''); |
| 114 | $event_end = sanitize_text_field($_POST['event_end_at'] ?? ''); |
| 115 | $is_active = isset($_POST['is_active']) ? (int) (!empty($_POST['is_active']) && $_POST['is_active'] !== '0') : 1; |
| 116 | $label = sanitize_text_field($_POST['label'] ?? ''); |
| 117 | $landing = !empty($_POST['landing_cards']) && $_POST['landing_cards'] !== '0'; |
| 118 | if (empty($slug) || empty($title)) { |
| 119 | wp_send_json_error('missing_fields'); |
| 120 | } |
| 121 | // Preserve existing meta, set landing_cards. |
| 122 | $existing = $id ? $repo->getById($id) : null; |
| 123 | $meta = ($existing && !empty($existing['meta'])) ? (json_decode($existing['meta'], true) ?: []) : []; |
| 124 | $meta['landing_cards'] = $landing; |
| 125 | // Generic seam: premium can read its own $_POST fields and add keys to $meta. |
| 126 | $meta = apply_filters($this->MAIN->_add_filter_prefix.'congress_save_meta', $meta, $_POST); |
| 127 | $saved_id = $repo->save([ |
| 128 | 'id' => $id ?: null, |
| 129 | 'slug' => $slug, |
| 130 | 'title' => $title, |
| 131 | 'label' => $label, |
| 132 | 'access_expires_at' => $expires ?: null, |
| 133 | 'event_start_at' => $event_start ?: null, |
| 134 | 'event_end_at' => $event_end ?: null, |
| 135 | 'is_active' => $is_active, |
| 136 | 'meta' => $meta, |
| 137 | ]); |
| 138 | if (!$saved_id) wp_send_json_error('save_failed'); |
| 139 | wp_send_json_success(['id' => $saved_id]); |
| 140 | break; |
| 141 | |
| 142 | case 'delete': |
| 143 | $id = (int)($_POST['id'] ?? 0); |
| 144 | if (!$id) wp_send_json_error('missing_id'); |
| 145 | $repo->delete($id); |
| 146 | wp_send_json_success(); |
| 147 | break; |
| 148 | |
| 149 | case 'duplicate': |
| 150 | $id = (int)($_POST['id'] ?? 0); |
| 151 | $new_slug = sanitize_title($_POST['new_slug'] ?? ''); |
| 152 | $new_title = sanitize_text_field($_POST['new_title'] ?? ''); |
| 153 | if (!$id || !$new_slug) wp_send_json_error('missing_fields'); |
| 154 | // Expire the original in 30 days |
| 155 | $orig = $repo->getById($id); |
| 156 | if ($orig) { |
| 157 | $orig['access_expires_at'] = date('Y-m-d H:i:s', strtotime('+30 days')); |
| 158 | if (!$repo->save($orig)) { |
| 159 | wp_send_json_error('expire_original_failed'); |
| 160 | } |
| 161 | } |
| 162 | $new_id = $repo->duplicate($id, $new_slug, $new_title ?: null); |
| 163 | if (!$new_id) wp_send_json_error('duplicate_failed'); |
| 164 | wp_send_json_success(['id' => $new_id]); |
| 165 | break; |
| 166 | |
| 167 | case 'get_section': |
| 168 | $section_id = (int)($_POST['section_id'] ?? 0); |
| 169 | if (!$section_id) wp_send_json_error('missing_section_id'); |
| 170 | $section = $repo->getSectionById($section_id); |
| 171 | if (!$section) wp_send_json_error('not_found'); |
| 172 | wp_send_json_success($section); |
| 173 | break; |
| 174 | |
| 175 | case 'save_section': |
| 176 | $section_id = (int)($_POST['section_id'] ?? 0); |
| 177 | $congress_id = (int)($_POST['congress_id'] ?? 0); |
| 178 | $page_id = (int)($_POST['page_id'] ?? 0); |
| 179 | $type = sanitize_key($_POST['type'] ?? 'info'); |
| 180 | $title = sanitize_text_field($_POST['title'] ?? ''); |
| 181 | $password = sanitize_text_field($_POST['password'] ?? ''); |
| 182 | $sort_order = (int)($_POST['sort_order'] ?? 0); |
| 183 | $raw_content = stripslashes($_POST['content'] ?? '{}'); |
| 184 | if (!$congress_id) wp_send_json_error('missing_congress_id'); |
| 185 | // New section with no page given → drop it on the start page. |
| 186 | if (!$page_id) { |
| 187 | $start = $repo->getStartPage($congress_id); |
| 188 | $page_id = $start ? (int)$start['id'] : 0; |
| 189 | } |
| 190 | $content = $this->sanitizeSectionContent($type, $raw_content); |
| 191 | $sid = $repo->saveSection([ |
| 192 | 'id' => $section_id ?: null, |
| 193 | 'congress_id' => $congress_id, |
| 194 | 'page_id' => $page_id, |
| 195 | 'type' => $type, |
| 196 | 'title' => $title, |
| 197 | 'password' => $password, |
| 198 | 'sort_order' => $sort_order, |
| 199 | 'content' => $content, |
| 200 | ]); |
| 201 | wp_send_json_success(['id' => $sid]); |
| 202 | break; |
| 203 | |
| 204 | case 'delete_section': |
| 205 | $section_id = (int)($_POST['section_id'] ?? 0); |
| 206 | if (!$section_id) wp_send_json_error('missing_section_id'); |
| 207 | $repo->deleteSection($section_id); |
| 208 | wp_send_json_success(); |
| 209 | break; |
| 210 | |
| 211 | case 'reorder_sections': |
| 212 | $congress_id = (int)($_POST['congress_id'] ?? 0); |
| 213 | $ordered_ids = array_map('intval', (array)($_POST['ordered_ids'] ?? [])); |
| 214 | if (!$congress_id) wp_send_json_error('missing_congress_id'); |
| 215 | $repo->reorderSections($congress_id, $ordered_ids); |
| 216 | wp_send_json_success(); |
| 217 | break; |
| 218 | |
| 219 | // ── Pages ── |
| 220 | case 'get_pages': |
| 221 | $congress_id = (int)($_POST['congress_id'] ?? 0); |
| 222 | if (!$congress_id) wp_send_json_error('missing_congress_id'); |
| 223 | // Each page with its sections, so the admin editor can render the whole tree. |
| 224 | $pages = $repo->getPages($congress_id); |
| 225 | foreach ($pages as &$p) { |
| 226 | $p['sections'] = $repo->getSectionsForPage((int)$p['id']); |
| 227 | $meta = $repo->getPageMeta($p); |
| 228 | $p['icon'] = $meta['icon']; |
| 229 | $p['image_id'] = $meta['image_id']; |
| 230 | $p['description'] = $meta['description']; |
| 231 | $p['color'] = $meta['color']; |
| 232 | $p['image_url'] = $meta['image_id'] ? (wp_get_attachment_image_url($meta['image_id'], 'medium') ?: '') : ''; |
| 233 | } |
| 234 | unset($p); |
| 235 | wp_send_json_success(['pages' => $pages]); |
| 236 | break; |
| 237 | |
| 238 | case 'save_page': |
| 239 | $congress_id = (int)($_POST['congress_id'] ?? 0); |
| 240 | $page_id = (int)($_POST['page_id'] ?? 0); |
| 241 | $title = sanitize_text_field($_POST['title'] ?? ''); |
| 242 | if (!$congress_id) wp_send_json_error('missing_congress_id'); |
| 243 | $page_data = [ |
| 244 | 'id' => $page_id ?: null, |
| 245 | 'congress_id' => $congress_id, |
| 246 | 'title' => $title, |
| 247 | ]; |
| 248 | // Only forward meta keys that were actually sent (merge-on-write in the repo). |
| 249 | if (array_key_exists('icon', $_POST)) $page_data['icon'] = sanitize_text_field($_POST['icon']); |
| 250 | if (array_key_exists('image_id', $_POST)) $page_data['image_id'] = (int) $_POST['image_id']; |
| 251 | if (array_key_exists('description', $_POST)) $page_data['description'] = sanitize_text_field($_POST['description']); |
| 252 | if (array_key_exists('color', $_POST)) $page_data['color'] = sanitize_hex_color($_POST['color']) ?: ''; |
| 253 | $pid = $repo->savePage($page_data); |
| 254 | wp_send_json_success(['id' => $pid]); |
| 255 | break; |
| 256 | |
| 257 | case 'delete_page': |
| 258 | $page_id = (int)($_POST['page_id'] ?? 0); |
| 259 | if (!$page_id) wp_send_json_error('missing_page_id'); |
| 260 | // Refuses to delete the last remaining page (returns false). |
| 261 | if (!$repo->deletePage($page_id)) wp_send_json_error('cannot_delete_last_page'); |
| 262 | wp_send_json_success(); |
| 263 | break; |
| 264 | |
| 265 | case 'reorder_pages': |
| 266 | $congress_id = (int)($_POST['congress_id'] ?? 0); |
| 267 | $ordered_ids = array_map('intval', (array)($_POST['ordered_ids'] ?? [])); |
| 268 | if (!$congress_id) wp_send_json_error('missing_congress_id'); |
| 269 | $repo->reorderPages($congress_id, $ordered_ids); |
| 270 | wp_send_json_success(); |
| 271 | break; |
| 272 | |
| 273 | default: |
| 274 | wp_send_json_error('unknown_action'); |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | /** |
| 279 | * Sanitize section content by type. Strips all JavaScript (wp_kses_post). |
| 280 | */ |
| 281 | public function sanitizeSectionContent(string $type, string $raw): array { |
| 282 | $data = json_decode($raw, true) ?? []; |
| 283 | switch ($type) { |
| 284 | case 'info': |
| 285 | case 'custom': |
| 286 | return ['html' => wp_kses_post($data['html'] ?? '')]; |
| 287 | |
| 288 | case 'program': |
| 289 | $days = []; |
| 290 | foreach ((array)($data['days'] ?? []) as $day) { |
| 291 | $slots = []; |
| 292 | foreach ((array)($day['slots'] ?? []) as $slot) { |
| 293 | $slots[] = [ |
| 294 | 'time' => sanitize_text_field($slot['time'] ?? ''), |
| 295 | 'title' => sanitize_text_field($slot['title'] ?? ''), |
| 296 | 'speaker' => sanitize_text_field($slot['speaker'] ?? ''), |
| 297 | 'room' => sanitize_text_field($slot['room'] ?? ''), |
| 298 | ]; |
| 299 | } |
| 300 | $days[] = ['date' => sanitize_text_field($day['date'] ?? ''), 'slots' => $slots]; |
| 301 | } |
| 302 | return ['days' => $days]; |
| 303 | |
| 304 | case 'download': |
| 305 | $files = []; |
| 306 | foreach ((array)($data['files'] ?? []) as $f) { |
| 307 | $att_id = (int)($f['attachment_id'] ?? 0); |
| 308 | $url = $att_id ? (wp_get_attachment_url($att_id) ?: '') : esc_url_raw($f['url'] ?? ''); |
| 309 | if (!$url) continue; |
| 310 | $files[] = [ |
| 311 | 'label' => sanitize_text_field($f['label'] ?? ''), |
| 312 | 'attachment_id' => $att_id, |
| 313 | 'filename' => sanitize_text_field($f['filename'] ?? ''), |
| 314 | 'url' => $url, |
| 315 | 'inline' => !empty($f['inline']), // true = open in browser, false = force download |
| 316 | ]; |
| 317 | } |
| 318 | return ['files' => $files]; |
| 319 | |
| 320 | case 'url': |
| 321 | $urls = []; |
| 322 | foreach ((array)($data['urls'] ?? []) as $u) { |
| 323 | $href = esc_url_raw($u['url'] ?? ''); |
| 324 | if (!$href) continue; |
| 325 | $urls[] = [ |
| 326 | 'label' => sanitize_text_field($u['label'] ?? $href), |
| 327 | 'url' => $href, |
| 328 | 'internal' => !empty($u['internal']), |
| 329 | ]; |
| 330 | } |
| 331 | return ['urls' => $urls]; |
| 332 | |
| 333 | case 'media': |
| 334 | $items = []; |
| 335 | foreach ((array)($data['items'] ?? []) as $item) { |
| 336 | $att_id = (int)($item['attachment_id'] ?? 0); |
| 337 | $url = $att_id ? wp_get_attachment_url($att_id) : esc_url_raw($item['url'] ?? ''); |
| 338 | if (!$url) continue; |
| 339 | $items[] = [ |
| 340 | 'attachment_id' => $att_id, |
| 341 | 'caption' => sanitize_text_field($item['caption'] ?? ''), |
| 342 | 'url' => $url, |
| 343 | ]; |
| 344 | } |
| 345 | return ['items' => $items]; |
| 346 | |
| 347 | case 'speakers': |
| 348 | // Each speaker: image (id+url), name, title, plain-text bio (≤500 chars). |
| 349 | // bio is PLAIN text — sanitize_textarea_field, NOT wp_kses_post. |
| 350 | $speakers = []; |
| 351 | foreach ((array)($data['speakers'] ?? []) as $spk) { |
| 352 | $att_id = (int)($spk['image_id'] ?? 0); |
| 353 | $url = $att_id ? (wp_get_attachment_url($att_id) ?: '') : esc_url_raw($spk['image_url'] ?? ''); |
| 354 | $name = sanitize_text_field($spk['name'] ?? ''); |
| 355 | $title = sanitize_text_field($spk['title'] ?? ''); |
| 356 | $bio = mb_substr(sanitize_textarea_field($spk['bio'] ?? ''), 0, 500); |
| 357 | if ($name === '' && $bio === '' && $url === '') continue; // skip empty rows |
| 358 | $speakers[] = ['image_id' => $att_id, 'image_url' => $url, 'name' => $name, 'title' => $title, 'bio' => $bio]; |
| 359 | } |
| 360 | return ['speakers' => $speakers]; |
| 361 | |
| 362 | case 'image': |
| 363 | $att_id = (int)($data['attachment_id'] ?? 0); |
| 364 | $url = $att_id ? (wp_get_attachment_url($att_id) ?: '') : esc_url_raw($data['url'] ?? ''); |
| 365 | $size = in_array(($data['size'] ?? 'full'), ['full', 'original', 'custom'], true) ? $data['size'] : 'full'; |
| 366 | $unit = (($data['width_unit'] ?? 'px') === '%') ? '%' : 'px'; |
| 367 | return [ |
| 368 | 'attachment_id' => $att_id, |
| 369 | 'url' => $url, |
| 370 | 'caption' => sanitize_text_field($data['caption'] ?? ''), |
| 371 | 'size' => $size, |
| 372 | 'width' => max(0, (int)($data['width'] ?? 0)), |
| 373 | 'width_unit' => $unit, |
| 374 | 'lightbox' => !empty($data['lightbox']), |
| 375 | ]; |
| 376 | |
| 377 | case 'video': |
| 378 | // provider: 'oembed' (YouTube/Vimeo etc.) or 'file' (self-hosted MP4 from media library). |
| 379 | $provider = ($data['provider'] ?? 'oembed') === 'file' ? 'file' : 'oembed'; |
| 380 | $att_id = (int)($data['attachment_id'] ?? 0); |
| 381 | if ($provider === 'file') { |
| 382 | $url = $att_id ? (wp_get_attachment_url($att_id) ?: '') : esc_url_raw($data['url'] ?? ''); |
| 383 | } else { |
| 384 | $url = esc_url_raw($data['url'] ?? ''); |
| 385 | } |
| 386 | return [ |
| 387 | 'provider' => $provider, |
| 388 | 'url' => $url, |
| 389 | 'attachment_id' => $att_id, |
| 390 | // embed_html is generated server-side at response time (REST prepareContent), never stored. |
| 391 | ]; |
| 392 | |
| 393 | default: |
| 394 | return []; |
| 395 | } |
| 396 | } |
| 397 | } |
| 398 |