PluginProbe ʕ •ᴥ•ʔ
Event Tickets with Ticket Scanner / 3.1.2
Event Tickets with Ticket Scanner v3.1.2
3.1.2 3.1.1 3.1.0 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.4 trunk 2.6.0 2.7.0 2.7.1 2.7.10 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 2.8.0 2.8.1 2.8.10 2.8.2 2.8.3 2.8.4 2.8.5 2.8.6 2.8.7 2.8.8 2.8.9 2.9.0 2.9.2 2.9.3 2.9.4 2.9.5 2.9.6 2.9.7 2.9.8 2.9.9 3.0.0 3.0.1 3.0.2 3.0.3
event-tickets-with-ticket-scanner / includes / seating / class-seating-admin.php
event-tickets-with-ticket-scanner / includes / seating Last commit date
class-seating-admin.php 1 week ago class-seating-base.php 1 week ago class-seating-block.php 1 week ago class-seating-frontend.php 1 week ago class-seating-plan.php 1 week ago class-seating-seat.php 1 week ago
class-seating-admin.php
1079 lines
1 <?php
2 /**
3 * Seating Admin Handler
4 *
5 * Handles admin UI for seating plan management.
6 *
7 * @package Event_Tickets_With_Ticket_Scanner
8 * @subpackage Seating
9 * @since 2.8.0
10 */
11
12 // Exit if accessed directly
13 if (!defined('ABSPATH')) {
14 exit;
15 }
16
17 require_once __DIR__ . '/class-seating-base.php';
18
19 /**
20 * Seating Admin Class
21 *
22 * Provides admin UI for managing seating plans and seats.
23 *
24 * @since 2.8.0
25 */
26 class sasoEventtickets_Seating_Admin extends sasoEventtickets_Seating_Base {
27
28 /**
29 * Constructor
30 *
31 * @param sasoEventtickets $main Main plugin instance
32 */
33 public function __construct($main) {
34 parent::__construct($main);
35 $this->initHooks();
36 }
37
38 /**
39 * Initialize WordPress hooks
40 *
41 * Note: AJAX handler is registered in index.php via executeSeatingAdmin_a()
42 * This follows the same pattern as executeAdminSettings for consistency
43 */
44 private function initHooks(): void {
45 // No direct AJAX registration here - handled via index.php dispatcher
46 }
47
48 /**
49 * Get meta object structure - Admin class has no meta fields
50 *
51 * @return array Empty array as Admin is UI-only
52 */
53 public function getMetaObject(): array {
54 return [];
55 }
56
57 /**
58 * Sanitize meta input from request data
59 *
60 * Only sanitizes user-provided values - does NOT set defaults.
61 * Defaults are handled by PlanManager::create() via getMetaObject().
62 *
63 * @param array $data Request data
64 * @return array Sanitized meta values (only keys that were provided)
65 */
66 private function sanitizeMetaInput(array $data): array {
67 $meta = [];
68
69 // Basic fields
70 if (isset($data['description'])) {
71 $meta['description'] = sanitize_textarea_field($data['description']);
72 }
73 if (isset($data['image_id'])) {
74 $meta['image_id'] = absint($data['image_id']);
75 }
76
77 // Canvas settings (only if provided)
78 if (isset($data['canvas_width'])) {
79 $meta['canvas_width'] = absint($data['canvas_width']);
80 }
81 if (isset($data['canvas_height'])) {
82 $meta['canvas_height'] = absint($data['canvas_height']);
83 }
84 if (isset($data['background_color'])) {
85 $color = sanitize_hex_color($data['background_color']);
86 if ($color) {
87 $meta['background_color'] = $color;
88 }
89 }
90
91 // Colors (only if provided)
92 $colorKeys = ['color_available', 'color_reserved', 'color_booked', 'color_selected'];
93 $colorMap = ['color_available' => 'available', 'color_reserved' => 'reserved', 'color_booked' => 'booked', 'color_selected' => 'selected'];
94 foreach ($colorKeys as $key) {
95 if (isset($data[$key])) {
96 $color = sanitize_hex_color($data[$key]);
97 if ($color) {
98 $meta['colors'][$colorMap[$key]] = $color;
99 }
100 }
101 }
102
103 return $meta;
104 }
105
106 // =========================================================================
107 // Main AJAX Dispatcher (Switch Pattern)
108 // =========================================================================
109
110 /**
111 * Execute seating admin JSON action
112 *
113 * Main dispatcher for all seating admin AJAX requests.
114 * Follows the same pattern as executeAdminSettings::executeJSON()
115 *
116 * @param string $a Action name
117 * @param array $data Request data (full $data array for Single Responsibility)
118 * @param bool $just_ret Return value instead of wp_send_json
119 * @param bool $skipNonceTest Skip nonce verification (default: false)
120 * @return mixed Result or wp_send_json response
121 */
122 public function executeSeatingJSON(string $a, array $data = [], bool $just_ret = false, bool $skipNonceTest = false) {
123 $ret = '';
124
125 // Nonce verification
126 if (!$skipNonceTest) {
127 $nonce = SASO_EVENTTICKETS::getRequestPara('nonce');
128 if (!wp_verify_nonce($nonce, $this->MAIN->_js_nonce)) {
129 if (!wp_verify_nonce($nonce, 'wp_rest')) {
130 if ($just_ret) {
131 throw new Exception('Security check failed');
132 }
133 return wp_send_json_error('Security check failed');
134 }
135 }
136 }
137
138 // Permission check for admin operations
139 if (!current_user_can('manage_options')) {
140 if ($just_ret) {
141 throw new Exception('Unauthorized');
142 }
143 return wp_send_json_error(['error' => 'unauthorized']);
144 }
145
146 try {
147 switch (trim($a)) {
148 // Plan operations
149 case 'getPlans':
150 $ret = $this->handleGetPlans($data);
151 break;
152 case 'getPlan':
153 $ret = $this->handleGetPlan($data);
154 break;
155 case 'createPlan':
156 $ret = $this->handleCreatePlan($data);
157 break;
158 case 'updatePlan':
159 $ret = $this->handleUpdatePlan($data);
160 break;
161 case 'deletePlan':
162 $ret = $this->handleDeletePlan($data);
163 break;
164 case 'clonePlan':
165 $ret = $this->handleClonePlan($data);
166 break;
167
168 // Draft/Publish operations (Visual Designer)
169 case 'saveDraft':
170 $ret = $this->handleSaveDraft($data);
171 break;
172 case 'publishPlan':
173 $ret = $this->handlePublishPlan($data);
174 break;
175 case 'discardDraft':
176 $ret = $this->handleDiscardDraft($data);
177 break;
178 case 'getDesignerData':
179 $ret = $this->handleGetDesignerData($data);
180 break;
181 case 'getPublishedData':
182 $ret = $this->handleGetPublishedData($data);
183 break;
184
185 // Seat operations
186 case 'getSeats':
187 $ret = $this->handleGetSeats($data);
188 break;
189 case 'createSeat':
190 $ret = $this->handleCreateSeat($data);
191 break;
192 case 'createSeatsBulk':
193 $ret = $this->handleCreateSeatsBulk($data);
194 break;
195 case 'updateSeat':
196 $ret = $this->handleUpdateSeat($data);
197 break;
198 case 'deleteSeat':
199 $ret = $this->handleDeleteSeat($data);
200 break;
201
202 // Statistics
203 case 'getStats':
204 $ret = $this->handleGetStats($data);
205 break;
206
207 case 'getDesignerPage':
208 $ret = $this->handleGetDesignerPage($data);
209 break;
210
211 // Premium delegation
212 case 'premium':
213 $ret = $this->executeSeatingJSONPremium($data);
214 break;
215
216 default:
217 throw new Exception('#8501 Unknown seating action: ' . $a);
218 }
219 } catch (Exception $e) {
220 $this->MAIN->getAdmin()->logErrorToDB($e, 'executeSeatingJSON', __FILE__ . ' on Line: ' . __LINE__);
221 if ($just_ret) {
222 throw $e;
223 }
224 return wp_send_json_error(['error' => $e->getMessage()]);
225 }
226
227 if ($just_ret) {
228 return $ret;
229 }
230 return wp_send_json_success($ret);
231 }
232
233 /**
234 * Execute seating JSON action delegated to premium plugin
235 *
236 * @param array $data Request data with 'c' for premium action
237 * @return mixed Premium handler result
238 * @throws Exception If premium not active or action missing
239 */
240 private function executeSeatingJSONPremium(array $data) {
241 if (!$this->MAIN->isPremium() || !method_exists($this->MAIN->getPremiumFunctions(), 'executeSeatingJSON')) {
242 throw new Exception('#8502 premium is not active or method not available');
243 }
244 if (!isset($data['c'])) {
245 throw new Exception('#8503 premium action parameter is missing');
246 }
247 return $this->MAIN->getPremiumFunctions()->executeSeatingJSON($data['c'], $data);
248 }
249
250 // =========================================================================
251 // Handler Methods (receive full $data for Single Responsibility)
252 // =========================================================================
253
254 /**
255 * Handle: Get all seating plans
256 *
257 * @param array $data Request data
258 * @return array Plans with seat counts and limits
259 */
260 private function handleGetPlans(array $data): array {
261 $plans = $this->MAIN->getSeating()->getPlanManager()->getAll();
262
263 // Add seat count to each plan
264 foreach ($plans as &$plan) {
265 $plan['seat_count'] = $this->MAIN->getSeating()->getSeatManager()->getCountForPlan((int) $plan['id']);
266 }
267
268 $result = ['plans' => $plans];
269
270 // Premium enrichment hook
271 if ($this->MAIN->isPremium() && method_exists($this->MAIN->getPremiumFunctions(), 'handleGetPlans')) {
272 $result = $this->MAIN->getPremiumFunctions()->handleGetPlans($result, $data);
273 }
274
275 return $result;
276 }
277
278 /**
279 * Handle: Get single seating plan
280 *
281 * @param array $data Request data with plan_id
282 * @return array Plan data
283 * @throws Exception If plan_id missing or not found
284 */
285 private function handleGetPlan(array $data): array {
286 $planId = isset($data['plan_id']) ? (int) $data['plan_id'] : 0;
287 if (!$planId) {
288 throw new Exception('#8510 missing plan_id');
289 }
290
291 $plan = $this->MAIN->getSeating()->getPlanManager()->getById($planId);
292 if (!$plan) {
293 throw new Exception('#8511 plan not found');
294 }
295
296 $plan['seat_count'] = $this->MAIN->getSeating()->getSeatManager()->getCountForPlan($planId);
297
298 if ($this->MAIN->isPremium() && method_exists($this->MAIN->getPremiumFunctions(), 'handleGetPlan')) {
299 $plan = $this->MAIN->getPremiumFunctions()->handleGetPlan($plan, $data);
300 }
301
302 return ['plan' => $plan];
303 }
304
305 private function sanitizePlanData($data) {
306 // Only pass sanitized user input - update() merges with getMetaObject() defaults
307 $planData = [
308 'name' => sanitize_text_field($data['name'] ?? ''),
309 'aktiv' => (!empty($data['aktiv']) && $data['aktiv'] != '0') ? 1 : 0,
310 'layout_type' => sanitize_text_field($data['layout_type'] ?? ''),
311 'meta' => $this->sanitizeMetaInput($data)
312 ];
313 return $planData;
314 }
315
316 /**
317 * Sanitize seat data from admin form or designer
318 *
319 * Handles both key formats:
320 * - Admin: seat_identifier, seat_label, seat_category
321 * - Designer: identifier, label, category, pos_x, pos_y, shape_type, etc.
322 *
323 * @param array $data Raw seat data
324 * @return array Sanitized seat data ready for create/update
325 */
326 private function sanitizeSeatData(array $data): array {
327 // Handle both key formats (admin form vs designer)
328 $identifier = $data['seat_identifier'] ?? $data['identifier'] ?? '';
329 $label = $data['seat_label'] ?? $data['label'] ?? '';
330 $category = $data['seat_category'] ?? $data['category'] ?? '';
331
332 $seatData = [
333 'seat_identifier' => sanitize_text_field($identifier),
334 'aktiv' => isset($data['aktiv']) ? (int) $data['aktiv'] : 1,
335 'meta' => [
336 'seat_label' => sanitize_text_field($label),
337 'seat_category' => sanitize_text_field($category),
338 'seat_desc' => sanitize_textarea_field($data['seat_desc'] ?? '')
339 ]
340 ];
341
342 // Designer-specific fields (position, shape, color)
343 if (isset($data['pos_x'])) {
344 $seatData['meta']['pos_x'] = floatval($data['pos_x']);
345 }
346 if (isset($data['pos_y'])) {
347 $seatData['meta']['pos_y'] = floatval($data['pos_y']);
348 }
349 if (isset($data['rotation'])) {
350 $seatData['meta']['rotation'] = intval($data['rotation']) % 360;
351 }
352 if (isset($data['shape_type'])) {
353 $seatData['meta']['shape_type'] = sanitize_text_field($data['shape_type']);
354 }
355 if (isset($data['shape_config'])) {
356 $seatData['meta']['shape_config'] = $data['shape_config'];
357 }
358 if (isset($data['color'])) {
359 $seatData['meta']['color'] = sanitize_hex_color($data['color']) ?: '#4CAF50';
360 }
361 if (isset($data['description'])) {
362 $seatData['meta']['description'] = sanitize_textarea_field($data['description']);
363 }
364
365 return $seatData;
366 }
367 /**
368 * Handle: Create seating plan
369 *
370 * @param array $data Request data with name, aktiv, description, layout_type, visual settings
371 * @return array Created plan info
372 * @throws Exception On create failure
373 */
374 private function handleCreatePlan(array $data): array {
375 // Only pass sanitized user input - create() merges with getMetaObject() defaults
376 $planData = $this->sanitizePlanData($data);
377
378 $planId = $this->MAIN->getSeating()->getPlanManager()->create($planData);
379 if (!$planId) {
380 throw new Exception('#8520 create plan failed');
381 }
382
383 return [
384 'plan_id' => $planId,
385 'message' => __('Seating plan created successfully', 'event-tickets-with-ticket-scanner')
386 ];
387 }
388
389 /**
390 * Handle: Update seating plan
391 *
392 * @param array $data Request data with plan_id, name, aktiv, visual settings, etc.
393 * @return array Success message
394 * @throws Exception On update failure
395 */
396 private function handleUpdatePlan(array $data): array {
397 $planId = isset($data['plan_id']) ? (int) $data['plan_id'] : 0;
398 if (!$planId) {
399 throw new Exception('#8530 missing plan_id');
400 }
401
402 // Only pass sanitized user input - update() merges with getMetaObject() defaults
403 $planData = $this->sanitizePlanData($data);
404
405 $success = $this->MAIN->getSeating()->getPlanManager()->update($planId, $planData);
406 if (!$success) {
407 throw new Exception('#8531 update plan failed');
408 }
409
410 return ['message' => __('Seating plan updated successfully', 'event-tickets-with-ticket-scanner')];
411 }
412
413 /**
414 * Handle: Delete seating plan
415 *
416 * @param array $data Request data with plan_id, optional force
417 * @return array Success message
418 * @throws Exception On delete failure
419 */
420 private function handleDeletePlan(array $data): array {
421 $planId = isset($data['plan_id']) ? (int) $data['plan_id'] : 0;
422 $force = isset($data['force']) && ($data['force'] === true || $data['force'] === 'true');
423
424 if (!$planId) {
425 throw new Exception('#8540 missing plan_id');
426 }
427
428 $success = $this->MAIN->getSeating()->getPlanManager()->delete($planId, $force);
429 if (!$success) {
430 throw new Exception('#8541 delete plan failed');
431 }
432
433 return ['message' => __('Seating plan deleted successfully', 'event-tickets-with-ticket-scanner')];
434 }
435
436 /**
437 * Handle: Clone/duplicate a seating plan
438 *
439 * @param array $data Request data with plan_id and optional new_name
440 * @return array Success info with new plan ID
441 * @throws Exception If plan_id missing or clone fails
442 * @since 2.8.2
443 */
444 private function handleClonePlan(array $data): array {
445 $planId = isset($data['plan_id']) ? (int) $data['plan_id'] : 0;
446 $newName = isset($data['new_name']) ? sanitize_text_field($data['new_name']) : null;
447
448 if (!$planId) {
449 throw new Exception('#8545 missing plan_id');
450 }
451
452 $newPlanId = $this->MAIN->getSeating()->getPlanManager()->clonePlan($planId, $newName);
453
454 // Get the new plan to return full info
455 $newPlan = $this->MAIN->getSeating()->getPlanManager()->getById($newPlanId);
456
457 return [
458 'message' => __('Seating plan cloned successfully', 'event-tickets-with-ticket-scanner'),
459 'plan_id' => $newPlanId,
460 'plan' => $newPlan
461 ];
462 }
463
464 /**
465 * Handle: Get seats for a plan
466 *
467 * @param array $data Request data with plan_id
468 * @return array Seats and limits
469 * @throws Exception If plan_id missing
470 */
471 private function handleGetSeats(array $data): array {
472 $planId = isset($data['plan_id']) ? (int) $data['plan_id'] : 0;
473 if (!$planId) {
474 throw new Exception('#8550 missing plan_id');
475 }
476
477 $seats = $this->MAIN->getSeating()->getSeatManager()->getByPlanId($planId);
478
479 $result = ['seats' => $seats];
480
481 // Premium enrichment hook
482 if ($this->MAIN->isPremium() && method_exists($this->MAIN->getPremiumFunctions(), 'enrichSeatingSeats')) {
483 $result = $this->MAIN->getPremiumFunctions()->enrichSeatingSeats($result, $data);
484 }
485
486 return $result;
487 }
488
489 /**
490 * Handle: Create single seat
491 *
492 * @param array $data Request data with plan_id, seat_identifier, etc.
493 * @return array Created seat info
494 * @throws Exception On failure
495 */
496 private function handleCreateSeat(array $data): array {
497 $planId = isset($data['plan_id']) ? (int) $data['plan_id'] : 0;
498 if (!$planId) {
499 throw new Exception('#8560 missing plan_id');
500 }
501
502 $seatData = $this->sanitizeSeatData($data);
503 $seatId = $this->MAIN->getSeating()->getSeatManager()->create($planId, $seatData);
504 if (!$seatId) {
505 throw new Exception('#8561 create seat failed');
506 }
507
508 return [
509 'seat_id' => $seatId,
510 'message' => __('Seat created successfully', 'event-tickets-with-ticket-scanner')
511 ];
512 }
513
514 /**
515 * Handle: Create multiple seats
516 *
517 * @param array $data Request data with plan_id, identifiers (newline-separated)
518 * @return array Created seat count and IDs
519 * @throws Exception On failure
520 */
521 private function handleCreateSeatsBulk(array $data): array {
522 $planId = isset($data['plan_id']) ? (int) $data['plan_id'] : 0;
523 if (!$planId || empty($data['identifiers'])) {
524 throw new Exception('#8570 missing plan_id or identifiers');
525 }
526
527 $identifiers = sanitize_textarea_field($data['identifiers']);
528 $identifierList = array_filter(array_map('trim', explode("\n", $identifiers)));
529 $createdIds = $this->MAIN->getSeating()->getSeatManager()->createBulk($planId, $identifierList);
530
531 return [
532 'created_count' => count($createdIds),
533 'seat_ids' => $createdIds,
534 'message' => sprintf(
535 __('%d seats created successfully', 'event-tickets-with-ticket-scanner'),
536 count($createdIds)
537 )
538 ];
539 }
540
541 /**
542 * Handle: Update one or multiple seats
543 *
544 * Accepts:
545 * - Single: {seat_id: 1, ...fields}
546 * - Multiple: {seat_ids: [1, 2, 3], ...fields}
547 *
548 * Same fields applied to all IDs
549 *
550 * @param array $data Request data with fields and seat_id or seat_ids
551 * @return array Results
552 * @throws Exception On failure
553 */
554 private function handleUpdateSeat(array $data): array {
555 $seatManager = $this->MAIN->getSeating()->getSeatManager();
556
557 // Get IDs (single or multiple)
558 $ids = [];
559 if (!empty($data['seat_ids'])) {
560 $ids = $data['seat_ids'];
561 if (is_string($ids)) {
562 $ids = json_decode(wp_unslash($ids), true);
563 }
564 } elseif (!empty($data['seat_id'])) {
565 $ids = [(int) $data['seat_id']];
566 }
567
568 if (empty($ids) || !is_array($ids)) {
569 throw new Exception('#8580 missing seat_id or seat_ids');
570 }
571
572 // Sanitize update data
573 $updateData = $this->sanitizeSeatData($data);
574
575 // Handle simple field updates (aktiv, sort_order)
576 if (isset($data['aktiv'])) {
577 $updateData['aktiv'] = (int) $data['aktiv'];
578 }
579 if (isset($data['sort_order'])) {
580 $updateData['sort_order'] = (int) $data['sort_order'];
581 }
582
583 $results = $seatManager->update($updateData, $ids);
584 $successCount = count(array_filter($results, fn($r) => $r['success']));
585
586 if (count($ids) === 1) {
587 // Single seat response
588 if (empty($results) || !$results[0]['success']) {
589 $error = $results[0]['error'] ?? 'update failed';
590 throw new Exception('#8581 ' . $error);
591 }
592 return ['message' => __('Seat updated successfully', 'event-tickets-with-ticket-scanner')];
593 }
594
595 // Multiple seats response
596 return [
597 'results' => $results,
598 'success_count' => $successCount,
599 'fail_count' => count($results) - $successCount,
600 'message' => sprintf(__('%d seats updated successfully', 'event-tickets-with-ticket-scanner'), $successCount)
601 ];
602 }
603
604 /**
605 * Handle: Delete one or multiple seats
606 *
607 * Accepts:
608 * - Single: {seat_id: 1}
609 * - Multiple: {seat_ids: [1, 2, 3]}
610 *
611 * @param array $data Request data
612 * @return array Results
613 * @throws Exception On failure
614 */
615 private function handleDeleteSeat(array $data): array {
616 $seatManager = $this->MAIN->getSeating()->getSeatManager();
617 $force = isset($data['force']) && ($data['force'] === true || $data['force'] === 'true');
618
619 // Get IDs (single or multiple)
620 $ids = [];
621 if (!empty($data['seat_ids'])) {
622 $ids = $data['seat_ids'];
623 if (is_string($ids)) {
624 $ids = json_decode(wp_unslash($ids), true);
625 }
626 } elseif (!empty($data['seat_id'])) {
627 $ids = [(int) $data['seat_id']];
628 }
629
630 if (empty($ids) || !is_array($ids)) {
631 throw new Exception('#8590 missing seat_id or seat_ids');
632 }
633
634 $results = $seatManager->delete($ids, $force);
635 $successCount = count(array_filter($results, fn($r) => $r['success']));
636
637 if (count($ids) === 1) {
638 // Single seat response
639 if (empty($results) || !$results[0]['success']) {
640 $error = $results[0]['error'] ?? 'delete failed';
641 throw new Exception('#8591 ' . $error);
642 }
643 return ['message' => __('Seat deleted successfully', 'event-tickets-with-ticket-scanner')];
644 }
645
646 // Multiple seats response
647 return [
648 'results' => $results,
649 'success_count' => $successCount,
650 'fail_count' => count($results) - $successCount,
651 'message' => sprintf(__('%d seats deleted successfully', 'event-tickets-with-ticket-scanner'), $successCount)
652 ];
653 }
654
655 /**
656 * Handle: Get seating statistics
657 *
658 * @param array $data Request data with plan_id, optional product_id, event_date
659 * @return array Statistics
660 * @throws Exception If plan_id missing
661 */
662 private function handleGetStats(array $data): array {
663 $planId = isset($data['plan_id']) ? (int) $data['plan_id'] : 0;
664
665 if (!$planId) {
666 throw new Exception('#8595 missing plan_id');
667 }
668
669 $eventDate = isset($data['event_date']) ? sanitize_text_field($data['event_date']) : null;
670 $stats = $this->MAIN->getSeating()->getStats($planId, $eventDate);
671 $productId = isset($data['product_id']) ? (int) $data['product_id'] : 0;
672
673 if ($productId) {
674 $seats = $this->MAIN->getSeating()->getSeatsWithStatus($planId, $productId, $eventDate);
675 $stats['seats'] = $seats;
676 }
677
678 $result = ['stats' => $stats];
679
680 // Premium enrichment hook (e.g., for live monitor data)
681 if ($this->MAIN->isPremium() && method_exists($this->MAIN->getPremiumFunctions(), 'enrichSeatingStats')) {
682 $result = $this->MAIN->getPremiumFunctions()->enrichSeatingStats($result, $data);
683 }
684
685 return $result;
686 }
687
688 // =========================================================================
689 // Draft/Publish Handler Methods (Visual Designer)
690 // =========================================================================
691
692 /**
693 * Handle: Save draft for visual designer
694 *
695 * @param array $data Request data with plan_id and draft content
696 * @return array Success message
697 * @throws Exception On failure
698 */
699 private function handleSaveDraft(array $data): array {
700 $planId = isset($data['plan_id']) ? (int) $data['plan_id'] : 0;
701 if (!$planId) {
702 throw new Exception('#8600 missing plan_id');
703 }
704
705 // Parse JSON strings from JS - use wp_unslash to remove WordPress magic quotes escaping
706 $decorations = [];
707 if (!empty($data['decorations'])) {
708 $jsonStr = is_string($data['decorations']) ? wp_unslash($data['decorations']) : $data['decorations'];
709 $decorations = is_string($jsonStr) ? json_decode($jsonStr, true) : $jsonStr;
710 $decorations = is_array($decorations) ? $decorations : [];
711 }
712
713 $lines = [];
714 if (!empty($data['lines'])) {
715 $jsonStr = is_string($data['lines']) ? wp_unslash($data['lines']) : $data['lines'];
716 $lines = is_string($jsonStr) ? json_decode($jsonStr, true) : $jsonStr;
717 $lines = is_array($lines) ? $lines : [];
718 }
719
720 $labels = [];
721 if (!empty($data['labels'])) {
722 $jsonStr = is_string($data['labels']) ? wp_unslash($data['labels']) : $data['labels'];
723 $labels = is_string($jsonStr) ? json_decode($jsonStr, true) : $jsonStr;
724 $labels = is_array($labels) ? $labels : [];
725 }
726
727 $colors = null;
728 if (!empty($data['colors'])) {
729 $jsonStr = is_string($data['colors']) ? wp_unslash($data['colors']) : $data['colors'];
730 $colors = is_string($jsonStr) ? json_decode($jsonStr, true) : $jsonStr;
731 }
732
733 $draftData = [
734 'decorations' => $decorations,
735 'lines' => $lines,
736 'labels' => $labels,
737 'canvas_width' => isset($data['canvas_width']) ? absint($data['canvas_width']) : null,
738 'canvas_height' => isset($data['canvas_height']) ? absint($data['canvas_height']) : null,
739 'background_color' => isset($data['background_color']) ? sanitize_hex_color($data['background_color']) : null,
740 'background_image' => isset($data['background_image']) ? esc_url_raw($data['background_image']) : null,
741 'background_image_id' => isset($data['background_image_id']) ? absint($data['background_image_id']) : null,
742 'background_image_fit' => isset($data['background_image_fit']) ? sanitize_text_field($data['background_image_fit']) : null,
743 'background_image_align' => isset($data['background_image_align']) ? sanitize_text_field($data['background_image_align']) : null
744 ];
745
746 // Remove null values (but keep empty arrays)
747 $draftData = array_filter($draftData, fn($v) => $v !== null);
748
749 // Update colors if provided
750 if (is_array($colors)) {
751 $draftData['colors'] = [
752 'available' => sanitize_hex_color($colors['available'] ?? '#4CAF50') ?: '#4CAF50',
753 'reserved' => sanitize_hex_color($colors['reserved'] ?? '#FFC107') ?: '#FFC107',
754 'booked' => sanitize_hex_color($colors['booked'] ?? '#F44336') ?: '#F44336',
755 'selected' => sanitize_hex_color($colors['selected'] ?? '#2196F3') ?: '#2196F3'
756 ];
757 }
758
759 $success = $this->MAIN->getSeating()->getPlanManager()->saveDraft($planId, $draftData);
760 if (!$success) {
761 throw new Exception('#8601 save draft failed');
762 }
763
764 // Handle seats if provided - use wp_unslash for WordPress magic quotes
765 if (!empty($data['seats'])) {
766 $jsonStr = is_string($data['seats']) ? wp_unslash($data['seats']) : $data['seats'];
767 $seats = is_string($jsonStr) ? json_decode($jsonStr, true) : $jsonStr;
768 if (is_array($seats)) {
769 $this->syncSeatsFromDesigner($planId, $seats);
770 }
771 }
772
773 // Get updated info for badges
774 $planManager = $this->MAIN->getSeating()->getPlanManager();
775 $auditInfo = $planManager->getAuditInfo($planId);
776 $publishInfo = $planManager->getPublishInfo($planId);
777
778 return [
779 'message' => __('Draft saved successfully', 'event-tickets-with-ticket-scanner'),
780 'has_unpublished_changes' => true,
781 'audit_info' => $auditInfo,
782 'publish_info' => $publishInfo
783 ];
784 }
785
786 /**
787 * Sync seats from designer data
788 *
789 * Creates new seats and updates existing ones.
790 * Does NOT delete seats (that happens on publish).
791 *
792 * @param int $planId Plan ID
793 * @param array $seatsData Seats data from designer
794 */
795 private function syncSeatsFromDesigner(int $planId, array $seatsData): void {
796 $seatManager = $this->MAIN->getSeating()->getSeatManager();
797
798 foreach ($seatsData as $rawSeatData) {
799 $seatId = isset($rawSeatData['id']) ? (int) $rawSeatData['id'] : 0;
800 $seatData = $this->sanitizeSeatData($rawSeatData);
801
802 // Ensure identifier for new seats
803 if (empty($seatData['seat_identifier'])) {
804 $seatData['seat_identifier'] = 'SEAT-' . uniqid();
805 }
806
807 if ($seatId > 0) {
808 $seatManager->update($seatData, $seatId);
809 } else {
810 $seatManager->create($planId, $seatData);
811 }
812 }
813 }
814
815 /**
816 * Handle: Publish plan (copy draft to published)
817 *
818 * @param array $data Request data with plan_id
819 * @return array Result with success status and any conflicts
820 * @throws Exception On failure
821 */
822 private function handlePublishPlan(array $data): array {
823 $planId = isset($data['plan_id']) ? (int) $data['plan_id'] : 0;
824 if (!$planId) {
825 throw new Exception('#8610 missing plan_id');
826 }
827
828 $planManager = $this->MAIN->getSeating()->getPlanManager();
829 $result = $planManager->publish($planId);
830
831 if (!$result['success']) {
832 return [
833 'success' => false,
834 'conflicts' => $result['conflicts'] ?? [],
835 'message' => $result['message']
836 ];
837 }
838
839 // Get updated info for badges
840 $auditInfo = $planManager->getAuditInfo($planId);
841 $publishInfo = $planManager->getPublishInfo($planId);
842
843 return [
844 'success' => true,
845 'published_at' => $result['published_at'],
846 'has_unpublished_changes' => false,
847 'audit_info' => $auditInfo,
848 'publish_info' => $publishInfo,
849 'message' => __('Seating plan published successfully', 'event-tickets-with-ticket-scanner')
850 ];
851 }
852
853 /**
854 * Handle: Discard draft changes
855 *
856 * @param array $data Request data with plan_id
857 * @return array Success message
858 * @throws Exception On failure
859 */
860 private function handleDiscardDraft(array $data): array {
861 $planId = isset($data['plan_id']) ? (int) $data['plan_id'] : 0;
862 if (!$planId) {
863 throw new Exception('#8620 missing plan_id');
864 }
865
866 $success = $this->MAIN->getSeating()->getPlanManager()->discardDraft($planId);
867 if (!$success) {
868 throw new Exception('#8621 discard draft failed');
869 }
870
871 return [
872 'message' => __('Draft changes discarded', 'event-tickets-with-ticket-scanner'),
873 'has_unpublished_changes' => false
874 ];
875 }
876
877 /**
878 * Handle: Get designer data (plan settings, seats, draft)
879 *
880 * @param array $data Request data with plan_id
881 * @return array Designer data
882 * @throws Exception On failure
883 */
884 private function handleGetDesignerData(array $data): array {
885 $planId = isset($data['plan_id']) ? (int) $data['plan_id'] : 0;
886 if (!$planId) {
887 throw new Exception('#8630 missing plan_id');
888 }
889
890 $planManager = $this->MAIN->getSeating()->getPlanManager();
891 $seatManager = $this->MAIN->getSeating()->getSeatManager();
892
893 $plan = $planManager->getById($planId);
894 if (!$plan) {
895 throw new Exception('#8631 plan not found');
896 }
897
898 $draftMeta = $planManager->getDraftMeta($planId);
899 $publishedMeta = $planManager->getPublishedMeta($planId);
900 $seats = $seatManager->getByPlanId($planId);
901 $hasUnpublishedChanges = $planManager->hasUnpublishedChanges($planId);
902 $publishInfo = $planManager->getPublishInfo($planId);
903 $auditInfo = $planManager->getAuditInfo($planId);
904 $activeSales = $planManager->getActiveSalesInfo($planId);
905
906 return [
907 'plan' => [
908 'id' => $plan['id'],
909 'name' => $plan['name'],
910 'layout_type' => $plan['layout_type'] ?? 'simple',
911 'aktiv' => $plan['aktiv']
912 ],
913 'draft' => $draftMeta,
914 'published' => $publishedMeta,
915 'seats' => $seats,
916 'has_unpublished_changes' => $hasUnpublishedChanges,
917 'publish_info' => $publishInfo,
918 'audit_info' => $auditInfo,
919 'active_sales' => $activeSales
920 ];
921 }
922
923 /**
924 * Handle: Get published data only (for version toggle)
925 *
926 * @param array $data Request data with plan_id
927 * @return array Published meta data
928 * @throws Exception On failure
929 */
930 private function handleGetPublishedData(array $data): array {
931 $planId = isset($data['plan_id']) ? (int) $data['plan_id'] : 0;
932 if (!$planId) {
933 throw new Exception('#8635 missing plan_id');
934 }
935
936 $planManager = $this->MAIN->getSeating()->getPlanManager();
937 $publishedMeta = $planManager->getPublishedMeta($planId);
938
939 return [
940 'published' => $publishedMeta
941 ];
942 }
943
944 /**
945 * Handle: Get designer page data (JSON only, HTML generated in JS)
946 *
947 * @param array $data Request data with plan_id
948 * @return array Config and data for designer
949 * @throws Exception On failure
950 */
951 private function handleGetDesignerPage(array $data): array {
952 $planId = isset($data['plan_id']) ? (int) $data['plan_id'] : 0;
953 if (!$planId) {
954 throw new Exception('#8640 missing plan_id');
955 }
956
957 // Load complete plan object
958 $plan = $this->MAIN->getSeating()->getPlanManager()->getFullPlan($planId);
959 if (!$plan) {
960 throw new Exception('#8641 plan not found');
961 }
962
963 // Return plan + UI config
964 return [
965 'plan' => $plan,
966 'config' => [
967 'container' => '#saso-designer-container',
968 'planId' => $planId,
969 'ajaxUrl' => admin_url('admin-ajax.php'),
970 'ajaxAction' => $this->MAIN->getPrefix() . '_executeSeatingAdmin',
971 'nonce' => wp_create_nonce($this->MAIN->_js_nonce)
972 ]
973 ];
974 }
975
976 /**
977 * Enqueue admin scripts and styles
978 */
979 public function enqueueScripts(): void {
980 wp_enqueue_script('jquery');
981 wp_enqueue_script('jquery-ui-sortable');
982 wp_enqueue_script('jquery-ui-dialog');
983 wp_enqueue_style('wp-jquery-ui-dialog');
984
985 // DataTables for seat list
986 wp_enqueue_script(
987 'datatables',
988 'https://cdn.datatables.net/1.13.7/js/jquery.dataTables.min.js',
989 ['jquery'],
990 '1.13.7',
991 true
992 );
993 wp_enqueue_style(
994 'datatables',
995 'https://cdn.datatables.net/1.13.7/css/jquery.dataTables.min.css',
996 [],
997 '1.13.7'
998 );
999
1000 // Plugin root path for assets (from /includes/seating/ go up 2 levels)
1001 $pluginRoot = dirname(dirname(__DIR__));
1002
1003 wp_enqueue_script(
1004 'saso-seating-admin',
1005 plugins_url('js/seating_admin.js', $pluginRoot . '/index.php'),
1006 ['jquery', 'jquery-ui-sortable', 'jquery-ui-dialog', 'wp-i18n'],
1007 $this->MAIN->getPluginVersion(),
1008 true
1009 );
1010
1011 wp_localize_script('saso-seating-admin', 'sasoSeatingAdmin', [
1012 'ajaxurl' => admin_url('admin-ajax.php'),
1013 'action' => $this->MAIN->getPrefix() . '_executeSeatingAdmin',
1014 'nonce' => wp_create_nonce($this->MAIN->_js_nonce),
1015 'i18n' => [
1016 'confirmDelete' => __('Are you sure you want to delete this?', 'event-tickets-with-ticket-scanner'),
1017 'confirmDeleteWithSeats' => __('This plan has seats. Delete plan and all seats?', 'event-tickets-with-ticket-scanner'),
1018 'planCreated' => __('Seating plan created successfully', 'event-tickets-with-ticket-scanner'),
1019 'planUpdated' => __('Seating plan updated successfully', 'event-tickets-with-ticket-scanner'),
1020 'planDeleted' => __('Seating plan deleted successfully', 'event-tickets-with-ticket-scanner'),
1021 'seatCreated' => __('Seat created successfully', 'event-tickets-with-ticket-scanner'),
1022 'seatsCreated' => __('Seats created successfully', 'event-tickets-with-ticket-scanner'),
1023 'seatUpdated' => __('Seat updated successfully', 'event-tickets-with-ticket-scanner'),
1024 'seatDeleted' => __('Seat deleted successfully', 'event-tickets-with-ticket-scanner'),
1025 'limitReached' => __('Limit reached. Upgrade to Premium for unlimited access.', 'event-tickets-with-ticket-scanner'),
1026 'error' => __('An error occurred. Please try again.', 'event-tickets-with-ticket-scanner'),
1027 'loading' => __('Loading...', 'event-tickets-with-ticket-scanner'),
1028 'noPlans' => __('No seating plans found. Create your first plan!', 'event-tickets-with-ticket-scanner'),
1029 'noSeats' => __('No seats in this plan. Add seats below.', 'event-tickets-with-ticket-scanner'),
1030 'layoutSimpleTitle' => __('Simple Layout (Dropdown)', 'event-tickets-with-ticket-scanner'),
1031 'layoutSimpleDesc' => __('Customers will see a dropdown menu to select their seat. Available seats are shown in a list sorted by identifier. This is ideal for smaller venues or when visual seat selection is not needed.', 'event-tickets-with-ticket-scanner'),
1032 'layoutVisualTitle' => __('Visual Layout (Seat Map)', 'event-tickets-with-ticket-scanner'),
1033 'layoutVisualDesc' => __('Customers will see an interactive seat map where they can click on available seats. Occupied seats are shown in a different color. This provides a visual overview of the venue. (Premium feature)', 'event-tickets-with-ticket-scanner'),
1034 // Visual Designer i18n
1035 'draftSaved' => __('Draft saved successfully', 'event-tickets-with-ticket-scanner'),
1036 'draftDiscarded' => __('Draft changes discarded', 'event-tickets-with-ticket-scanner'),
1037 'planPublished' => __('Seating plan published successfully', 'event-tickets-with-ticket-scanner'),
1038 'unpublishedChanges' => __('You have unpublished changes', 'event-tickets-with-ticket-scanner'),
1039 'lastPublished' => __('Last published:', 'event-tickets-with-ticket-scanner'),
1040 'neverPublished' => __('Never published', 'event-tickets-with-ticket-scanner'),
1041 'confirmDiscard' => __('Discard all unsaved changes and revert to published version?', 'event-tickets-with-ticket-scanner'),
1042 'confirmPublish' => __('Publish changes? This will make them visible to customers.', 'event-tickets-with-ticket-scanner'),
1043 'publishConflicts' => __('Cannot publish: The following seats have sold tickets and cannot be deleted:', 'event-tickets-with-ticket-scanner'),
1044 'activeSalesWarning' => __('Warning: This seating plan has active ticket sales. Changes will only be visible after publishing.', 'event-tickets-with-ticket-scanner'),
1045 'ticketsSold' => __('%d tickets sold', 'event-tickets-with-ticket-scanner')
1046 ]
1047 ]);
1048
1049 wp_set_script_translations('saso-seating-admin', 'event-tickets-with-ticket-scanner', $pluginRoot . '/languages');
1050
1051 wp_enqueue_style(
1052 'saso-seating-admin',
1053 plugins_url('css/seating_admin.css', $pluginRoot . '/index.php'),
1054 [],
1055 $this->MAIN->getPluginVersion()
1056 );
1057
1058 // Visual Designer scripts and styles
1059 wp_enqueue_script(
1060 'saso-seating-designer',
1061 plugins_url('js/seating_designer.js', $pluginRoot . '/index.php'),
1062 ['jquery', 'wp-i18n', 'saso-seating-admin'],
1063 $this->MAIN->getPluginVersion(),
1064 true
1065 );
1066
1067 wp_enqueue_style(
1068 'saso-seating-designer',
1069 plugins_url('css/seating_designer.css', $pluginRoot . '/index.php'),
1070 ['saso-seating-admin'],
1071 $this->MAIN->getPluginVersion()
1072 );
1073 }
1074
1075 // NOTE: renderAdminPage() removed - HTML is now generated in JavaScript (seating_admin.js)
1076 // See renderAdminHTML(), renderPlanModal(), renderSeatModal() in seating_admin.js
1077
1078 }
1079