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 );
?>
- format_money( (int) ( $stats['total_spent_cents'] ?? 0 ) ) ); ?>
- format_money( (int) ( $stats['average_spend_cents'] ?? 0 ) ) ); ?>
render_history_table( $history ); ?>
1 ) : ?>
';
\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;
}
?>
';
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;
}
}