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' ) . '
';
}
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;
}
}