appointments = new AppointmentsRepository(); $this->services = new ServicesRepository(); $this->categories = new ServiceCategoriesRepository(); $this->staff = new StaffRepository(); $this->locations = new LocationsRepository(); $this->availability = new AvailabilityRepository(); $this->exceptions = new AvailabilityExceptionsRepository(); } public function register(): void { add_action( 'admin_post_abientot_save_service', array( $this, 'save_service' ) ); add_action( 'admin_post_abientot_delete_service', array( $this, 'delete_service' ) ); add_action( 'admin_post_abientot_replace_service_and_delete', array( $this, 'replace_service_and_delete' ) ); add_action( 'admin_post_abientot_save_service_category', array( $this, 'save_service_category' ) ); add_action( 'admin_post_abientot_reorder_service_categories', array( $this, 'reorder_service_categories' ) ); add_action( 'admin_post_abientot_delete_service_category', array( $this, 'delete_service_category' ) ); add_action( 'admin_post_abientot_save_staff', array( $this, 'save_staff' ) ); add_action( 'admin_post_abientot_save_location', array( $this, 'save_location' ) ); add_action( 'admin_post_abientot_save_availability_exception', array( $this, 'save_availability_exception' ) ); add_action( 'admin_post_abientot_delete_availability_exception', array( $this, 'delete_availability_exception' ) ); add_action( 'admin_post_abientot_save_time_off_calendar', array( $this, 'save_time_off_calendar' ) ); add_action( 'admin_post_abientot_delete_time_off_period', array( $this, 'delete_time_off_period' ) ); add_action( 'admin_post_abientot_save_schedule_override', array( $this, 'save_schedule_override' ) ); add_action( 'admin_post_abientot_delete_schedule_override', array( $this, 'delete_schedule_override' ) ); } public function render_services(): void { if ( ! current_user_can( 'abientot_manage_services' ) ) { wp_die( esc_html__( 'You do not have permission to manage services.', 'abientot' ) ); } $edit_id = (int) Request::view( 'service_id', 'int' ); $service = $edit_id > 0 ? $this->services->find( $edit_id ) : null; $categories = $this->categories->ordered(); $services = $this->services->list_for_admin(); $staff = $this->default_staff(); $this->notice(); ?>

render_services_table( $services ); ?>
render_service_delete_impact_panel( $services ); ?>

input_row( 'service_name', __( 'Name', 'abientot' ), (string) ( $service['name'] ?? '' ), 'text', true ); $this->service_staff_row( $staff, $service ); $this->input_row( 'duration_minutes', __( 'Duration minutes', 'abientot' ), (string) ( $service['duration_minutes'] ?? 60 ), 'number', true, '1', '1440' ); $this->price_row( $this->format_price( (int) ( $service['price_cents'] ?? 0 ) ) ); $this->media_row( 'image_id', __( 'Service image', 'abientot' ), (int) ( $service['image_id'] ?? 0 ) ); ?>
category_select_row( $categories, (int) ( $service['category_id'] ?? 0 ) ); $this->textarea_row( 'description', __( 'Description', 'abientot' ), (string) ( $service['description'] ?? '' ) ); $this->input_row( 'buffer_before_minutes', __( 'Buffer before', 'abientot' ), (string) ( $service['buffer_before_minutes'] ?? 0 ), 'number', false, '0', '1440', '', __( 'Time reserved before the appointment for preparation. Customers cannot book another service during this period.', 'abientot' ) ); $this->input_row( 'buffer_after_minutes', __( 'Buffer after', 'abientot' ), (string) ( $service['buffer_after_minutes'] ?? 0 ), 'number', false, '0', '1440', '', __( 'Time reserved after the appointment for cleanup or follow-up. Customers cannot book another service during this period.', 'abientot' ) ); $this->input_row( 'color', __( 'Color', 'abientot' ), (string) ( $service['color'] ?? '#172033' ), 'color' ); $this->select_row( 'status', __( 'Status', 'abientot' ), (string) ( $service['status'] ?? 'active' ), array( 'active' => __( 'Active', 'abientot' ), 'archived' => __( 'Archived', 'abientot' ) ) ); $this->select_row( 'visibility', __( 'Visibility', 'abientot' ), (string) ( $service['visibility'] ?? 'public' ), array( 'public' => __( 'Public', 'abientot' ), 'private' => __( 'Private', 'abientot' ) ) ); $this->input_row( 'sort_order', __( 'Order', 'abientot' ), (string) ( $service['sort_order'] ?? 0 ), 'number', false, '-9999', '9999' ); ?>

media_field( 'category_image_id', 0 ); ?>
  • media_field( 'category_image_id_' . $category_id, (int) ( $category['image_id'] ?? 0 ), 'category_image_ids[' . $category_id . ']' ); ?>

default_staff(); $location = $this->default_location(); $rules = $this->constrain_staff_rule_map( $this->weekly_rule_map( (int) $staff['id'] ), $this->location_weekly_rule_map() ); $services = $this->services->list_for_admin(); $this->notice(); ?>

input_row( 'display_name', __( 'Display name', 'abientot' ), (string) $staff['display_name'], 'text', true ); $this->input_row( 'email', __( 'Email', 'abientot' ), (string) ( $staff['email'] ?? '' ), 'email' ); $this->input_row( 'phone', __( 'Phone', 'abientot' ), (string) ( $staff['phone'] ?? '' ), 'tel' ); $this->media_row( 'photo_id', __( 'Photo', 'abientot' ), (int) ( $staff['photo_id'] ?? 0 ) ); ?>
timezone_row( 'timezone', __( 'Timezone', 'abientot' ), (string) ( ( $staff['timezone'] ?? '' ) ?: Settings::values()['business_timezone'] ) ); $this->select_row( 'status', __( 'Status', 'abientot' ), (string) $staff['status'], array( 'active' => __( 'Active', 'abientot' ), 'inactive' => __( 'Inactive', 'abientot' ) ) ); $this->textarea_row( 'bio', __( 'Bio', 'abientot' ), (string) ( $staff['bio'] ?? '' ) ); ?>

render_weekly_schedule_table( 'schedule', $rules ); ?>

render_schedule_overrides_panel( (int) $staff['id'], (int) $location['id'], $rules ); ?> render_staff_time_off_panel( $staff, (int) $location['id'] ); ?> default_location(); $rules = $this->location_weekly_rule_map(); $this->notice(); ?>

input_row( 'name', __( 'Name', 'abientot' ), (string) $location['name'], 'text', true ); $this->input_row( 'address_1', __( 'Address line 1', 'abientot' ), (string) ( $location['address_1'] ?? '' ) ); $this->input_row( 'city', __( 'City', 'abientot' ), (string) ( $location['city'] ?? '' ) ); $this->input_row( 'postal_code', __( 'Postal code', 'abientot' ), (string) ( $location['postal_code'] ?? '' ) ); $this->input_row( 'country', __( 'Country', 'abientot' ), (string) ( $location['country'] ?? '' ) ); $this->timezone_row( 'timezone', __( 'Timezone', 'abientot' ), (string) ( ( $location['timezone'] ?? '' ) ?: Settings::values()['business_timezone'] ) ); ?>
input_row( 'address_2', __( 'Address line 2', 'abientot' ), (string) ( $location['address_2'] ?? '' ) ); $this->input_row( 'region', __( 'Region', 'abientot' ), (string) ( $location['region'] ?? '' ) ); $this->input_row( 'phone', __( 'Phone', 'abientot' ), (string) ( $location['phone'] ?? '' ), 'tel' ); $this->input_row( 'email', __( 'Email', 'abientot' ), (string) ( $location['email'] ?? '' ), 'email' ); $this->input_row( 'map_url', __( 'Map URL', 'abientot' ), (string) ( $location['map_url'] ?? '' ), 'url' ); $this->select_row( 'status', __( 'Status', 'abientot' ), (string) $location['status'], array( 'active' => __( 'Active', 'abientot' ), 'inactive' => __( 'Inactive', 'abientot' ) ) ); ?>

render_weekly_schedule_table( 'location_schedule', $rules ); ?>
render_location_closures_panel( $location ); ?> authorize( 'abientot_manage_services' ); $this->input = Input::post( 'abientot_save_service' ); $id = $this->input()->int( 'service_id' ); if ( ( new \Abientot\Repositories\ResourceBookingRepository() )->internal_service( $id ) ) { wp_die( esc_html__( 'Invalid request.','abientot' ),'',array('response' => 400) ); } $now = gmdate( 'Y-m-d H:i:s' ); $name = $this->text_post( 'service_name' ); $currency = (string) Settings::values()['currency']; $color = (string) sanitize_hex_color( $this->input()->text( 'color' ) ); if ( '' === $name ) { $this->redirect( 'services', 'missing-name' ); } $data = array( 'category_id' => $this->nullable_absint_post( 'category_id' ), 'name' => $name, 'slug' => $this->unique_slug( $name, $this->services, $id ), 'description' => $this->textarea_post( 'description' ), 'image_id' => $this->attachment_id_post( 'image_id' ), 'duration_minutes' => max( 1, min( 1440, $this->int_post( 'duration_minutes' ) ) ), 'buffer_before_minutes' => min( 1440, $this->int_post( 'buffer_before_minutes' ) ), 'buffer_after_minutes' => min( 1440, $this->int_post( 'buffer_after_minutes' ) ), 'price_cents' => $this->price_cents_post( 'price' ), 'currency' => $currency, 'color' => $color ?: '#172033', 'status' => $this->choice_post( 'status', array( 'active', 'archived' ), 'active' ), 'visibility' => $this->choice_post( 'visibility', array( 'public', 'private' ), 'public' ), 'sort_order' => $this->signed_int_post( 'sort_order' ), 'updated_at' => $now, ); $existing_service = $id > 0 ? $this->services->find( $id ) : null; // A licensed extension can save canonical service data and assignments atomically. $result = apply_filters( 'abientot_save_service_with_staff', null, $id, $data, $existing_service ); if ( null !== $result ) { if ( is_wp_error( $result ) ) { wp_die( esc_html( $result->get_error_message() ), '', array( 'response' => 400, 'back_link' => true ) ); } if ( ! is_int( $result ) || $result <= 0 ) { wp_die( esc_html__( 'Invalid request.', 'abientot' ), '', array( 'response' => 400 ) ); } $this->redirect( 'services', 'saved' ); return; } if ( $existing_service ) { $this->services->update( $id, $data ); } else { $data['created_at'] = $now; $id = $this->services->insert( $data ); } $staff = $this->default_staff(); if ( ! $existing_service ) { $this->services->link_to_staff( $id, (int) $staff['id'] ); } $this->redirect( 'services', 'saved' ); } public function delete_service(): void { check_admin_referer( 'abientot_delete_service' ); $this->authorize( 'abientot_manage_services' ); $query = Input::query( 'abientot_delete_service' ); $id = $query->int( 'service_id' ); if ( $id > 0 ) { if ( $this->appointments->count_by_service( $id ) > 0 ) { wp_safe_redirect( RedirectNotice::url( admin_url( 'admin.php?page=abientot-services' ), array( 'abientot_notice' => 'service-in-use', 'affected_service_id' => $id, ) ) ); exit; } $this->services->delete_staff_links( $id ); $this->services->delete( $id ); } $this->redirect( 'services', 'deleted' ); } public function replace_service_and_delete(): void { check_admin_referer( 'abientot_replace_service_and_delete' ); $this->authorize( 'abientot_manage_services' ); $this->input = Input::post( 'abientot_replace_service_and_delete' ); $from_service_id = $this->int_post( 'from_service_id' ); $to_service_id = $this->int_post( 'to_service_id' ); $retained = new \Abientot\Repositories\ResourceBookingRepository(); if ( $retained->internal_service( $from_service_id ) || $retained->internal_service( $to_service_id ) ) { wp_die( esc_html__( 'Invalid request.','abientot' ),'',array('response' => 400) ); } $from_service = $this->services->find( $from_service_id ); $to_service = $this->services->find( $to_service_id ); if ( ! $from_service || ! $to_service || $from_service_id === $to_service_id ) { $this->redirect( 'services', 'invalid-replacement-service' ); } if ( $this->appointments->count_by_service( $from_service_id ) <= 0 ) { $this->services->delete_staff_links( $from_service_id ); $this->services->delete( $from_service_id ); $this->redirect( 'services', 'deleted' ); } if ( $this->appointments->service_has_retained_relationships( $from_service_id ) && ! apply_filters( 'abientot_can_edit_booking_structure', false, null ) ) { $this->redirect( 'services', 'retained-team-bookings' ); } $this->appointments->reassign_service( $from_service_id, $to_service_id ); if ( $this->appointments->count_by_service( $from_service_id ) > 0 ) { wp_safe_redirect( RedirectNotice::url( admin_url( 'admin.php?page=abientot-services' ), array( 'abientot_notice' => 'service-in-use', 'affected_service_id' => $from_service_id, ) ) ); exit; } $this->services->delete_staff_links( $from_service_id ); $this->services->delete( $from_service_id ); $this->redirect( 'services', 'service-reassigned-deleted' ); } public function save_service_category(): void { check_admin_referer( 'abientot_save_service_category' ); $this->authorize( 'abientot_manage_services' ); $this->input = Input::post( 'abientot_save_service_category' ); $id = $this->input()->int( 'category_id' ); $name = $this->text_post( 'category_name' ); if ( '' === $name ) { $this->redirect( 'services', 'missing-category' ); } $now = gmdate( 'Y-m-d H:i:s' ); $existing = $id > 0 ? $this->categories->find( $id ) : null; $data = array( 'name' => $name, 'slug' => $this->unique_slug( $name, $this->categories, $id ), 'description' => '', 'image_id' => $this->attachment_id_post( 'category_image_id' ), 'sort_order' => $existing ? (int) $existing['sort_order'] : $this->categories->next_sort_order(), 'is_active' => 1, 'updated_at' => $now, ); if ( $existing ) { $this->categories->update( $id, $data ); } else { $data['created_at'] = $now; $this->categories->insert( $data ); } $this->redirect( 'services', 'category-saved' ); } public function reorder_service_categories(): void { check_admin_referer( 'abientot_reorder_service_categories' ); $this->authorize( 'abientot_manage_services' ); $this->input = Input::post( 'abientot_reorder_service_categories' ); $categories = $this->categories->ordered(); $valid_ids = array_map( 'absint', wp_list_pluck( $categories, 'id' ) ); $names = array_map( array( $this, 'list_text' ), $this->input()->text_list( 'category_names' ) ); $images = array_map( 'absint', $this->input()->text_list( 'category_image_ids' ) ); foreach ( $valid_ids as $id ) { $name = isset( $names[ $id ] ) && is_scalar( $names[ $id ] ) ? sanitize_text_field( (string) $names[ $id ] ) : ''; if ( '' === $name ) { $this->redirect( 'services', 'missing-category' ); } } foreach ( $categories as $category ) { $id = absint( $category['id'] ); $name = sanitize_text_field( (string) $names[ $id ] ); $image_id = isset( $images[ $id ] ) ? absint( $images[ $id ] ) : 0; $image_id = $image_id > 0 && 'attachment' === get_post_type( $image_id ) ? $image_id : null; $this->categories->update( $id, array( 'name' => $name, 'slug' => $this->unique_slug( $name, $this->categories, $id ), 'image_id' => $image_id, 'updated_at' => gmdate( 'Y-m-d H:i:s' ), ) ); } $order = $this->input()->text( 'category_order' ); $ids = array_values( array_filter( array_map( 'absint', explode( ',', $order ) ), static function ( int $id ) use ( $valid_ids ): bool { return in_array( $id, $valid_ids, true ); } ) ); $ids = array_values( array_unique( array_merge( $ids, $valid_ids ) ) ); if ( $ids ) { $this->categories->reorder( $ids ); } $this->redirect( 'services', 'saved' ); } public function delete_service_category(): void { check_admin_referer( 'abientot_delete_service_category' ); $this->authorize( 'abientot_manage_services' ); $query = Input::query( 'abientot_delete_service_category' ); $id = $query->int( 'category_id' ); if ( $id > 0 ) { $this->categories->delete( $id ); } $this->redirect( 'services', 'category-deleted' ); } public function save_staff(): void { check_admin_referer( 'abientot_save_staff' ); $this->authorize( 'abientot_manage_staff' ); $this->input = Input::post( 'abientot_save_staff' ); $staff = $this->default_staff(); $now = gmdate( 'Y-m-d H:i:s' ); $schedule_rules = $this->schedule_rules_from_post(); $this->staff->update( (int) $staff['id'], array( 'display_name' => $this->text_post( 'display_name' ) ?: __( 'Default Staff', 'abientot' ), 'email' => $this->input()->email( 'email' ), 'phone' => $this->phone_post( (string) ( $staff['phone'] ?? '' ) ), 'bio' => $this->textarea_post( 'bio' ), 'photo_id' => $this->attachment_id_post( 'photo_id' ), 'timezone' => $this->timezone_post( 'timezone' ), 'is_default' => 1, 'status' => $this->choice_post( 'status', array( 'active', 'inactive' ), 'active' ), 'updated_at' => $now, ) ); $this->availability->replace_weekly_rules( (int) $staff['id'], $schedule_rules ); // Retained team assignments are not rewritten by a Free profile save. if ( count( $this->staff->all( 2 ) ) <= 1 ) { $this->link_all_services_to_default_staff(); } $this->redirect( 'staff', 'saved' ); } public function save_location(): void { check_admin_referer( 'abientot_save_location' ); $this->authorize( 'abientot_manage_locations' ); $this->input = Input::post( 'abientot_save_location' ); $location = $this->default_location(); $schedule = $this->location_schedule_from_post(); $this->locations->update( (int) $location['id'], array( 'name' => $this->text_post( 'name' ) ?: __( 'Default Location', 'abientot' ), 'address_1' => $this->text_post( 'address_1' ), 'address_2' => $this->text_post( 'address_2' ), 'city' => $this->text_post( 'city' ), 'region' => $this->text_post( 'region' ), 'postal_code' => $this->text_post( 'postal_code' ), 'country' => strtoupper( substr( $this->text_post( 'country' ), 0, 2 ) ), 'phone' => $this->phone_post( (string) ( $location['phone'] ?? '' ) ), 'email' => $this->input()->email( 'email' ), 'map_url' => $this->input()->url( 'map_url' ), 'timezone' => $this->timezone_post( 'timezone' ), 'is_default' => 1, 'status' => $this->choice_post( 'status', array( 'active', 'inactive' ), 'active' ), 'updated_at' => gmdate( 'Y-m-d H:i:s' ), ) ); update_option( 'abientot_location_weekly_schedule', $schedule, false ); $this->redirect( 'locations', 'saved' ); } public function save_availability_exception(): void { check_admin_referer( 'abientot_save_availability_exception' ); $this->authorize( 'abientot_manage_staff' ); $this->input = Input::post( 'abientot_save_availability_exception' ); $staff = $this->default_staff(); $date = $this->input()->text( 'exception_date' ); $end_date = $this->input()->text( 'exception_end_date' ); if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) { $this->redirect( 'staff', 'invalid-date' ); } if ( '' !== $end_date && ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $end_date ) ) { $end_date = ''; } $type = $this->choice_post( 'exception_type', array( 'closed', 'open', 'break' ), 'closed' ); $now = gmdate( 'Y-m-d H:i:s' ); $dates = $this->exception_dates( $date, 'closed' === $type ? $end_date : '' ); foreach ( $dates as $exception_date ) { $this->exceptions->insert( array( 'staff_id' => (int) $staff['id'], 'location_id' => $this->nullable_absint_post( 'location_id' ), 'service_id' => null, 'exception_date' => $exception_date, 'start_time' => 'closed' === $type ? null : $this->time_post( 'start_time' ), 'end_time' => 'closed' === $type ? null : $this->time_post( 'end_time' ), 'exception_type' => $type, 'reason' => $this->text_post( 'reason' ), 'created_at' => $now, 'updated_at' => $now, ) ); } $this->redirect( 'staff', 'exception-saved' ); } public function delete_availability_exception(): void { check_admin_referer( 'abientot_delete_availability_exception' ); $this->authorize( 'abientot_manage_staff' ); $query = Input::query( 'abientot_delete_availability_exception' ); $id = $query->int( 'exception_id' ); if ( $id > 0 ) { $this->exceptions->delete( $id ); } $this->redirect( 'staff', 'exception-deleted' ); } public function save_time_off_calendar(): void { check_admin_referer( 'abientot_save_time_off_calendar' ); $this->input = Input::post( 'abientot_save_time_off_calendar' ); $scope = $this->choice_post( 'time_off_scope', array( 'staff', 'location' ), 'staff' ); $this->authorize( 'location' === $scope ? 'abientot_manage_locations' : 'abientot_manage_staff' ); $staff = $this->default_staff(); $location = $this->default_location(); $start_date = $this->date_post( 'calendar_start_date' ); $end_date = $this->date_post( 'calendar_end_date' ); $dates = $this->dates_post( 'closed_dates', $start_date, $end_date ); $reason = 'location' === $scope ? __( 'Location closure', 'abientot' ) : __( 'Staff time off', 'abientot' ); $page = 'location' === $scope ? 'locations' : 'staff'; $anchor = $this->date_post( 'calendar_anchor_date' ); if ( '' === $start_date || '' === $end_date || $end_date < $start_date ) { $this->redirect( $page, 'invalid-date' ); } $current = array(); foreach ( $this->exceptions->closed_days_for_staff_between( (int) $staff['id'], $start_date, $end_date, 'location' === $scope ? (int) $location['id'] : null ) as $exception ) { $current[ (string) $exception['exception_date'] ] = true; } $adding = array_values( array_filter( $dates, static function ( string $date ) use ( $current ): bool { return empty( $current[ $date ] ); } ) ); $conflicts = $this->closure_conflicts( $scope, (int) $staff['id'], (int) $location['id'], $adding ); if ( $conflicts ) { $this->redirect_with_conflicts( $page, 'closure-conflict', $conflicts, array( ( 'location' === $scope ? 'location_time_off_month' : 'staff_time_off_month' ) => '' !== $anchor ? $anchor : $start_date ) ); } $this->exceptions->replace_closed_days_for_staff_between( (int) $staff['id'], 'location' === $scope ? (int) $location['id'] : null, $start_date, $end_date, $dates, $reason ); $this->redirect_to_calendar_month( $page, $scope, '' !== $anchor ? $anchor : $start_date, 'time-off-saved' ); } public function save_schedule_override(): void { check_admin_referer( 'abientot_save_schedule_override' ); $this->authorize( 'abientot_manage_staff' ); $this->input = Input::post( 'abientot_save_schedule_override' ); $staff = $this->default_staff(); $location = $this->default_location(); $start_date = $this->date_post( 'override_start_date' ); $end_date = $this->date_post( 'override_end_date' ); if ( '' === $start_date ) { $this->redirect( 'staff', 'invalid-date' ); } if ( '' === $end_date || $end_date < $start_date ) { $end_date = $start_date; } $rules = $this->schedule_rules_from_post_for_range( $start_date, $end_date, (int) $location['id'] ); if ( ! $rules ) { $this->redirect( 'staff', 'empty-override' ); } $this->availability->delete_overrides_for_staff_range( (int) $staff['id'], $start_date, $end_date ); foreach ( $rules as $rule ) { $this->availability->add_override_rule( (int) $staff['id'], $rule ); } $this->redirect_to_schedule_week( $start_date, 'schedule-override-saved' ); } public function delete_time_off_period(): void { check_admin_referer( 'abientot_delete_time_off_period' ); $query = Input::query( 'abientot_delete_time_off_period' ); $scope = $query->key( 'time_off_scope', 'staff' ); $scope = in_array( $scope, array( 'staff', 'location' ), true ) ? $scope : 'staff'; $this->authorize( 'location' === $scope ? 'abientot_manage_locations' : 'abientot_manage_staff' ); $staff = $this->default_staff(); $location = $this->default_location(); $start_date = $query->text( 'period_start' ); $end_date = $query->text( 'period_end' ); $anchor = $query->text( 'calendar_month' ); $page = 'location' === $scope ? 'locations' : 'staff'; if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $start_date ) || ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $end_date ) || $end_date < $start_date ) { $this->redirect( $page, 'invalid-date' ); } $this->exceptions->delete_closed_days_for_staff_between( (int) $staff['id'], 'location' === $scope ? (int) $location['id'] : null, $start_date, $end_date ); $this->redirect_to_calendar_month( $page, $scope, preg_match( '/^\d{4}-\d{2}-\d{2}$/', $anchor ) ? $anchor : $start_date, 'time-off-period-deleted' ); } public function delete_schedule_override(): void { check_admin_referer( 'abientot_delete_schedule_override' ); $this->authorize( 'abientot_manage_staff' ); $query = Input::query( 'abientot_delete_schedule_override' ); $id = $query->int( 'rule_id' ); if ( $id > 0 ) { $this->availability->delete( $id ); } $this->redirect( 'staff', 'schedule-override-deleted' ); } /** * @return array{anchor:string,start:string,end:string,label:string,previous:string,next:string} */ private function calendar_month_from_request( string $key ): array { $date = Request::view( $key ) ?? wp_date( 'Y-m-d' ); if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) { $date = wp_date( 'Y-m-d' ); } try { $anchor = new DateTimeImmutable( $date . ' 00:00:00', DateTimeHelper::business_timezone() ); } catch ( Exception $exception ) { $anchor = new DateTimeImmutable( 'today', DateTimeHelper::business_timezone() ); } $month_start = $anchor->modify( 'first day of this month' ); $grid_start = \Abientot\Support\Locale::week_start_date( $month_start ); $grid_end = $grid_start->modify( '+41 days' ); return array( 'anchor' => $month_start->format( 'Y-m-d' ), 'start' => $grid_start->format( 'Y-m-d' ), 'end' => $grid_end->format( 'Y-m-d' ), 'label' => \Abientot\Support\Locale::date( DateTimeHelper::month_year_format(), $month_start->getTimestamp(), DateTimeHelper::business_timezone() ), 'previous' => $month_start->modify( '-1 month' )->format( 'Y-m-d' ), 'next' => $month_start->modify( '+1 month' )->format( 'Y-m-d' ), ); } /** * @param array{anchor:string,start:string,end:string,label:string,previous:string,next:string} $month Month state. */ private function render_time_off_month_nav( string $scope, array $month ): void { $key = 'location' === $scope ? 'location_time_off_month' : 'staff_time_off_month'; $page = 'location' === $scope ? 'abientot-locations' : 'abientot-staff'; ?>
> $exceptions Exceptions. */ private function render_time_off_periods( array $exceptions, string $scope, string $anchor_date ): void { $periods = $this->group_closed_periods( array_keys( $this->closed_date_map( $exceptions ) ) ); ?>

'abientot_delete_time_off_period', 'time_off_scope' => $scope, 'period_start' => $start_date, 'period_end' => $end_date, 'calendar_month' => $anchor_date, ), admin_url( 'admin-post.php' ) ), 'abientot_delete_time_off_period' ); } /** * @param string[] $dates Dates. * @return array */ private function group_closed_periods( array $dates ): array { sort( $dates ); $periods = array(); $start = ''; $end = ''; foreach ( $dates as $date ) { if ( '' === $start ) { $start = $date; $end = $date; continue; } if ( $date === gmdate( 'Y-m-d', strtotime( $end . ' +1 day' ) ) ) { $end = $date; continue; } $periods[] = $this->closed_period_payload( $start, $end ); $start = $date; $end = $date; } if ( '' !== $start ) { $periods[] = $this->closed_period_payload( $start, $end ); } return $periods; } /** * @return array{start:string,end:string,days:int,label:string} */ private function closed_period_payload( string $start, string $end ): array { $days = max( 1, (int) floor( ( strtotime( $end ) - strtotime( $start ) ) / DAY_IN_SECONDS ) + 1 ); $label = $start === $end ? $this->display_date( $start ) : sprintf( '%s - %s', $this->display_date( $start ), $this->display_date( $end ) ); return array( 'start' => $start, 'end' => $end, 'days' => $days, 'label' => $label, ); } /** * @return array{start:string,end:string,previous:string,next:string} */ private function schedule_week_from_request(): array { $date = Request::view( 'schedule_week' ) ?? wp_date( 'Y-m-d' ); if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) { $date = wp_date( 'Y-m-d' ); } try { $anchor = new DateTimeImmutable( $date . ' 00:00:00', DateTimeHelper::business_timezone() ); } catch ( Exception $exception ) { $anchor = new DateTimeImmutable( 'today', DateTimeHelper::business_timezone() ); } $start = \Abientot\Support\Locale::week_start_date( $anchor ); $end = $start->modify( '+6 days' ); return array( 'start' => $start->format( 'Y-m-d' ), 'end' => $end->format( 'Y-m-d' ), 'previous' => $start->modify( '-7 days' )->format( 'Y-m-d' ), 'next' => $start->modify( '+7 days' )->format( 'Y-m-d' ), ); } /** * @param array{start:string,end:string,previous:string,next:string} $week Week state. */ private function render_schedule_week_picker( array $week ): void { ?>
display_date( $week['start'] ), $this->display_date( $week['end'] ) ) ); ?>
*/ private function nearby_weeks( string $selected_start ): array { $weeks = array(); try { $base = new DateTimeImmutable( $selected_start . ' 00:00:00', DateTimeHelper::business_timezone() ); } catch ( Exception $exception ) { $base = \Abientot\Support\Locale::week_start_date( new DateTimeImmutable( 'today', DateTimeHelper::business_timezone() ) ); } for ( $offset = -2; $offset <= 7; $offset++ ) { $start = $base->modify( (string) $offset . ' weeks' ); $weeks[] = array( 'start' => $start->format( 'Y-m-d' ), 'end' => $start->modify( '+6 days' )->format( 'Y-m-d' ), ); } return $weeks; } /** * @param array{start:string,end:string,previous:string,next:string} $week Week state. * @return array */ private function week_dates( array $week ): array { $days = array(); try { $start = new DateTimeImmutable( $week['start'] . ' 00:00:00', DateTimeHelper::business_timezone() ); } catch ( Exception $exception ) { $start = \Abientot\Support\Locale::week_start_date( new DateTimeImmutable( 'today', DateTimeHelper::business_timezone() ) ); } for ( $offset = 0; $offset < 7; $offset++ ) { $day = $start->modify( '+' . $offset . ' days' ); $days[] = array( 'date' => $day->format( 'Y-m-d' ), 'weekday' => (int) $day->format( 'w' ), 'label' => \Abientot\Support\Locale::date( 'l ' . DateTimeHelper::date_display_format(), $day->getTimestamp(), DateTimeHelper::business_timezone() ), ); } return $days; } private function display_date( string $date ): string { try { $day = new DateTimeImmutable( $date . ' 00:00:00', DateTimeHelper::business_timezone() ); return \Abientot\Support\Locale::date( DateTimeHelper::date_display_format(), $day->getTimestamp(), DateTimeHelper::business_timezone() ); } catch ( Exception $exception ) { return $date; } } /** * @param array $staff Staff row. */ private function render_staff_time_off_panel( array $staff, int $location_id ): void { $month = $this->calendar_month_from_request( 'staff_time_off_month' ); $start = $month['start']; $end = $month['end']; $exceptions = $this->exceptions->closed_days_for_staff_between( (int) $staff['id'], $start, $end, null ); $all_closed = $this->exceptions->closed_days_for_staff( (int) $staff['id'], null ); $conflicts = $this->conflicts_from_request( 'staff' ); $bookings = $this->booking_conflicts_for_window( 'staff', (int) $staff['id'], $location_id, $start, $end ); ?>

render_closure_conflicts_panel( $conflicts, 'staff' ); ?> render_time_off_month_nav( 'staff', $month ); ?>
render_closed_days_calendar( $exceptions, 'staff', $bookings, $start, $end, $month['anchor'] ); ?>
render_time_off_periods( $all_closed, 'staff', $month['anchor'] ); ?>
$location Location row. */ private function render_location_closures_panel( array $location ): void { $staff = $this->default_staff(); $month = $this->calendar_month_from_request( 'location_time_off_month' ); $start = $month['start']; $end = $month['end']; $exceptions = $this->exceptions->closed_days_for_staff_between( (int) $staff['id'], $start, $end, (int) $location['id'] ); $all_closed = $this->exceptions->closed_days_for_staff( (int) $staff['id'], (int) $location['id'] ); $bookings = $this->booking_conflicts_for_window( 'location', (int) $staff['id'], (int) $location['id'], $start, $end ); ?>

render_time_off_month_nav( 'location', $month ); ?>
render_closed_days_calendar( $exceptions, 'location', $bookings, $start, $end, $month['anchor'] ); ?>
render_time_off_periods( $all_closed, 'location', $month['anchor'] ); ?>
availability->overrides_for_staff( $staff_id, 80 ); $week = $this->schedule_week_from_request(); $week_days = $this->week_dates( $week ); $visible = $this->show_specific_schedule(); ?>
>

render_schedule_week_picker( $week ); ?>

display_date( $week['start'] ), $this->display_date( $week['end'] ) ) ); ?>

weekdays() ); ?>
render_schedule_overrides_table( $overrides ); ?>
exceptions->for_staff( $staff_id ); ?>

render_closed_days_calendar( $exceptions ); ?>
exception_time_label( $exception ) ); ?>

> $exceptions Exceptions. */ private function render_closed_days_calendar( array $exceptions, string $scope = 'staff', array $booking_counts = array(), string $start_date = '', string $end_date = '', string $anchor_date = '' ): void { $closed = $this->closed_date_map( $exceptions ); $label = 'location' === $scope ? __( 'Location closure days', 'abientot' ) : __( 'Staff time off days', 'abientot' ); if ( '' === $start_date || '' === $end_date ) { $start_date = wp_date( 'Y-m-d' ); $end_date = wp_date( 'Y-m-d', strtotime( $start_date . ' +41 days' ) ); } $anchor_month = preg_match( '/^\d{4}-\d{2}-\d{2}$/', $anchor_date ) ? substr( $anchor_date, 0, 7 ) : substr( $start_date, 0, 7 ); echo '
'; foreach ( array_merge( array_slice( \Abientot\Support\Locale::date_names()['shortWeekdays'],\Abientot\Support\Locale::week_start() ),array_slice( \Abientot\Support\Locale::date_names()['shortWeekdays'],0,\Abientot\Support\Locale::week_start() ) ) as $weekday_label ) { echo '' . esc_html( $weekday_label ) . ''; } foreach ( $this->exception_dates( $start_date, $end_date ) as $date ) { $booking_data = $booking_counts[ $date ] ?? array(); $count = is_array( $booking_data ) ? (int) ( $booking_data['count'] ?? 0 ) : (int) $booking_data; $booking_ids = is_array( $booking_data ) ? array_map( 'absint', (array) ( $booking_data['ids'] ?? array() ) ) : array(); $classes = array(); if ( ! empty( $closed[ $date ] ) ) { $classes[] = 'is-closed'; $classes[] = 'is-saved'; } if ( substr( $date, 0, 7 ) !== $anchor_month ) { $classes[] = 'is-outside-month'; } printf( '', esc_attr( implode( ' ', $classes ) ), esc_html( \Abientot\Support\Locale::date( 'j', strtotime( $date ), DateTimeHelper::business_timezone() ) ), esc_html( \Abientot\Support\Locale::date( 'M', strtotime( $date ), DateTimeHelper::business_timezone() ) ), esc_attr( $date ), esc_attr( ! empty( $closed[ $date ] ) ? 'true' : 'false' ), $count > 0 ? '' . esc_html( sprintf( /* translators: %d: number of bookings. */ _n( '%d booking', '%d bookings', $count, 'abientot' ), $count ) ) . '' : '', esc_attr( implode( ',', array_filter( $booking_ids ) ) ), esc_attr( ! empty( $closed[ $date ] ) ? '1' : '0' ) ); } echo '
'; } /** * @param array> $exceptions Exceptions. * @return array */ private function closed_date_map( array $exceptions ): array { $closed = array(); foreach ( $exceptions as $exception ) { if ( 'closed' === (string) $exception['exception_type'] && empty( $exception['start_time'] ) ) { $closed[ (string) $exception['exception_date'] ] = true; } } ksort( $closed ); return $closed; } /** * @param array> $rows Booking rows. */ private function render_closure_conflicts_panel( array $rows, string $scope ): void { if ( ! $rows ) { return; } ?>

local_booking_time_label( $row ) ); ?>
> $overrides Override rows. */ private function render_schedule_overrides_table( array $overrides ): void { if ( ! $overrides ) { echo '

' . esc_html__( 'No specific week schedules yet.', 'abientot' ) . '

'; return; } ?>

weekdays()[ (int) $row['weekday'] ] ?? '' ); ?> time_value( (string) $row['start_time'] ) . ' - ' . $this->time_value( (string) $row['end_time'] ) ); ?>
staff->default_staff(); if ( is_array( $staff ) ) { return $staff; } $now = gmdate( 'Y-m-d H:i:s' ); $id = $this->staff->insert( array( 'display_name' => __( 'Default Staff', 'abientot' ), 'timezone' => (string) Settings::values()['business_timezone'], 'is_default' => 1, 'status' => 'active', 'created_at' => $now, 'updated_at' => $now, ) ); return $this->staff->find( $id ) ?: array( 'id' => $id, 'display_name' => __( 'Default Staff', 'abientot' ), 'status' => 'active' ); } private function default_location(): array { $location = $this->locations->default_location(); if ( is_array( $location ) ) { return $location; } $now = gmdate( 'Y-m-d H:i:s' ); $id = $this->locations->insert( array( 'name' => __( 'Default Location', 'abientot' ), 'timezone' => (string) Settings::values()['business_timezone'], 'is_default' => 1, 'status' => 'active', 'created_at' => $now, 'updated_at' => $now, ) ); return $this->locations->find( $id ) ?: array( 'id' => $id, 'name' => __( 'Default Location', 'abientot' ), 'status' => 'active' ); } private function link_all_services_to_default_staff(): void { $staff = $this->default_staff(); foreach ( $this->services->list_for_admin() as $service ) { $this->services->link_to_staff( (int) $service['id'], (int) $staff['id'] ); } } private function weekly_rule_map( int $staff_id ): array { return ScheduleEditor::from_rules( $this->availability->weekly_rules_for_staff( $staff_id ) ); } /** * Returns the default location's recurring business hours. Existing sites inherit * the staff schedule until location hours are saved explicitly. * * @return array>> */ private function location_weekly_rule_map(): array { $saved = get_option( 'abientot_location_weekly_schedule', null ); if ( is_array( $saved ) ) { return $saved; } $staff = $this->default_staff(); return $this->weekly_rule_map( (int) $staff['id'] ); } /** * @param array>> $staff_rules Staff schedule. * @param array>> $location_rules Location schedule. * @return array>> */ private function constrain_staff_rule_map( array $staff_rules, array $location_rules ): array { return $staff_rules; } /** * @return array>> */ private function location_schedule_from_post(): array { return $this->posted_period_map( 'location_schedule' ); } private function posted_period_map( string $name ): array { try { if ( ! $this->input()->has_list( $name ) ) { throw new \InvalidArgumentException(); } return ScheduleEditor::posted( $this->input()->text_list( $name ) ); } catch ( \InvalidArgumentException $error ) { wp_die( esc_html__( 'Check working periods: use valid times, different start and end times, and no overlapping periods.', 'abientot' ), '', array( 'response' => 400 ) ); } } /** * @return array> */ private function schedule_rules_from_post(): array { return $this->period_rules_from_post( 'schedule', (int) $this->default_location()['id'] ); } private function period_rules_from_post( string $name, int $location_id, ?string $from = null, ?string $to = null ): array { $map = $this->posted_period_map( $name ); $rules = array(); foreach ( $map as $weekday => $entry ) { if ( null !== $from && ! $entry['periods'] ) { $rules[] = array( 'location_id' => $location_id, 'weekday' => $weekday, 'start_time' => '00:00:00', 'end_time' => '00:00:00', 'rule_type' => 'override_closed', 'effective_from' => $from, 'effective_to' => $to ); } foreach ( $entry['periods'] as $period ) { $offset = (int) ( $period['day_offset'] ?? 0 ); $rules[] = array( 'location_id' => $location_id, 'weekday' => ( $weekday + $offset ) % 7, 'start_time' => $period['start_time'], 'end_time' => $period['end_time'], 'rule_type' => 'working', 'effective_from' => $from && $offset ? ( new \DateTimeImmutable( $from ) )->modify( '+1 day' )->format( 'Y-m-d' ) : $from, 'effective_to' => $to && $offset ? ( new \DateTimeImmutable( $to ) )->modify( '+1 day' )->format( 'Y-m-d' ) : $to ); } } return $rules; } /** * @param array>> $rules Schedule map. */ private function render_weekly_schedule_table( string $field_name, array $rules, bool $include_breaks = true ): void { ScheduleEditor::render( $field_name, $rules, $this->weekdays() ); } /** * @return array> */ private function schedule_rules_from_post_for_range( string $start_date, string $end_date, int $location_id ): array { return $this->period_rules_from_post( 'override_schedule', $location_id, $start_date, $end_date ); } /** * @param string[] $dates Local dates. * @return array> */ private function closure_conflicts( string $scope, int $staff_id, int $location_id, array $dates ): array { $conflicts = array(); foreach ( $dates as $date ) { $window = $this->local_date_utc_window( $date ); if ( ! $window ) { continue; } $rows = 'location' === $scope ? $this->appointments->blocking_for_location_between_admin( $location_id, $window['start_utc'], $window['end_utc'] ) : $this->appointments->blocking_for_staff_between_admin( $staff_id, $window['start_utc'], $window['end_utc'] ); foreach ( $rows as $row ) { $conflicts[ (int) $row['id'] ] = $row; } } return array_values( $conflicts ); } /** * @return array */ private function booking_conflicts_for_window( string $scope, int $staff_id, int $location_id, string $start_date, string $end_date ): array { $dates = $this->exception_dates( $start_date, $end_date ); $rows = $this->closure_conflicts( $scope, $staff_id, $location_id, $dates ); $bookings = array(); foreach ( $rows as $row ) { try { $timezone = new DateTimeZone( (string) $row['timezone'] ); } catch ( Exception $exception ) { $timezone = DateTimeHelper::business_timezone(); } $start = new DateTimeImmutable( (string) $row['start_utc'], DateTimeHelper::utc_timezone() ); $date = $start->setTimezone( $timezone )->format( 'Y-m-d' ); if ( empty( $bookings[ $date ] ) ) { $bookings[ $date ] = array( 'count' => 0, 'ids' => array(), ); } $bookings[ $date ]['count']++; $bookings[ $date ]['ids'][] = (int) $row['id']; } return $bookings; } /** * @return array{start_utc:string,end_utc:string}|null */ private function local_date_utc_window( string $date ): ?array { if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) { return null; } try { $start = new DateTimeImmutable( $date . ' 00:00:00', DateTimeHelper::business_timezone() ); $end = $start->modify( '+1 day' ); } catch ( Exception $exception ) { return null; } return array( 'start_utc' => $start->setTimezone( DateTimeHelper::utc_timezone() )->format( 'Y-m-d H:i:s' ), 'end_utc' => $end->setTimezone( DateTimeHelper::utc_timezone() )->format( 'Y-m-d H:i:s' ), ); } /** * @return array> */ private function conflicts_from_request( string $scope ): array { $rows = array(); foreach ( RedirectNotice::ids( 'conflict_ids' ) as $id ) { $row = $this->appointments->find_for_admin( $id ); if ( $row ) { $rows[] = $row; } } return $rows; } /** * @param array> $conflicts Conflict booking rows. */ private function redirect_with_conflicts( string $page, string $notice, array $conflicts, array $extra_args = array() ): void { $ids = array(); foreach ( $conflicts as $row ) { $ids[] = (int) $row['id']; } wp_safe_redirect( RedirectNotice::url( admin_url( 'admin.php?page=abientot-' . $page ), array_merge( $extra_args, array( 'abientot_notice' => $notice, 'conflict_ids' => implode( ',', array_slice( array_unique( $ids ), 0, 20 ) ), ) ) ) ); exit; } /** * @param array $row Booking row. */ private function local_booking_time_label( array $row ): string { try { $timezone = new DateTimeZone( (string) $row['timezone'] ); } catch ( Exception $exception ) { $timezone = DateTimeHelper::business_timezone(); } $start = new DateTimeImmutable( (string) $row['start_utc'], DateTimeHelper::utc_timezone() ); $end = new DateTimeImmutable( (string) $row['end_utc'], DateTimeHelper::utc_timezone() ); return DateTimeHelper::format_utc_datetime( (string) $row['start_utc'], $timezone->getName() ) . ' - ' . DateTimeHelper::format_utc_datetime( (string) $row['end_utc'], $timezone->getName(), DateTimeHelper::time_display_format() ); } private function render_services_table( array $services ): void { $status_labels = array( 'active' => __( 'Active', 'abientot' ), 'archived' => __( 'Archived', 'abientot' ), 'inactive' => __( 'Inactive', 'abientot' ) ); $visibility_labels = array( 'public' => __( 'Public', 'abientot' ), 'private' => __( 'Private', 'abientot' ) ); if ( ! $services ) { echo '

' . esc_html__( 'Create your first service to make it available for future booking forms.', 'abientot' ) . '

'; return; } ?>
format_money( (int) $row['price_cents'], (string) $row['currency'] ) ); ?>
services->find( $service_id ); if ( ! $service ) { return; } $count = $this->appointments->count_by_service( $service_id ); $appointments = $this->appointments->service_delete_impact( $service_id ); $replacements = array_values( array_filter( $services, static function ( array $row ) use ( $service_id ): bool { return (int) $row['id'] !== $service_id; } ) ); if ( $count <= 0 ) { return; } ?>

getName() ) ) ); ?>
count( $appointments ) ) : ?>

text_post( 'phone' ); if ( '' === $phone || $phone === $previous ) { return $phone; } try { $country = $this->input()->text( 'phone_country' ); return \Abientot\Support\Phone::normalize( $phone, $country ); } catch ( \DomainException $error ) { wp_die( esc_html__( 'Enter a valid phone number. Select its country or include the +country code.', 'abientot' ), '', array( 'response' => 400 ) ); } } private function input_row( string $name, string $label, string $value, string $type = 'text', bool $required = false, string $min = '', string $max = '', string $step = '', string $description = '' ): void { if ( 'tel' === $type ) { echo ''; \Abientot\Support\PhoneField::render( $name, $value, 'phone_country', array( 'id' => $name, 'required' => $required ), $label ); echo ''; return; } ?> >

$staff Default staff profile. */ private function service_staff_row( array $staff, ?array $service = null ): void { if ( apply_filters( 'abientot_render_service_staff', false, $staff, $service ) ) { return; } ?>

media_field( $name, $attachment_id ); ?> 0 ? wp_get_attachment_image( $attachment_id, 'thumbnail', false, array( 'class' => 'abientot-media-preview-image' ) ) : ''; $input_name = '' === $input_name ? $name : $input_name; ?>
__( 'Changes saved.', 'abientot' ), 'retained-team-bookings' => __( 'This service has retained team bookings. Use a compatible Pro edition to replace it.', 'abientot' ), 'deleted' => __( 'Item deleted.', 'abientot' ), 'category-saved' => __( 'Category saved.', 'abientot' ), 'category-order-saved' => __( 'Category order saved.', 'abientot' ), 'category-deleted' => __( 'Category deleted.', 'abientot' ), 'exception-saved' => __( 'Exception saved.', 'abientot' ), 'exception-deleted' => __( 'Exception deleted.', 'abientot' ), 'time-off-saved' => __( 'Calendar saved.', 'abientot' ), 'time-off-period-deleted' => __( 'Period deleted.', 'abientot' ), 'closure-conflict' => __( 'Closure was not saved because there are bookings on one or more selected days.', 'abientot' ), 'schedule-override-saved' => __( 'Schedule override saved.', 'abientot' ), 'schedule-override-deleted' => __( 'Schedule override deleted.', 'abientot' ), 'empty-override' => __( 'Choose at least one working day for the override.', 'abientot' ), 'service-in-use' => __( 'This service has bookings. Reassign the affected bookings before deleting it.', 'abientot' ), 'service-reassigned-deleted' => __( 'Bookings reassigned and service deleted.', 'abientot' ), 'invalid-replacement-service' => __( 'Choose a valid replacement service.', 'abientot' ), 'missing-name' => __( 'Name is required.', 'abientot' ), 'missing-category' => __( 'Category name is required.', 'abientot' ), 'invalid-date' => __( 'Enter a valid exception date.', 'abientot' ), ); if ( isset( $map[ $notice ] ) ) { $class = in_array( $notice, array( 'closure-conflict', 'invalid-date', 'empty-override', 'missing-name', 'missing-category', 'service-in-use', 'invalid-replacement-service' ), true ) ? 'notice-error' : 'notice-success'; echo '

' . esc_html( $map[ $notice ] ) . '

'; } } /** * Capability check and configuration lock, for handlers that verified their nonce first. */ private function authorize( string $capability ): void { if ( ! current_user_can( $capability ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'abientot' ), '', array( 'response' => 403 ) ); } BookingLock::require_configuration_lock(); } /** * Fields of the current form; only handlers set them, after verifying the nonce. */ private function input(): Input { if ( null === $this->input ) { wp_die( esc_html__( 'Invalid request.', 'abientot' ), '', array( 'response' => 400 ) ); } return $this->input; } /** Array values that are not strings become empty strings. */ private function list_text( $value ): string { return is_string( $value ) ? $value : ''; } private function redirect( string $page, string $notice ): void { wp_safe_redirect( RedirectNotice::url( admin_url( 'admin.php?page=abientot-' . $page ), array( 'abientot_notice' => $notice ) ) ); exit; } private function redirect_to_calendar_month( string $page, string $scope, string $month, string $notice ): void { $key = 'location' === $scope ? 'location_time_off_month' : 'staff_time_off_month'; wp_safe_redirect( RedirectNotice::url( admin_url( 'admin.php?page=abientot-' . $page ), array( 'abientot_notice' => $notice, $key => $month, ) ) ); exit; } private function redirect_to_schedule_week( string $week, string $notice ): void { wp_safe_redirect( RedirectNotice::url( admin_url( 'admin.php?page=abientot-staff' ), array( 'abientot_notice' => $notice, 'schedule_week' => $week, 'show_specific_schedule' => '1', ) ) ); exit; } private function text_post( string $key ): string { return $this->input()->text( $key ); } private function textarea_post( string $key ): string { return $this->input()->textarea( $key ); } private function date_post( string $key ): string { $date = $this->input()->text( $key ); return preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ? $date : ''; } /** * @return string[] */ private function dates_post( string $key, string $start_date, string $end_date ): array { $value = $this->input()->text( $key ); $dates = array(); foreach ( explode( ',', $value ) as $date ) { $date = trim( $date ); if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) && $date >= $start_date && $date <= $end_date ) { $dates[ $date ] = true; } } return array_keys( $dates ); } private function int_post( string $key ): int { return $this->input()->int( $key ); } private function signed_int_post( string $key ): int { return (int) $this->input()->text( $key ); } private function nullable_absint_post( string $key ): ?int { $value = $this->int_post( $key ); return $value > 0 ? $value : null; } private function attachment_id_post( string $key ): ?int { $id = $this->nullable_absint_post( $key ); if ( null === $id ) { return null; } return 'attachment' === get_post_type( $id ) ? $id : null; } private function price_cents_post( string $key ): int { $value = $this->input()->text( $key, '0' ); try { $cents = \Abientot\Support\MoneyInput::parse( $value,'USD' ); $id = $this->input()->int( 'service_id' ); $existing = $id ? $this->services->find( $id ) : null; $currency = (string) Settings::values()['currency']; if ( ! $existing || (int) $existing['price_cents'] !== $cents || $existing['currency'] !== $currency ) { \Abientot\Support\Currency::validate( $cents,$currency ); } return $cents; } catch ( \InvalidArgumentException $error ) { wp_die( esc_html__( 'Enter an exact price supported by the selected currency. Use whole amounts for zero-decimal currencies; other prices support increments of 0.01.','abientot' ),'',array('response' => 400) ); } } private function currency_post( string $key ): string { $currency = strtoupper( $this->text_post( $key ) ); return preg_match( '/^[A-Z]{3}$/', $currency ) ? $currency : 'USD'; } /** * @return array */ private function timezone_options(): array { $options = array(); foreach ( timezone_identifiers_list() as $timezone ) { if ( false === strpos( $timezone, '/' ) ) { continue; } $options[ $timezone ] = $this->timezone_label( $timezone ); } asort( $options, SORT_NATURAL | SORT_FLAG_CASE ); return $options; } private function timezone_label( string $timezone ): string { try { $zone = new DateTimeZone( $timezone ); $now = new DateTimeImmutable( 'now', $zone ); $location = timezone_location_get( $zone ); $country = ''; if ( is_array( $location ) && ! empty( $location['country_code'] ) && class_exists( '\Locale' ) ) { $country = (string) \Locale::getDisplayRegion( '-' . $location['country_code'], get_user_locale() ); } if ( '' === $country ) { $country = str_replace( '_', ' ', strtok( $timezone, '/' ) ?: $timezone ); } $city = str_replace( '_', ' ', substr( strrchr( $timezone, '/' ) ?: $timezone, 1 ) ); $offset = $zone->getOffset( $now ); $sign = $offset >= 0 ? '+' : '-'; $offset = abs( $offset ); return sprintf( '%s (%s) - GMT%s%02d:%02d', $country, $city, $sign, floor( $offset / HOUR_IN_SECONDS ), floor( ( $offset % HOUR_IN_SECONDS ) / MINUTE_IN_SECONDS ) ); } catch ( Exception $exception ) { return $timezone; } } private function timezone_post( string $key ): string { $timezone = $this->text_post( $key ); return in_array( $timezone, timezone_identifiers_list(), true ) ? $timezone : wp_timezone_string(); } private function choice_post( string $key, array $choices, string $default ): string { $value = $this->input()->key( $key, $default ); return in_array( $value, $choices, true ) ? $value : $default; } private function time_post( string $key ): ?string { $value = $this->input()->has( $key ) ? $this->sanitize_time_value( $this->input()->text( $key ) ) : ''; return '' === $value ? null : $value . ':00'; } private function sanitize_time_value( $value ): string { $value = is_string( $value ) ? sanitize_text_field( $value ) : ''; return preg_match( '/^\d{2}:\d{2}$/', $value ) ? $value : ''; } private function time_value( string $value ): string { return substr( $value, 0, 5 ); } private function unique_slug( string $name, $repository, int $current_id = 0 ): string { $base = sanitize_title( $name ) ?: 'item'; $slug = $base; $i = 2; while ( true ) { $existing = $repository->find_by_slug( $slug ); if ( ! $existing || ( $current_id > 0 && (int) $existing['id'] === $current_id ) ) { return $slug; } $slug = $base . '-' . $i; $i++; } } private function format_price( int $cents ): string { return number_format( $cents / 100, 2, '.', '' ); } private function format_money( int $cents, string $currency ): string { return \Abientot\Support\Currency::format( $cents,$currency ); } private function exception_time_label( array $exception ): string { if ( 'closed' === (string) $exception['exception_type'] ) { return __( 'All day', 'abientot' ); } return $this->time_value( (string) $exception['start_time'] ) . ' - ' . $this->time_value( (string) $exception['end_time'] ); } /** * @return array */ private function weekdays(): array { $labels = array( 0 => __( 'Sunday', 'abientot' ), 1 => __( 'Monday', 'abientot' ), 2 => __( 'Tuesday', 'abientot' ), 3 => __( 'Wednesday', 'abientot' ), 4 => __( 'Thursday', 'abientot' ), 5 => __( 'Friday', 'abientot' ), 6 => __( 'Saturday', 'abientot' ), ); $ordered = array(); $start = \Abientot\Support\Locale::week_start(); for ( $offset = 0; $offset < 7; $offset++ ) { $weekday = ( $start + $offset ) % 7; $ordered[ $weekday ] = $labels[ $weekday ]; } return $ordered; } }