From 22d58c03031efae59fde85790046506e0efaa53e Mon Sep 17 00:00:00 2001 From: gltechguy954 <41957724+gltechguy954@users.noreply.github.com> Date: Wed, 8 Oct 2025 12:19:46 -0400 Subject: [PATCH] Show nursery CPT menus under Expo menu --- assets/nursery.css | 161 ++++++ assets/nursery.js | 27 + includes/class-admin.php | 10 + includes/class-nursery.php | 891 +++++++++++++++++++++++++++++++++ includes/class-qr-checkins.php | 30 +- uc-expo-qr-checkin.php | 4 +- 6 files changed, 1121 insertions(+), 2 deletions(-) create mode 100644 assets/nursery.css create mode 100644 assets/nursery.js create mode 100644 includes/class-nursery.php diff --git a/assets/nursery.css b/assets/nursery.css new file mode 100644 index 0000000..bd9360d --- /dev/null +++ b/assets/nursery.css @@ -0,0 +1,161 @@ +.uc-nursery .uc-nursery-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 20px; + margin-top: 20px; +} + +.uc-nursery .uc-nursery-child { + border: 1px solid #e1e5ee; + border-radius: 8px; + background: #fff; + padding: 16px; + box-shadow: 0 1px 2px rgba(0,0,0,0.05); + display: flex; + flex-direction: column; + gap: 10px; +} + +.uc-nursery .uc-nursery-child.status-checked_in { + border-color: #2f855a; + box-shadow: 0 0 0 3px rgba(47,133,90,0.2); +} + +.uc-nursery .uc-nursery-child.status-expired { + opacity: 0.6; +} + +.uc-nursery .uc-nursery-allergy, +.uc-label-allergy { + background: #fee2e2; + border-left: 4px solid #dc2626; + padding: 6px 10px; + border-radius: 4px; + font-weight: 600; +} + +.uc-nursery .uc-nursery-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.uc-nursery-token-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 12px; + margin-top: 12px; +} + +.uc-nursery-qr, +.uc-label-qr { + width: 160px; + height: 160px; + margin: 8px auto; +} + +.uc-nursery-token-capture { + margin: 20px 0; + padding: 16px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; +} + +.uc-token-inline { + display: flex; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; +} + +.uc-nursery-print .uc-card { + overflow: visible; +} + +.uc-label-options { + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid #e2e8f0; +} + +.uc-label-options form { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; +} + +.uc-label-grid { + display: grid; + gap: 16px; +} + +.uc-label-grid.columns-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); +} + +.uc-label-grid.columns-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.uc-label-grid.columns-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.uc-label-grid.columns-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.uc-label-card { + border: 1px solid #cbd5f5; + border-radius: 12px; + padding: 16px; + background: #fff; + min-height: 220px; + display: flex; + flex-direction: column; + gap: 8px; + justify-content: space-between; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08); +} + +.uc-label-grid.bleed-0 .uc-label-card { margin: 0; } +.uc-label-grid.bleed-8 .uc-label-card { margin: 8px; } +.uc-label-grid.bleed-12 .uc-label-card { margin: 12px; } +.uc-label-grid.bleed-16 .uc-label-card { margin: 16px; } +.uc-label-grid.bleed-24 .uc-label-card { margin: 24px; } +.uc-label-grid.bleed-32 .uc-label-card { margin: 32px; } +.uc-label-grid.bleed-48 .uc-label-card { margin: 48px; } + +.uc-label-card h2 { + margin: 0; + font-size: 20px; +} + +.uc-label-card small { + color: #475569; +} + +@media print { + body.wp-admin #wpadminbar, + body.wp-admin #adminmenuwrap, + body.wp-admin #adminmenuback, + body.wp-admin #screen-meta, + body.wp-admin #screen-meta-links, + body.wp-admin .notice, + body.wp-admin .uc-toolbar, + body.wp-admin .uc-label-options { + display: none !important; + } + body.wp-admin #wpcontent { + margin-left: 0; + } + .uc-label-grid { + gap: 0; + } + .uc-label-card { + box-shadow: none; + border-color: #000; + } +} diff --git a/assets/nursery.js b/assets/nursery.js new file mode 100644 index 0000000..f06a743 --- /dev/null +++ b/assets/nursery.js @@ -0,0 +1,27 @@ +(function(){ + function renderQr(element, url) { + if (!element || !url) return; + while (element.firstChild) { + element.removeChild(element.firstChild); + } + new QRCode(element, { + text: url, + width: element.classList.contains('uc-label-qr') ? 220 : 160, + height: element.classList.contains('uc-label-qr') ? 220 : 160, + correctLevel: QRCode.CorrectLevel.M + }); + } + + function boot() { + var qrBlocks = document.querySelectorAll('.uc-nursery-qr[data-url], .uc-label-qr[data-url]'); + qrBlocks.forEach(function(block){ + renderQr(block, block.getAttribute('data-url')); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } +})(); diff --git a/includes/class-admin.php b/includes/class-admin.php index c847a5a..317f799 100644 --- a/includes/class-admin.php +++ b/includes/class-admin.php @@ -34,6 +34,7 @@ public function menu() { public function register_settings() { register_setting('uc_expo_qr', UC_Expo_QR_Checkins::OPTION_EVENT, ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field']); register_setting('uc_expo_qr', UC_Expo_QR_Checkins::OPTION_SECRET, ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field']); + register_setting('uc_expo_qr', UC_Expo_QR_Nursery::OPTION_ENABLE, ['type' => 'boolean', 'sanitize_callback' => [$this, 'sanitize_checkbox']]); add_settings_section('uc_expo_qr_main', __('General', 'uc-expo'), function(){ echo '

'.esc_html__('Set the current event id (e.g., 2025-ATL). Use "Rotate Secret" to invalidate old QR signatures and generate new ones.', 'uc-expo').'

'; @@ -49,6 +50,15 @@ public function register_settings() { echo ''; submit_button(__('Rotate Secret', 'uc-expo'), 'secondary', 'uc_expo_rotate_secret', false, ['style'=>'margin-left:10px']); }, 'uc_expo_qr', 'uc_expo_qr_main'); + + add_settings_field('nursery_mode', __('Nursery Mode', 'uc-expo'), function(){ + $enabled = UC_Expo_QR_Nursery::instance()->is_enabled(); + echo ''; + }, 'uc_expo_qr', 'uc_expo_qr_main'); + } + + public function sanitize_checkbox($value) { + return $value ? 1 : 0; } public function render_settings() { diff --git a/includes/class-nursery.php b/includes/class-nursery.php new file mode 100644 index 0000000..410f794 --- /dev/null +++ b/includes/class-nursery.php @@ -0,0 +1,891 @@ +qr_types, true); + } + + public function on_activate(): void { + $this->register_post_types(); + $this->create_tables(); + flush_rewrite_rules(); + } + + private function create_tables(): void { + global $wpdb; + $charset = $wpdb->get_charset_collate(); + + $checkins = $wpdb->prefix . self::TABLE_CHECKINS; + $audit = $wpdb->prefix . self::TABLE_AUDIT; + + $sql = []; + $sql[] = "CREATE TABLE IF NOT EXISTS `$checkins` ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + child_id BIGINT UNSIGNED NOT NULL, + family_id BIGINT UNSIGNED NOT NULL, + service_id BIGINT UNSIGNED NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'created', + child_token_hash CHAR(64) NOT NULL, + pickup_token_hash CHAR(64) NOT NULL, + expires_at DATETIME NOT NULL, + checkin_at DATETIME NULL, + checkout_at DATETIME NULL, + checkin_staff BIGINT UNSIGNED NULL, + checkout_staff BIGINT UNSIGNED NULL, + label_printed_at DATETIME NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + KEY idx_child (child_id), + KEY idx_service (service_id), + KEY idx_status (status), + KEY idx_family (family_id), + KEY idx_expires (expires_at) + ) $charset;"; + + $sql[] = "CREATE TABLE IF NOT EXISTS `$audit` ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + checkin_id BIGINT UNSIGNED NOT NULL, + actor_id BIGINT UNSIGNED NULL, + action VARCHAR(50) NOT NULL, + note TEXT NULL, + created_at DATETIME NOT NULL, + KEY idx_checkin (checkin_id) + ) $charset;"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + foreach ($sql as $statement) { + dbDelta($statement); + } + } + + public function register_post_types(): void { + $parent_menu = $this->is_enabled() ? 'uc-expo-qr' : false; + + $labels = [ + 'labels' => [ + 'name' => __('Nursery Services', 'uc-expo'), + 'singular_name' => __('Nursery Service', 'uc-expo'), + ], + 'public' => false, + 'show_ui' => true, + 'show_in_menu' => $parent_menu, + 'supports' => ['title', 'editor'], + 'menu_icon' => 'dashicons-calendar-alt', + 'show_in_rest' => true, + ]; + register_post_type(self::CPT_SERVICE, $labels); + + $family_labels = [ + 'labels' => [ + 'name' => __('Nursery Families', 'uc-expo'), + 'singular_name' => __('Nursery Family', 'uc-expo'), + ], + 'public' => false, + 'show_ui' => true, + 'show_in_menu' => $parent_menu, + 'supports' => ['title', 'editor'], + 'menu_icon' => 'dashicons-groups', + 'show_in_rest' => true, + ]; + register_post_type(self::CPT_FAMILY, $family_labels); + + $child_labels = [ + 'labels' => [ + 'name' => __('Nursery Children', 'uc-expo'), + 'singular_name' => __('Nursery Child', 'uc-expo'), + ], + 'public' => false, + 'show_ui' => true, + 'show_in_menu' => $parent_menu, + 'supports' => ['title', 'editor'], + 'menu_icon' => 'dashicons-buddicons-buddypress-logo', + 'show_in_rest' => true, + ]; + register_post_type(self::CPT_CHILD, $child_labels); + } + + public function register_meta_boxes(): void { + add_meta_box('uc-nursery-service', __('Service Details', 'uc-expo'), [$this, 'render_service_meta'], self::CPT_SERVICE, 'side', 'default'); + add_meta_box('uc-nursery-family', __('Family Details', 'uc-expo'), [$this, 'render_family_meta'], self::CPT_FAMILY, 'side', 'default'); + add_meta_box('uc-nursery-child', __('Child Details', 'uc-expo'), [$this, 'render_child_meta'], self::CPT_CHILD, 'side', 'default'); + } + + public function render_service_meta(WP_Post $post): void { + $start = get_post_meta($post->ID, self::META_SERVICE_START, true); + $end = get_post_meta($post->ID, self::META_SERVICE_END, true); + $label = get_post_meta($post->ID, self::META_SERVICE_LABEL, true); + wp_nonce_field('uc_nursery_service_meta', 'uc_nursery_service_nonce'); + echo '

'; + echo '

'; + echo '

'; + } + + public function render_family_meta(WP_Post $post): void { + $contact = get_post_meta($post->ID, self::META_FAMILY_CONTACT, true); + $contact = is_array($contact) ? $contact : []; + wp_nonce_field('uc_nursery_family_meta', 'uc_nursery_family_nonce'); + echo '

'; + echo '

'; + echo '

'; + } + + public function render_child_meta(WP_Post $post): void { + $family_id = (int) get_post_meta($post->ID, self::META_CHILD_FAMILY, true); + $allergy = get_post_meta($post->ID, self::META_CHILD_ALLERGY, true); + $notes = get_post_meta($post->ID, self::META_CHILD_NOTES, true); + wp_nonce_field('uc_nursery_child_meta', 'uc_nursery_child_nonce'); + echo '

'; + echo '

'; + echo '

'; + } + + private function format_datetime_local($value): string { + if (!$value) return ''; + $ts = strtotime($value); + if (!$ts) return ''; + return date('Y-m-d\TH:i', $ts); + } + + public function save_post_meta(int $post_id, WP_Post $post): void { + if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return; + if ($post->post_type === self::CPT_SERVICE) { + if (!isset($_POST['uc_nursery_service_nonce']) || !wp_verify_nonce($_POST['uc_nursery_service_nonce'], 'uc_nursery_service_meta')) return; + $data = $_POST['uc_nursery'] ?? []; + $label = sanitize_text_field($data['label'] ?? ''); + $start = sanitize_text_field($data['start'] ?? ''); + $end = sanitize_text_field($data['end'] ?? ''); + update_post_meta($post_id, self::META_SERVICE_LABEL, $label); + update_post_meta($post_id, self::META_SERVICE_START, $start); + update_post_meta($post_id, self::META_SERVICE_END, $end); + } + if ($post->post_type === self::CPT_FAMILY) { + if (!isset($_POST['uc_nursery_family_nonce']) || !wp_verify_nonce($_POST['uc_nursery_family_nonce'], 'uc_nursery_family_meta')) return; + $data = $_POST['uc_nursery_family'] ?? []; + $contact = [ + 'name' => sanitize_text_field($data['name'] ?? ''), + 'email' => sanitize_email($data['email'] ?? ''), + 'phone' => sanitize_text_field($data['phone'] ?? ''), + ]; + update_post_meta($post_id, self::META_FAMILY_CONTACT, $contact); + } + if ($post->post_type === self::CPT_CHILD) { + if (!isset($_POST['uc_nursery_child_nonce']) || !wp_verify_nonce($_POST['uc_nursery_child_nonce'], 'uc_nursery_child_meta')) return; + $data = $_POST['uc_nursery_child'] ?? []; + $family = isset($data['family']) ? absint($data['family']) : 0; + update_post_meta($post_id, self::META_CHILD_FAMILY, $family); + update_post_meta($post_id, self::META_CHILD_ALLERGY, sanitize_textarea_field($data['allergies'] ?? '')); + update_post_meta($post_id, self::META_CHILD_NOTES, sanitize_textarea_field($data['notes'] ?? '')); + } + } + + public function register_menu(): void { + if (!$this->is_enabled()) return; + add_submenu_page('uc-expo-qr', __('Nursery Check-ins', 'uc-expo'), __('Nursery Check-ins', 'uc-expo'), 'manage_options', 'uc-nursery', [$this, 'render_dashboard']); + add_submenu_page('uc-expo-qr', __('Nursery Print', 'uc-expo'), __('Nursery Print', 'uc-expo'), 'manage_options', 'uc-nursery-print', [$this, 'render_print_page']); + } + + public function enqueue_assets(string $hook): void { + if (strpos($hook, 'uc-nursery') === false) return; + wp_enqueue_style('uc-nursery-admin', UC_EXPO_QR_URL . 'assets/nursery.css', [], UC_Expo_QR_Checkins::VERSION); + wp_enqueue_script('uc-expo-qrcode-lib', 'https://unpkg.com/qrcodejs@1.0.0/qrcode.min.js', [], '1.0.0', true); + wp_enqueue_script('uc-nursery-admin', UC_EXPO_QR_URL . 'assets/nursery.js', ['uc-expo-qrcode-lib'], UC_Expo_QR_Checkins::VERSION, true); + wp_localize_script('uc-nursery-admin', 'UCNursery', [ + 'confirmCheckout' => __('Confirm checkout?', 'uc-expo'), + ]); + } + + public function register_shortcodes(): void { + add_shortcode('uc_print_labels', [$this, 'shortcode_print_labels']); + } + + public function register_rest_routes(): void { + // Reserved for future kiosk integrations. + } + + public function shortcode_print_labels($atts = []): string { + if (!current_user_can('manage_options')) { + return '
'.esc_html__('Nursery printing is restricted to staff.', 'uc-expo').'
'; + } + $atts = shortcode_atts([ + 'service_id' => 0, + 'family_id' => 0, + ], $atts, 'uc_print_labels'); + $service_id = absint($atts['service_id']); + $family_id = absint($atts['family_id']); + ob_start(); + $this->render_print_content($service_id, $family_id, true); + return ob_get_clean(); + } + + public function handle_actions(): void { + if (!is_admin() || !current_user_can('manage_options')) return; + if (!$this->is_enabled()) return; + + if (!empty($_POST['uc_nursery_action'])) { + $action = sanitize_key($_POST['uc_nursery_action']); + if ($action === 'check_in' && check_admin_referer('uc_nursery_checkin')) { + $this->process_check_in(); + } + if ($action === 'check_out' && check_admin_referer('uc_nursery_checkin')) { + $this->process_check_out(); + } + if ($action === 'regenerate' && check_admin_referer('uc_nursery_checkin')) { + $this->process_regenerate_tokens(); + } + } + } + + private function process_check_in(): void { + $child_id = absint($_POST['child_id'] ?? 0); + $service_id = absint($_POST['service_id'] ?? 0); + $note = sanitize_text_field($_POST['note'] ?? ''); + if (!$child_id || !$service_id) return; + $result = $this->check_in_child($child_id, $service_id, get_current_user_id(), $note); + if (is_wp_error($result)) { + add_action('admin_notices', function() use ($result){ + echo '

'.esc_html($result->get_error_message()).'

'; + }); + return; + } + $tokens = $result['tokens']; + set_transient($this->token_transient_key($result['checkin_id']), $tokens, $result['ttl']); + $redirect = add_query_arg([ + 'page' => 'uc-nursery', + 'service' => $service_id, + 'checked_in' => $result['checkin_id'], + ], admin_url('admin.php')); + wp_safe_redirect($redirect); exit; + } + + private function process_check_out(): void { + $token = sanitize_text_field($_POST['pickup_token'] ?? ''); + $checkin = absint($_POST['checkin_id'] ?? 0); + $service = absint($_POST['service_id'] ?? 0); + if ($token) { + $res = $this->complete_pickup_by_token($token, get_current_user_id()); + } elseif ($checkin) { + $res = $this->complete_pickup($checkin, get_current_user_id()); + } else { + return; + } + if (is_wp_error($res)) { + add_action('admin_notices', function() use ($res){ + echo '

'.esc_html($res->get_error_message()).'

'; + }); + return; + } + $redirect = add_query_arg([ + 'page' => 'uc-nursery', + 'service' => $service ?: $res['service_id'], + 'checked_out' => $res['checkin_id'], + ], admin_url('admin.php')); + wp_safe_redirect($redirect); exit; + } + + private function process_regenerate_tokens(): void { + $checkin_id = absint($_POST['checkin_id'] ?? 0); + if (!$checkin_id) return; + $res = $this->regenerate_tokens($checkin_id, get_current_user_id()); + if (is_wp_error($res)) { + add_action('admin_notices', function() use ($res){ + echo '

'.esc_html($res->get_error_message()).'

'; + }); + return; + } + set_transient($this->token_transient_key($checkin_id), $res['tokens'], $res['ttl']); + $redirect = add_query_arg([ + 'page' => 'uc-nursery', + 'service' => $res['service_id'], + 'regenerated' => $checkin_id, + ], admin_url('admin.php')); + wp_safe_redirect($redirect); exit; + } + + private function check_in_child(int $child_id, int $service_id, int $staff_id, string $note = '') { + $child = get_post($child_id); + $service = get_post($service_id); + if (!$child || $child->post_type !== self::CPT_CHILD) return new WP_Error('invalid_child', __('Child not found.', 'uc-expo')); + if (!$service || $service->post_type !== self::CPT_SERVICE) return new WP_Error('invalid_service', __('Service not found.', 'uc-expo')); + $family_id = (int) get_post_meta($child_id, self::META_CHILD_FAMILY, true); + if (!$family_id) return new WP_Error('missing_family', __('Assign the child to a family before checking in.', 'uc-expo')); + + $active = $this->get_active_checkin($child_id, $service_id); + if ($active && $active->status === self::STATUS_CHECKED_IN) { + return new WP_Error('already_checked_in', __('Child is already checked in for this service.', 'uc-expo')); + } + + $tokens = $this->generate_tokens(); + $hashes = $this->hash_tokens($tokens); + $expires = $this->get_service_end($service_id); + $ttl = max(1, $expires - time()); + + global $wpdb; + $table = $wpdb->prefix . self::TABLE_CHECKINS; + $data = [ + 'child_id' => $child_id, + 'family_id' => $family_id, + 'service_id' => $service_id, + 'status' => self::STATUS_CHECKED_IN, + 'child_token_hash' => $hashes['child'], + 'pickup_token_hash' => $hashes['pickup'], + 'expires_at' => gmdate('Y-m-d H:i:s', $expires), + 'checkin_at' => current_time('mysql'), + 'checkin_staff' => $staff_id, + 'created_at' => current_time('mysql'), + 'updated_at' => current_time('mysql'), + ]; + if ($active) { + $wpdb->update($table, $data, ['id' => $active->id], ['%d','%d','%d','%s','%s','%s','%s','%s','%d','%s','%s'], ['%d']); + $checkin_id = $active->id; + } else { + $wpdb->insert($table, $data, ['%d','%d','%d','%s','%s','%s','%s','%s','%d','%s','%s']); + $checkin_id = (int) $wpdb->insert_id; + } + $this->log_audit($checkin_id, 'check_in', $staff_id, $note); + + return [ + 'checkin_id' => $checkin_id, + 'tokens' => $tokens, + 'ttl' => $ttl, + ]; + } + + private function regenerate_tokens(int $checkin_id, int $staff_id) { + $record = $this->get_checkin($checkin_id); + if (!$record) return new WP_Error('missing', __('Check-in not found.', 'uc-expo')); + if ($record->status !== self::STATUS_CHECKED_IN) return new WP_Error('invalid', __('Only active check-ins can be regenerated.', 'uc-expo')); + $tokens = $this->generate_tokens(); + $hashes = $this->hash_tokens($tokens); + global $wpdb; + $table = $wpdb->prefix . self::TABLE_CHECKINS; + $wpdb->update($table, [ + 'child_token_hash' => $hashes['child'], + 'pickup_token_hash' => $hashes['pickup'], + 'updated_at' => current_time('mysql'), + ], ['id' => $checkin_id], ['%s','%s','%s'], ['%d']); + $this->log_audit($checkin_id, 'regenerate_tokens', $staff_id, ''); + $ttl = max(1, strtotime($record->expires_at . ' UTC') - time()); + return [ + 'checkin_id' => $checkin_id, + 'tokens' => $tokens, + 'ttl' => $ttl, + 'service_id' => (int) $record->service_id, + ]; + } + + private function complete_pickup(int $checkin_id, int $staff_id) { + $record = $this->get_checkin($checkin_id); + if (!$record) return new WP_Error('missing', __('Check-in not found.', 'uc-expo')); + if ($record->status !== self::STATUS_CHECKED_IN) return new WP_Error('invalid_status', __('Check-in is not active.', 'uc-expo')); + global $wpdb; + $table = $wpdb->prefix . self::TABLE_CHECKINS; + $wpdb->update($table, [ + 'status' => self::STATUS_CHECKED_OUT, + 'checkout_at' => current_time('mysql'), + 'checkout_staff' => $staff_id, + 'updated_at' => current_time('mysql'), + ], ['id' => $checkin_id], ['%s','%s','%d','%s'], ['%d']); + delete_transient($this->token_transient_key($checkin_id)); + $this->log_audit($checkin_id, 'check_out', $staff_id, ''); + return [ + 'checkin_id' => $checkin_id, + 'service_id' => (int) $record->service_id, + ]; + } + + private function complete_pickup_by_token(string $token, int $staff_id) { + $hash = $this->hash_token($token); + global $wpdb; + $table = $wpdb->prefix . self::TABLE_CHECKINS; + $record = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE pickup_token_hash = %s AND status = %s", $hash, self::STATUS_CHECKED_IN)); + if (!$record) return new WP_Error('invalid_token', __('Pickup token not found or expired.', 'uc-expo')); + return $this->complete_pickup((int) $record->id, $staff_id); + } + + public function handle_qr_scan(string $type, int $checkin_id, string $token): void { + $record = $this->get_checkin($checkin_id); + if (!$record) { + status_header(404); wp_die(__('Check-in record not found.', 'uc-expo'), __('Not Found', 'uc-expo'), ['response' => 404]); + } + $valid = false; + if ($type === 'child') { + $valid = hash_equals($record->child_token_hash, $this->hash_token($token)); + } elseif ($type === 'pickup') { + $valid = hash_equals($record->pickup_token_hash, $this->hash_token($token)); + } elseif ($type === 'label') { + $valid = true; // label view uses signature verification already + } + if (!$valid) { + status_header(403); wp_die(__('Token mismatch or expired.', 'uc-expo'), __('Forbidden', 'uc-expo'), ['response' => 403]); + } + if (!is_user_logged_in()) { + $return = home_url($_SERVER['REQUEST_URI'] ?? ''); + wp_safe_redirect(wp_login_url($return)); exit; + } + if (!current_user_can('manage_options')) { + status_header(403); wp_die(__('You do not have permission for nursery operations.', 'uc-expo'), __('Forbidden', 'uc-expo'), ['response' => 403]); + } + if ($type === 'pickup') { + $res = $this->complete_pickup($checkin_id, get_current_user_id()); + if (is_wp_error($res)) { + status_header(400); wp_die(esc_html($res->get_error_message()), __('Nursery', 'uc-expo'), ['response' => 400]); + } + $url = add_query_arg([ + 'page' => 'uc-nursery', + 'service' => $record->service_id, + 'checked_out' => $checkin_id, + ], admin_url('admin.php')); + wp_safe_redirect($url); exit; + } + if ($type === 'label') { + $url = add_query_arg([ + 'page' => 'uc-nursery-print', + 'service' => $record->service_id, + 'checkin' => $checkin_id, + ], admin_url('admin.php')); + wp_safe_redirect($url); exit; + } + $url = add_query_arg([ + 'page' => 'uc-nursery', + 'service' => $record->service_id, + 'child' => $record->child_id, + ], admin_url('admin.php')); + wp_safe_redirect($url); exit; + } + + private function token_transient_key(int $checkin_id): string { + return 'uc_nursery_tokens_' . $checkin_id; + } + + private function generate_tokens(): array { + return [ + 'child' => $this->generate_token(), + 'pickup' => $this->generate_token(), + ]; + } + + private function hash_tokens(array $tokens): array { + return [ + 'child' => $this->hash_token($tokens['child']), + 'pickup' => $this->hash_token($tokens['pickup']), + ]; + } + + private function generate_token(): string { + $alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + $token = ''; + for ($i = 0; $i < 10; $i++) { + $token .= $alphabet[random_int(0, strlen($alphabet) - 1)]; + } + return $token; + } + + private function hash_token(string $token): string { + return hash_hmac('sha256', $token, AUTH_SALT); + } + + private function get_service_end(int $service_id): int { + $end = get_post_meta($service_id, self::META_SERVICE_END, true); + $ts = $end ? strtotime($end) : false; + if ($ts) return $ts; + return time() + HOUR_IN_SECONDS * 6; + } + + private function get_active_checkin(int $child_id, int $service_id) { + global $wpdb; + $table = $wpdb->prefix . self::TABLE_CHECKINS; + $this->expire_records(); + return $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE child_id = %d AND service_id = %d ORDER BY id DESC LIMIT 1", $child_id, $service_id)); + } + + private function get_checkin(int $checkin_id) { + global $wpdb; + $table = $wpdb->prefix . self::TABLE_CHECKINS; + $this->expire_records(); + return $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE id = %d", $checkin_id)); + } + + private function expire_records(): void { + global $wpdb; + $table = $wpdb->prefix . self::TABLE_CHECKINS; + $now = current_time('mysql'); + $wpdb->query($wpdb->prepare("UPDATE $table SET status = %s, updated_at = %s WHERE status = %s AND expires_at < %s", self::STATUS_EXPIRED, $now, self::STATUS_CHECKED_IN, $now)); + } + + private function log_audit(int $checkin_id, string $action, int $actor_id = 0, string $note = ''): void { + global $wpdb; + $table = $wpdb->prefix . self::TABLE_AUDIT; + $wpdb->insert($table, [ + 'checkin_id' => $checkin_id, + 'actor_id' => $actor_id ?: null, + 'action' => $action, + 'note' => $note, + 'created_at' => current_time('mysql'), + ], ['%d','%d','%s','%s','%s']); + } + + public function render_dashboard(): void { + if (!current_user_can('manage_options')) return; + $service_id = absint($_GET['service'] ?? 0); + $child_focus = absint($_GET['child'] ?? 0); + $message = ''; + if (!empty($_GET['checked_in'])) { + $message = __('Child checked in. Print and distribute the pickup pass now.', 'uc-expo'); + } elseif (!empty($_GET['checked_out'])) { + $message = __('Child checked out successfully.', 'uc-expo'); + } elseif (!empty($_GET['regenerated'])) { + $message = __('Tokens regenerated. Please reprint the labels.', 'uc-expo'); + } + echo '

'.esc_html__('Nursery Check-ins', 'uc-expo').'

'; + if ($message) { + echo '

'.esc_html($message).'

'; + } + echo '
'; + echo '
'; + echo ''; + $services = get_posts([ + 'post_type' => self::CPT_SERVICE, + 'post_status' => 'publish', + 'numberposts' => 50, + 'orderby' => 'date', + 'order' => 'DESC', + ]); + echo ' '; + submit_button(__('Load', 'uc-expo'), 'secondary', '', false); + echo '
'; + + if ($service_id) { + echo '
'; + echo '
'; + wp_nonce_field('uc_nursery_checkin'); + echo ''; + echo ''; + echo ' '; + submit_button(__('Validate & Check Out', 'uc-expo'), 'primary', '', false); + echo '
'; + echo '
'; + $children = $this->get_children_for_service($service_id); + echo '
'; + foreach ($children as $child) { + $status = $child['status']; + $classes = 'uc-nursery-child status-' . esc_attr($status); + echo '
'; + echo '

'.esc_html($child['name']).'

'; + if ($child['allergies']) { + echo '

'.esc_html__('Allergies', 'uc-expo').': '.esc_html($child['allergies']).'

'; + } + echo '

'.esc_html__('Family', 'uc-expo').': '.esc_html($child['family']).'

'; + echo '

'.esc_html__('Status', 'uc-expo').': '.esc_html(ucwords(str_replace('_', ' ', $status))).'

'; + echo '
'; + if ($status !== self::STATUS_CHECKED_IN) { + echo '
'; + wp_nonce_field('uc_nursery_checkin'); + echo ''; + echo ''; + echo ''; + echo ''; + echo '
'; + } else { + echo '
'; + wp_nonce_field('uc_nursery_checkin'); + echo ''; + echo ''; + echo ''; + echo ''; + echo '
'; + echo '
'; + wp_nonce_field('uc_nursery_checkin'); + echo ''; + echo ''; + echo ''; + echo '
'; + $tokens = get_transient($this->token_transient_key($child['checkin_id'])); + if ($tokens) { + $child_url = UC_Expo_QR_Checkins::instance()->nursery_qr_url('child', $child['checkin_id'], $tokens['child']); + $pickup_url = UC_Expo_QR_Checkins::instance()->nursery_qr_url('pickup', $child['checkin_id'], $tokens['pickup']); + $label_url = UC_Expo_QR_Checkins::instance()->nursery_qr_url('label', $child['checkin_id'], $tokens['child']); + echo '
'; + echo '
'.esc_html__('Child QR', 'uc-expo').'
'.esc_html($tokens['child']).'
'; + echo '
'.esc_html__('Pickup QR', 'uc-expo').'
'.esc_html($tokens['pickup']).'
'; + echo '

'.esc_html__('Open Label View', 'uc-expo').'

'; + echo '
'; + } + } + echo '
'; + echo '
'; + } + echo '
'; + } else { + echo '

'.esc_html__('Select a service to manage nursery check-ins.', 'uc-expo').'

'; + } + echo '
'; + } + + private function get_children_for_service(int $service_id): array { + $args = [ + 'post_type' => self::CPT_CHILD, + 'post_status' => 'publish', + 'numberposts' => -1, + 'orderby' => 'title', + 'order' => 'ASC', + ]; + $children = get_posts($args); + $list = []; + foreach ($children as $child) { + $family_id = (int) get_post_meta($child->ID, self::META_CHILD_FAMILY, true); + $family = $family_id ? get_post($family_id) : null; + $checkin = $this->get_active_checkin($child->ID, $service_id); + $list[] = [ + 'id' => $child->ID, + 'name' => $child->post_title, + 'allergies' => get_post_meta($child->ID, self::META_CHILD_ALLERGY, true), + 'family' => $family ? $family->post_title : __('Unassigned', 'uc-expo'), + 'status' => $checkin ? $checkin->status : self::STATUS_CREATED, + 'checkin_id' => $checkin ? (int) $checkin->id : 0, + ]; + } + return $list; + } + + public function render_print_page(): void { + if (!current_user_can('manage_options')) return; + $service_id = absint($_GET['service'] ?? 0); + $family_id = absint($_GET['family'] ?? 0); + $checkin_id = absint($_GET['checkin'] ?? 0); + echo '

'.esc_html__('Nursery Label Printer', 'uc-expo').'

'; + echo '
'; + echo '
'; + echo ''; + echo ' '; + echo ' '; + if ($checkin_id) echo ''; + submit_button(__('Load', 'uc-expo'), 'secondary', '', false); + echo ''; + echo '
'; + $this->render_print_content($service_id, $family_id, false, $checkin_id); + echo '
'; + } + + private function render_print_content(int $service_id, int $family_id, bool $is_shortcode, int $checkin_id = 0): void { + if (!$service_id) { + if ($is_shortcode) { + echo '

'.esc_html__('Select a service to print nursery labels.', 'uc-expo').'

'; + } + return; + } + $options = $this->get_print_options(); + echo '
'; + echo '
'; + if (!$is_shortcode) { + echo ''; + } + echo ''; + if ($family_id) echo ''; + if ($checkin_id) echo ''; + echo ' '; + echo ' '; + echo ' '; + submit_button(__('Apply', 'uc-expo'), 'secondary', '', false); + echo '
'; + echo '
'; + $checkins = $this->get_print_checkins($service_id, $family_id, $checkin_id); + if (!$checkins) { + echo '

'.esc_html__('No active check-ins for this selection.', 'uc-expo').'

'; + return; + } + $current_size = sanitize_key($_GET['label_size'] ?? '2x3'); + $columns = max(1, min(4, intval($_GET['columns'] ?? 2))); + $bleed = max(0, min(48, intval($_GET['bleed'] ?? 12))); + $class = 'label-' . $current_size; + echo '
'; + foreach ($checkins as $row) { + $tokens = get_transient($this->token_transient_key($row['id'])); + $child_token = $tokens['child'] ?? null; + $pickup_token = $tokens['pickup'] ?? null; + $missing_msg = __('Token unavailable. Regenerate to print.', 'uc-expo'); + if (!$child_token) { + // tokens not cached; offer regenerate prompt + $child_token = $missing_msg; + } + $pickup_url = ($pickup_token) ? UC_Expo_QR_Checkins::instance()->nursery_qr_url('pickup', $row['id'], $pickup_token) : ''; + $child_url = ($child_token !== $missing_msg) ? UC_Expo_QR_Checkins::instance()->nursery_qr_url('child', $row['id'], $child_token) : ''; + echo '
'; + echo '

'.esc_html($row['child']).'

'; + if ($row['allergies']) { + echo '

'.esc_html__('Allergies', 'uc-expo').': '.esc_html($row['allergies']).'

'; + } + echo '

'.esc_html__('Service', 'uc-expo').': '.esc_html($row['service_label']).'

'; + echo '

'.esc_html__('Time', 'uc-expo').': '.esc_html($row['service_time']).'

'; + if ($child_url) { + echo '
'; + } else { + echo '

'.esc_html__('No QR available. Regenerate tokens.', 'uc-expo').'

'; + } + if ($pickup_url) { + echo ''.esc_html__('Guardian QR ready', 'uc-expo').''; + } + echo '
'; + } + echo '
'; + } + + private function get_print_checkins(int $service_id, int $family_id, int $checkin_id = 0): array { + global $wpdb; + $table = $wpdb->prefix . self::TABLE_CHECKINS; + $this->expire_records(); + $sql = "SELECT id, child_id, service_id FROM $table WHERE service_id = %d AND status = %s"; + $args = [$service_id, self::STATUS_CHECKED_IN]; + if ($family_id) { + $sql .= " AND family_id = %d"; + $args[] = $family_id; + } + if ($checkin_id) { + $sql .= " AND id = %d"; + $args[] = $checkin_id; + } + $rows = $wpdb->get_results($wpdb->prepare($sql, ...$args), ARRAY_A); + $out = []; + foreach ($rows as $row) { + $child = get_post((int) $row['child_id']); + $service = get_post((int) $row['service_id']); + if (!$child || !$service) continue; + $out[] = [ + 'id' => (int) $row['id'], + 'child' => $child->post_title, + 'allergies' => get_post_meta($child->ID, self::META_CHILD_ALLERGY, true), + 'service_label' => get_post_meta($service->ID, self::META_SERVICE_LABEL, true) ?: $service->post_title, + 'service_time' => $this->format_service_time($service->ID), + ]; + } + return $out; + } + + private function get_print_options(): array { + return [ + 'sizes' => [ + '2x3' => __('2″ × 3″', 'uc-expo'), + '3x4' => __('3″ × 4″', 'uc-expo'), + ], + ]; + } + + private function format_service_time(int $service_id): string { + $start = get_post_meta($service_id, self::META_SERVICE_START, true); + $end = get_post_meta($service_id, self::META_SERVICE_END, true); + if ($start && $end) { + $s = date_i18n(get_option('time_format'), strtotime($start)); + $e = date_i18n(get_option('time_format'), strtotime($end)); + return $s . ' – ' . $e; + } + if ($start) { + return date_i18n(get_option('time_format'), strtotime($start)); + } + return __('Time TBD', 'uc-expo'); + } +} diff --git a/includes/class-qr-checkins.php b/includes/class-qr-checkins.php index a52b247..ab891ed 100644 --- a/includes/class-qr-checkins.php +++ b/includes/class-qr-checkins.php @@ -2,7 +2,7 @@ if (!defined('ABSPATH')) exit; final class UC_Expo_QR_Checkins { - const VERSION = '1.6.0'; + const VERSION = '2.0.0'; const OPTION_SECRET = 'uc_expo_qr_secret'; const OPTION_EVENT = 'uc_expo_current_event_id'; const COOKIE_PENDING = 'uc_qr_pending_checkin'; @@ -50,6 +50,11 @@ public function add_rewrite() { 'index.php?' . self::QV_FLAG . '=1&'. self::QV_TP .'=exhibitor&ex=$matches[1]&ev=$matches[2]&sig=$matches[3]', 'top' ); + add_rewrite_rule( + '^qr/(child|pickup|label)/([0-9]+)/([^/]+)/([^/]+)$', + 'index.php?' . self::QV_FLAG . '=1&'. self::QV_TP .'=$matches[1]&ex=$matches[2]&ev=$matches[3]&sig=$matches[4]', + 'top' + ); } public function table_name() { global $wpdb; return $wpdb->prefix . 'uc_expo_checkins'; } @@ -86,6 +91,14 @@ public function handle_qr_route() { $event_id = sanitize_text_field((string) get_query_var('ev')); $sig = sanitize_text_field((string) get_query_var('sig')); + if (UC_Expo_QR_Nursery::instance()->is_nursery_qr_type($type_seg)) { + if (!$post_id || !$event_id || !$sig || !$type_seg || !$this->verify_nursery_sig($type_seg, $post_id, $event_id, $sig)) { + status_header(403); wp_die('Invalid or expired QR link.', 'QR Forbidden', ['response' => 403]); + } + UC_Expo_QR_Nursery::instance()->handle_qr_scan($type_seg, $post_id, $event_id); + return; + } + if (!$post_id || !$event_id || !$sig || !$type_seg || !$this->verify_sig($type_seg, $post_id, $event_id, $sig)) { status_header(403); wp_die('Invalid or expired QR link.', 'QR Forbidden', ['response' => 403]); } @@ -105,6 +118,21 @@ public function handle_qr_route() { wp_safe_redirect( wp_login_url($return) ); exit; } + public function nursery_sig_for(string $type_seg, int $record_id, string $token): string { + $data = "nursery:$type_seg|id:$record_id|token:$token"; + return $this->b64url(hash_hmac('sha256', $data, $this->get_secret(), true)); + } + + public function verify_nursery_sig(string $type_seg, int $record_id, string $token, string $sig): bool { + $calc = $this->nursery_sig_for($type_seg, $record_id, $token); + return hash_equals($calc, $sig); + } + + public function nursery_qr_url(string $type_seg, int $record_id, string $token): string { + $sig = $this->nursery_sig_for($type_seg, $record_id, $token); + return home_url("/qr/$type_seg/$record_id/$token/$sig"); + } + public function complete_post_login($user_login, $user) { if (empty($_COOKIE[self::COOKIE_PENDING])) return; $raw = wp_unslash($_COOKIE[self::COOKIE_PENDING]); diff --git a/uc-expo-qr-checkin.php b/uc-expo-qr-checkin.php index e465dda..ec4d7ee 100644 --- a/uc-expo-qr-checkin.php +++ b/uc-expo-qr-checkin.php @@ -2,7 +2,7 @@ /** * Plugin Name: UC Expo — QR Check-ins * Description: The ultimate app for running an expo. Allow attendees to check in to exhibitors booths, sessions, display leaderboarads, gamification, and more - * Version: 1.6.0 + * Version: 2.0.0 * Author: UC Dev Team * Requires PHP: 7.4 */ @@ -20,6 +20,7 @@ require_once UC_EXPO_QR_DIR . 'includes/class-leaderboard.php'; require_once UC_EXPO_QR_DIR . 'includes/class-blocks.php'; require_once UC_EXPO_QR_DIR . 'includes/class-dashboard-widget.php'; +require_once UC_EXPO_QR_DIR . 'includes/class-nursery.php'; add_action('plugins_loaded', function(){ UC_Expo_QR_Checkins::instance(); @@ -31,4 +32,5 @@ UC_Expo_QR_Dashboard_Widget::instance(); } UC_Expo_QR_Blocks::instance(); + UC_Expo_QR_Nursery::instance(); }); \ No newline at end of file