customers = new CustomersRepository(); } public function register(): void { add_action( 'admin_post_abientot_export_customers', array( $this, 'export_csv' ) ); add_action( 'wp_ajax_abientot_customer_profile', array( $this, 'ajax_profile' ) ); add_action( 'wp_ajax_abientot_create_customer', array( $this, 'ajax_create' ) ); add_action( 'wp_ajax_abientot_update_customer', array( $this, 'ajax_update' ) ); } public function render_customers(): void { if ( ! current_user_can( 'abientot_manage_customers' ) ) { wp_die( esc_html__( 'You do not have permission to manage customers.', 'abientot' ) ); } $filters = $this->filters_from_request(); $page_number = max( 1, (int) ( Request::view( 'paged', 'int' ) ?? 1 ) ); $limit = 25; $offset = ( $page_number - 1 ) * $limit; $customers = $this->customers->list_for_admin( $filters, $limit, $offset ); $total = $this->customers->count_for_admin( $filters ); $customer_id = (int) Request::view( 'customer_id', 'int' ); $customer = $customer_id > 0 ? $this->customers->find( $customer_id ) : null; $this->render_filters( $filters ); echo '

' . esc_html( sprintf( /* translators: %s: three-letter currency code. */ __( 'Customer spending totals include payments in %s only.', 'abientot' ), (string) Settings::values()['currency'] ) ) . '

'; ?>

render_customers_table( $customers ); ?> render_pagination( $total, $limit, $page_number, $filters ); ?>
require_ajax_capability(); $input = Input::post( 'abientot_manage_customers', 'nonce' ); $customer = $this->customers->find( $input->int( 'customer_id' ) ); if ( ! $customer ) { wp_send_json_error( array( 'message' => __( 'Customer not found.', 'abientot' ) ), 404 ); } ob_start(); $this->render_profile( $customer ); wp_send_json_success( array( 'html' => (string) ob_get_clean(), 'customerId' => (int) $customer['id'] ) ); } public function ajax_create(): void { check_ajax_referer( 'abientot_manage_customers', 'nonce' ); $this->require_ajax_capability(); $input = Input::post( 'abientot_manage_customers', 'nonce' ); \Abientot\Repositories\BookingLock::require_configuration_lock(); $first_name = $input->text( 'first_name' ); $last_name = $input->text( 'last_name' ); $email = $input->email( 'email' ); $phone = $input->text( 'phone' ); $notes = $input->textarea( 'notes' ); if ( '' === trim( $first_name . $last_name ) ) { wp_send_json_error( array( 'message' => __( 'Enter at least a first name or last name.', 'abientot' ) ), 400 ); } if ( $input->has_list( 'email' ) || ( '' !== $input->text( 'email' ) && '' === $email ) ) { wp_send_json_error( array( 'message' => __( 'Enter a valid email address.', 'abientot' ) ), 400 ); } if ( '' !== $phone ) { try { $country = $input->text( 'phone_country' ); $phone = \Abientot\Support\Phone::normalize( $phone, $country ); } catch ( \DomainException $error ) { wp_send_json_error( array( 'message' => __( 'Enter a valid phone number. Select its country or include the +country code.', 'abientot' ) ), 400 ); } } $now = gmdate( 'Y-m-d H:i:s' ); $id = $this->customers->insert( array( 'first_name' => $first_name, 'last_name' => $last_name, 'display_name' => trim( $first_name . ' ' . $last_name ), 'email' => $email, 'phone' => $phone, 'notes' => $notes, 'marketing_opt_in' => $input->filled( 'marketing_opt_in' ) ? 1 : 0, 'created_at' => $now, 'updated_at' => $now, ) ); if ( $id <= 0 ) { wp_send_json_error( array( 'message' => __( 'The customer could not be created.', 'abientot' ) ), 500 ); } wp_send_json_success( array( 'customer' => array( 'id' => $id, 'name' => trim( $first_name . ' ' . $last_name ), 'firstName' => $first_name, 'lastName' => $last_name, 'email' => $email, 'phone' => $phone, 'bookingCount' => 0, 'totalSpentCents' => 0, 'averageSpendCents' => 0, 'cancelledCount' => 0, 'noShowCount' => 0, 'updatedAt' => $now, 'actions' => array( 'view' => admin_url( 'admin.php?page=abientot-customers&customer_id=' . $id ), 'createBooking' => admin_url( 'admin.php?page=abientot-bookings&customer_id=' . $id ), ), ) ) ); } public function ajax_update(): void { check_ajax_referer( 'abientot_manage_customers', 'nonce' ); $this->require_ajax_capability(); $input = Input::post( 'abientot_manage_customers', 'nonce' ); \Abientot\Repositories\BookingLock::require_configuration_lock(); $id = $input->int( 'customer_id' ); $customer = $this->customers->find( $id ); if ( ! $customer ) { wp_send_json_error( array( 'message' => __( 'Customer not found.', 'abientot' ) ), 404 ); } $first_name = $input->text( 'first_name' ); $last_name = $input->text( 'last_name' ); $email = $input->email( 'email' ); $phone = $input->text( 'phone' ); $notes = $input->textarea( 'notes' ); if ( '' === trim( $first_name . $last_name ) ) { wp_send_json_error( array( 'message' => __( 'Enter at least a first name or last name.', 'abientot' ) ), 400 ); } if ( $input->has_list( 'email' ) || ( '' !== $input->text( 'email' ) && '' === $email ) ) { wp_send_json_error( array( 'message' => __( 'Enter a valid email address.', 'abientot' ) ), 400 ); } if ( '' !== $phone && $phone !== (string) ( $customer['phone'] ?? '' ) ) { try { $country = $input->text( 'phone_country' ); $phone = \Abientot\Support\Phone::normalize( $phone, $country ); } catch ( \DomainException $error ) { wp_send_json_error( array( 'message' => __( 'Enter a valid phone number. Select its country or include the +country code.', 'abientot' ) ), 400 ); } } $now = gmdate( 'Y-m-d H:i:s' ); $values = array( 'first_name' => $first_name, 'last_name' => $last_name, 'display_name' => trim( $first_name . ' ' . $last_name ), 'email' => $email, 'phone' => $phone, 'notes' => $notes, 'marketing_opt_in' => $input->filled( 'marketing_opt_in' ) ? 1 : 0, 'updated_at' => $now ); global $wpdb; $started = false; try { foreach ( array($wpdb->prefix . 'abientot_customers',$wpdb->prefix . 'abientot_activity_log')as$table ) { $engine = $wpdb->get_var( $wpdb->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s',$table ) ); if ( strtoupper( (string) $engine ) !== 'INNODB' ) { throw new \RuntimeException( 'Transactional customer storage required.' ); } } \Abientot\Support\TransactionGuard::prepare_write(); if ( false === $wpdb->query( 'START TRANSACTION' ) ) { throw new \RuntimeException( 'Customer transaction unavailable.' ); } $started = true; if ( ! $this->customers->update( $id,$values ) || ! \Abientot\Support\ActivityObserver::customer_changes( $id,$customer,$values ) ) { throw new \RuntimeException( 'Customer update unavailable.' ); } if ( false === $wpdb->query( 'COMMIT' ) ) { throw new \RuntimeException( 'Customer commit unavailable.' ); } $started = false; } catch ( \Throwable $error ) { if ( $started ) { $wpdb->query( 'ROLLBACK' ); } wp_send_json_error( array('message' => __( 'The customer could not be updated.','abientot' )),500 ); } $customer = $this->customers->find( $id ); $stats = $this->customers->stats_for_customer( $id ); ob_start(); $this->render_profile( $customer ); $html = (string) ob_get_clean(); wp_send_json_success( array( 'html' => $html, 'customer' => array( 'id' => $id, 'name' => trim( $first_name . ' ' . $last_name ), 'firstName' => $first_name, 'lastName' => $last_name, 'email' => $email, 'phone' => $phone, 'bookingCount' => (int) ( $stats['booking_count'] ?? 0 ), 'totalSpentCents' => (int) ( $stats['total_spent_cents'] ?? 0 ), 'averageSpendCents' => (int) ( $stats['average_spend_cents'] ?? 0 ), 'cancelledCount' => (int) ( $stats['cancelled_count'] ?? 0 ), 'noShowCount' => (int) ( $stats['no_show_count'] ?? 0 ), 'updatedAt' => $now, 'actions' => array( 'view' => admin_url( 'admin.php?page=abientot-customers&customer_id=' . $id ), 'createBooking' => admin_url( 'admin.php?page=abientot-bookings&customer_id=' . $id ) ), ), ) ); } public function export_csv(): void { check_admin_referer( 'abientot_export_customers' ); if ( ! current_user_can( 'abientot_manage_customers' ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'abientot' ), '', array( 'response' => 403 ) ); } $rows = $this->customers->iterate_for_export( $this->filters_from_request() ); nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename=abientot-customers-' . gmdate( 'Y-m-d' ) . '.csv' ); $out = fopen( 'php://output', 'w' ); if ( false === $out ) { exit; } fputcsv( $out, array( 'ID', 'Name', 'Email', 'Phone', 'Bookings', 'Total Spent', 'Average Spend', 'Cancelled', 'No-show', 'Created', 'Updated' ) ); foreach ( $rows as $row ) { fputcsv( $out, array( $row['id'], $this->csv_cell( (string) $row['display_name'] ), $this->csv_cell( (string) $row['email'] ), $this->csv_cell( (string) $row['phone'] ), (int) $row['booking_count'], $this->format_price( (int) $row['total_spent_cents'] ), $this->format_price( (int) $row['average_spend_cents'] ), (int) $row['cancelled_count'], (int) $row['no_show_count'], $this->csv_cell( (string) $row['created_at'] ), $this->csv_cell( (string) $row['updated_at'] ), ) ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the authorized php://output CSV response stream; WP_Filesystem does not manage response streams. fclose( $out ); exit; } private function render_filters( array $filters ): void { ?>
' . esc_html__( 'No customers found.', 'abientot' ) . '

'; return; } ?> duplicates_for_customer( $customer ); ?>
customer_name( $customer ) ); ?>
#

format_money( (int) $customer['total_spent_cents'] ) ); ?> format_money( (int) $customer['average_spend_cents'] ) ); ?>
' . esc_html__( 'Select a customer to view their profile, duplicate warnings, and booking history.', 'abientot' ) . '

'; return; } $stats = $this->customers->stats_for_customer( (int) $customer['id'] ); $history_total = (int) ( $stats['booking_count'] ?? 0 ); $history_pages = max( 1, (int) ceil( $history_total / 25 ) ); $history_page = min( $history_pages, max( 1, (int) ( Request::view( 'history_page', 'int' ) ?? 1 ) ) ); $history = $this->customers->booking_history( (int) $customer['id'], 25, ( $history_page - 1 ) * 25 ); $duplicates = $this->duplicates_for_customer( $customer ); ?>

customer_field( 'first_name', __( 'First name', 'abientot' ), 'text', 'given-name', (string) $customer['first_name'] ); ?> customer_field( 'last_name', __( 'Last name', 'abientot' ), 'text', 'family-name', (string) $customer['last_name'] ); ?> customer_field( 'email', __( 'Email', 'abientot' ), 'email', 'email', (string) $customer['email'] ); ?> customer_field( 'phone', __( 'Phone', 'abientot' ), 'tel', 'tel', (string) $customer['phone'] ); ?>
format_money( (int) ( $stats['total_spent_cents'] ?? 0 ) ) ); ?>
format_money( (int) ( $stats['average_spend_cents'] ?? 0 ) ) ); ?>

render_history_table( $history ); ?> 1 ) : ?>

customer_field( 'first_name', __( 'First name', 'abientot' ), 'text', 'given-name' ); ?> customer_field( 'last_name', __( 'Last name', 'abientot' ), 'text', 'family-name' ); ?> customer_field( 'email', __( 'Email', 'abientot' ), 'email', 'email' ); ?> customer_field( 'phone', __( 'Phone', 'abientot' ), 'tel', 'tel' ); ?>
'; \Abientot\Support\PhoneField::render( $name, $value, 'phone_country', array( 'id' => $id ), $label ); echo ''; return; } printf( '', esc_attr( $id ), esc_html( $label ), esc_attr( $name ), esc_attr( $type ), esc_attr( $autocomplete ), esc_attr( $value ) ); } private function render_history_table( array $history ): void { if ( ! $history ) { echo '

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

'; return; } ?>
local_datetime_label( (string) $booking['start_utc'], (string) $booking['timezone'] ) ); ?> label_from_slug( (string) $booking['payment_status'] ) ); ?> format_money( (int) $booking['price_cents'], (string) $booking['currency'] ) ); ?>
'; for ( $i = 1; $i <= $total_pages; $i++ ) { $args = array_merge( array( 'page' => 'abientot-customers', 'paged' => $i ), $this->query_args_from_filters( $filters ) ); printf( '%3$s ', esc_attr( $i === $page_number ? 'button-primary' : '' ), esc_url( add_query_arg( $args, admin_url( 'admin.php' ) ) ), esc_html( (string) $i ) ); } echo '

'; } private function filters_from_request(): array { return array( 'search' => (string) ( Request::view( 's' ) ?? '' ), ); } private function query_args_from_filters( array $filters ): array { return ! empty( $filters['search'] ) ? array( 's' => $filters['search'] ) : array(); } /** * @return array> */ private function duplicates_for_customer( array $customer): array { $settings = Settings::values(); $fields = array(); if ( ! empty( $settings['match_customer_by_email'] ) ) { $fields[] = 'email'; } if ( ! empty( $settings['match_customer_by_phone'] ) ) { $fields[] = 'phone'; } return $this->customers->duplicate_candidates( $customer, $fields ); } /** Capability check for AJAX handlers that verified their nonce first. */ private function require_ajax_capability(): void { if ( ! current_user_can( 'abientot_manage_customers' ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'abientot' ) ), 403 ); } } private function customer_name( array $customer ): string { $name = trim( (string) ( $customer['display_name'] ?? '' ) ); return '' !== $name ? $name : __( 'Unnamed customer', 'abientot' ); } private function local_datetime_label( string $utc_datetime, string $timezone, string $format = 'Y-m-d H:i' ): string { if ( 'Y-m-d H:i' === $format ) { return DateTimeHelper::format_utc_datetime( $utc_datetime, $timezone ); } try { $date = new DateTimeImmutable( $utc_datetime, DateTimeHelper::utc_timezone() ); return $date->setTimezone( new DateTimeZone( $timezone ) )->format( $format ); } catch ( Exception $exception ) { return get_date_from_gmt( $utc_datetime, $format ); } } private function format_price( int $cents ): string { return number_format( $cents / 100, 2, '.', '' ); } private function format_money( int $cents, string $currency = '' ): string { $currency = '' !== $currency ? $currency : (string) Settings::values()['currency']; return \Abientot\Support\Currency::format( $cents,$currency ); } private function label_from_slug( string $slug ): string { return ucwords( str_replace( '_', ' ', $slug ) ); } private function csv_cell( string $value ): string { $value = str_replace( array( "\r", "\n" ), ' ', $value ); return preg_match( '/^[\x00-\x20]*[=+\-@]/', $value ) ? "'" . $value : $value; } }