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 '';
+ if ($message) {
+ echo '
';
+ }
+ echo '
';
+ echo '
';
+
+ if ($service_id) {
+ echo '
';
+ 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 '
';
+ } else {
+ 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 '';
+ 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 '';
+ 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