prefix . self::DISMISSED; } public function register(): void { add_filter( 'abientot_register_admin_modules', array( $this, 'modules' ) ); add_filter( 'abientot_render_admin_module', array( $this, 'render_module' ), 10, 2 ); add_action( 'wp_ajax_abientot_activity_list', array( $this, 'ajax_list' ) ); add_action( 'wp_ajax_abientot_activity_dismiss', array( $this, 'ajax_dismiss' ) ); add_action( 'admin_post_abientot_save_notification_preferences', array( $this, 'save_preferences' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_config' ), 200 ); } public function modules( array $modules ): array { $modules['history'] = array( 'title' => __( 'History', 'abientot' ), 'menu_title' => __( 'History', 'abientot' ), 'capability' => 'abientot_manage_bookings', 'description' => __( 'Review booking and configuration activity.', 'abientot' ), 'nav_icon' => 'dashicons-backup' ); $modules['notification-settings'] = array( 'title' => __( 'Notifications', 'abientot' ), 'menu_title' => __( 'Notifications', 'abientot' ), 'capability' => 'abientot_manage_settings', 'description' => __( 'Choose which activity appears in the notification bell.', 'abientot' ), 'nav_icon' => 'dashicons-bell' ); return $modules; } public function enqueue_config(): void { if ( ! current_user_can( 'abientot_manage_bookings' ) || ! wp_script_is( 'abientot-activity-center', 'enqueued' ) ) { return; } wp_localize_script( 'abientot-activity-center', 'abientotActivity', array( 'url' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'abientot_activity' ), 'historyUrl' => admin_url( 'admin.php?page=abientot-history' ) ) ); } public static function categories(): array { return apply_filters( 'abientot_activity_categories', array( 'bookings' => __( 'New bookings', 'abientot' ), 'edits' => __( 'Other booking edits', 'abientot' ), 'starts' => __( 'Appointments starting', 'abientot' ), 'statuses' => __( 'Booking status changes', 'abientot' ), 'cancellations' => __( 'Booking cancellations', 'abientot' ), 'reschedules' => __( 'Booking reschedules', 'abientot' ), 'payments' => __( 'Payment changes', 'abientot' ), 'customers' => __( 'Customer information changes', 'abientot' ), 'settings' => __( 'Configuration changes', 'abientot' ) ) ); } public static function normalize_preferences( array $values ): array { $categories = isset( $values['categories'] ) && is_array( $values['categories'] ) ? array_values( array_intersect( array_keys( self::categories() ), array_filter( $values['categories'], 'is_string' ) ) ) : array_keys( self::categories() ); return array( 'categories' => $categories, 'hours' => max( 1, min( 720, is_scalar( $values['hours'] ?? 24 ) ? (int) ( $values['hours'] ?? 24 ) : 24 ) ), 'extend_closures' => ! isset( $values['extend_closures'] ) || ! empty( $values['extend_closures'] ) ); } public static function preferences(): array { $stored = get_option( self::OPTION, array() ); return self::normalize_preferences( is_array( $stored ) ? $stored : array() ); } public function header_markup(): void { if ( ! current_user_can( 'abientot_manage_bookings' ) ) { return; } ?>
__( 'You do not have permission to access this Abientot page.', 'abientot' ) ), 403 ); } } public function ajax_list(): void { check_ajax_referer( 'abientot_activity', 'nonce' ); $this->authorize(); wp_send_json_success( $this->feed() ); } public function ajax_dismiss(): void { check_ajax_referer( 'abientot_activity', 'nonce' ); $this->authorize(); $key = \Abientot\Support\Input::post( 'abientot_activity', 'nonce' )->text( 'key' ); if ( ! preg_match( '/^log:[1-9][0-9]*$/D', $key ) || ! ( new ActivityLogRepository() )->can_read_event( (int) substr( $key, 4 ) ) ) { wp_send_json_error( array( 'message' => __( 'Invalid notification.', 'abientot' ) ), 400 ); } $dismissed = $this->dismissed(); $dismissed[ $key ] = time(); arsort( $dismissed ); if ( false === update_user_meta( get_current_user_id(), $this->dismissal_key(), array_slice( $dismissed, 0, 1000, true ) ) && ! isset( $this->dismissed()[ $key ] ) ) { wp_send_json_error( array( 'message' => __( 'The notification could not be dismissed.', 'abientot' ) ), 500 ); } wp_send_json_success( $this->feed() ); } private function dismissed(): array { $values = get_user_meta( get_current_user_id(), $this->dismissal_key(), true ); return is_array( $values ) ? array_filter( $values, static fn( $timestamp ): bool => is_numeric( $timestamp ) && (int) $timestamp >= time() - 90 * 86400 ) : array(); } public static function category( array $row ): string { $event = $row['event'] ?? ''; $object = $row['object_type'] ?? ''; if ( 'booking_started' === $event ) { return 'starts'; } if ( 'settings' === $object || str_contains( $event, 'settings' ) || str_contains( $event, 'configuration' ) ) { return 'settings'; } if ( str_contains( $event, 'waitlist' ) || $object === 'waitlist' ) { return 'waitlist'; } if ( str_contains( $event, 'resource' ) || $object === 'resource' ) { return 'resources'; } if ( str_contains( $event, 'payment' ) ) { return 'payments'; } if ( str_contains( $event, 'cancel' ) ) { return 'cancellations'; } if ( str_contains( $event, 'reschedul' ) ) { return 'reschedules'; } if ( str_contains( $event, 'status' ) ) { return 'statuses'; } if ( $object === 'customer' || str_contains( $event, 'customer' ) ) { return 'customers'; } if ( $event === 'booking_updated' ) { return 'edits'; } return 'bookings'; } private function feed(): array { $prefs = self::preferences(); $since = time() - $prefs['hours'] * 3600; // The provider supplies the earliest per-location closure window for the bounded query. $query_since = min( $since, (int) apply_filters( 'abientot_notification_query_since', $since, $prefs ) ); $dismissed = $this->dismissed(); $count = 0; $html = ''; $page = 1; $repo = new ActivityLogRepository(); $repo->capture_due_starts( $query_since ); $scanned = 0; do { $result = $repo->activity_page( $page++, 100, gmdate( 'Y-m-d H:i:s', $query_since ) ); foreach ( $result['rows'] as $row ) { ++$scanned; $window = (int) apply_filters( 'abientot_notification_since', $since, $row, $prefs ); if ( isset( $dismissed[ $row['activity_key'] ] ) || ! in_array( self::category( $row ), $prefs['categories'], true ) || strtotime( $row['created_at'] . ' UTC' ) < $window ) { continue; } ++$count; if ( $count <= 50 ) { $html .= $this->row_markup( $row, true ); } } } while ( $result['has_more'] && $scanned < 2000 ); if ( ! $count && ! $result['has_more'] ) { $html = '

' . esc_html__( 'No recent notifications.', 'abientot' ) . '

'; } if ( $count > 50 || $result['has_more'] ) { $html .= '

' . esc_html__( 'View history for more activity.', 'abientot' ) . '

'; } return array( 'count' => $count, 'html' => $html, 'truncated' => $result['has_more'], 'countLabel' => $result['has_more'] ? (string) $count . '+' : (string) $count ); } private function row_markup( array $row, bool $dismiss = false ): string { $actor = __( 'System', 'abientot' ); if ( 'customer' === $row['actor_type'] ) { $actor = __( 'Customer', 'abientot' ); } elseif ( 'user' === $row['actor_type'] ) { $user = get_userdata( (int) $row['actor_id'] ); $actor = $user ? sprintf( /* translators: %s: staff member display name. */ __( 'Staff: %s', 'abientot' ), $user->display_name ) : __( 'Deleted user', 'abientot' ); } $message = 'booking_started' === $row['event'] ? sprintf( /* translators: %d: booking ID. */ __( 'Appointment #%d is starting.', 'abientot' ), (int) $row['object_id'] ) : (string) $row['message']; $id = 'event-log' . (int) $row['id']; $details = ActivityDetails::for_row( $row ); $url = $dismiss ? admin_url( 'admin.php?page=abientot-history&activity_event=' . (int) $row['id'] . '#' . $id ) : ( $details['url'] ?? '' ); $html = '
'; if ( $url ) { $html .= ''; } $html .= '' . esc_html( $actor ) . '

' . esc_html( $message ) . '

'; if ( ! empty( $details['title'] ) || ! empty( $details['fields'] ) ) { $html .= '
' . esc_html( $details['title'] ?? '' ) . ''; if ( ! empty( $details['fields'] ) ) { $html .= '' . esc_html__( 'Current details', 'abientot' ) . ''; } $html .= '
'; foreach ( $details['fields'] ?? array()as$label => $value ) { $html .= '
' . esc_html( $label ) . '
' . esc_html( $value ) . '
'; } $html .= '
'; } if ( ! $dismiss && $url && ! empty( $details['link_label'] ) ) { $html .= '' . esc_html( $details['link_label'] ) . ''; } if ( $url ) { $html .= '
'; } if ( $dismiss ) { $html .= ''; } return $html . '
'; } /** Tags and attributes row_markup() produces. */ private static function row_html(): array { return array( 'article' => array( 'class' => true, 'id' => true ), 'a' => array( 'class' => true, 'href' => true, 'data-abientot-live-nav' => true ), 'time' => array(), 'span' => array( 'class' => true, 'aria-hidden' => true ), 'p' => array(), 'div' => array( 'class' => true ), 'strong' => array( 'class' => true ), 'dl' => array( 'class' => true ), 'dt' => array(), 'dd' => array(), 'button' => array( 'type' => true, 'class' => true, 'data-activity-dismiss' => true, 'aria-label' => true ), ); } public function render_module( bool $rendered, string $module ): bool { if ( 'history' === $module && current_user_can( 'abientot_manage_bookings' ) ) { $this->render_history(); return true; } if ( 'notification-settings' === $module && current_user_can( 'abientot_manage_settings' ) ) { $this->render_preferences(); return true; } return $rendered; } private function render_history(): void { $page = max( 1, min( 1000000, (int) ( \Abientot\Support\Request::view( 'activity_page', 'int' ) ?? 1 ) ) ); $repo = new ActivityLogRepository(); $prefs = self::preferences(); $since = time() - $prefs['hours'] * 3600; $repo->capture_due_starts( min( $since, (int) apply_filters( 'abientot_notification_query_since', $since, $prefs ) ) ); $event = \Abientot\Support\Request::view( 'activity_event', 'int' ); if ( null !== $event ) { $page = $repo->page_for_event( (int) $event ); } $result = $repo->activity_page( $page ); echo '

' . esc_html__( 'History', 'abientot' ) . '

'; foreach ( $result['rows'] as $row ) { echo wp_kses( $this->row_markup( $row ), self::row_html() ); } if ( ! $result['rows'] ) { echo '

' . esc_html__( 'No activity yet.', 'abientot' ) . '

'; } echo '
'; } private function render_preferences(): void { $prefs = self::preferences(); echo '

' . esc_html__( 'Notifications', 'abientot' ) . '

'; wp_nonce_field( 'abientot_notification_preferences' ); echo '

' . esc_html__( 'These choices affect the bell only. History keeps all activity.', 'abientot' ) . '

' . esc_html__( 'Notification categories', 'abientot' ) . ''; foreach ( self::categories() as $key => $label ) { echo '

'; } echo '

'; submit_button( __( 'Save notification settings', 'abientot' ) ); echo '
'; } public function save_preferences(): void { check_admin_referer( 'abientot_notification_preferences' ); if ( ! current_user_can( 'abientot_manage_settings' ) ) { wp_die( esc_html__( 'You do not have permission to access this Abientot page.', 'abientot' ) ); } $values = self::normalize_preferences( array( 'categories' => isset( $_POST['categories'] ) && is_array( $_POST['categories'] ) ? array_values( array_filter( map_deep( wp_unslash( $_POST['categories'] ), 'sanitize_key' ), 'is_string' ) ) : array(), 'hours' => isset( $_POST['hours'] ) && is_scalar( $_POST['hours'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['hours'] ) ) : 24, 'extend_closures' => ! empty( $_POST['extend_closures'] ) ) ); update_option( self::OPTION, $values, false ); wp_safe_redirect( admin_url( 'admin.php?page=abientot-settings&tab=notifications' ) ); exit; } }