Files
wp-bnb/src/PostTypes/Service.php

624 lines
20 KiB
PHP
Raw Normal View History

<?php
/**
* Service post type.
*
* Custom post type for BnB additional services.
*
* @package Magdev\WpBnb\PostTypes
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\PostTypes;
use Magdev\WpBnb\Pricing\Calculator;
/**
* Service post type class.
*/
final class Service {
/**
* Post type slug.
*
* @var string
*/
public const POST_TYPE = 'bnb_service';
/**
* Meta key prefix.
*
* @var string
*/
private const META_PREFIX = '_bnb_service_';
/**
* Initialize the post type.
*
* @return void
*/
public static function init(): void {
add_action( 'init', array( self::class, 'register' ) );
add_action( 'add_meta_boxes', array( self::class, 'add_meta_boxes' ) );
add_action( 'save_post_' . self::POST_TYPE, array( self::class, 'save_meta' ), 10, 2 );
add_filter( 'manage_' . self::POST_TYPE . '_posts_columns', array( self::class, 'add_columns' ) );
add_action( 'manage_' . self::POST_TYPE . '_posts_custom_column', array( self::class, 'render_column' ), 10, 2 );
add_filter( 'manage_edit-' . self::POST_TYPE . '_sortable_columns', array( self::class, 'sortable_columns' ) );
add_action( 'restrict_manage_posts', array( self::class, 'add_filters' ) );
add_action( 'pre_get_posts', array( self::class, 'filter_query' ) );
add_filter( 'enter_title_here', array( self::class, 'change_title_placeholder' ), 10, 2 );
}
/**
* Register the post type.
*
* @return void
*/
public static function register(): void {
$labels = array(
'name' => _x( 'Services', 'post type general name', 'wp-bnb' ),
'singular_name' => _x( 'Service', 'post type singular name', 'wp-bnb' ),
'menu_name' => _x( 'Services', 'admin menu', 'wp-bnb' ),
'name_admin_bar' => _x( 'Service', 'add new on admin bar', 'wp-bnb' ),
'add_new' => _x( 'Add New', 'service', 'wp-bnb' ),
'add_new_item' => __( 'Add New Service', 'wp-bnb' ),
'new_item' => __( 'New Service', 'wp-bnb' ),
'edit_item' => __( 'Edit Service', 'wp-bnb' ),
'view_item' => __( 'View Service', 'wp-bnb' ),
'all_items' => __( 'Services', 'wp-bnb' ),
'search_items' => __( 'Search Services', 'wp-bnb' ),
'parent_item_colon' => __( 'Parent Services:', 'wp-bnb' ),
'not_found' => __( 'No services found.', 'wp-bnb' ),
'not_found_in_trash' => __( 'No services found in Trash.', 'wp-bnb' ),
'archives' => __( 'Service archives', 'wp-bnb' ),
'insert_into_item' => __( 'Insert into service', 'wp-bnb' ),
'uploaded_to_this_item' => __( 'Uploaded to this service', 'wp-bnb' ),
'filter_items_list' => __( 'Filter services list', 'wp-bnb' ),
'items_list_navigation' => __( 'Services list navigation', 'wp-bnb' ),
'items_list' => __( 'Services list', 'wp-bnb' ),
);
$args = array(
'labels' => $labels,
'public' => false,
'publicly_queryable' => false,
'show_ui' => true,
'show_in_menu' => 'wp-bnb',
'query_var' => false,
'capability_type' => 'post',
'has_archive' => false,
'hierarchical' => false,
'menu_position' => null,
'menu_icon' => 'dashicons-plus-alt',
'supports' => array( 'title', 'editor', 'thumbnail' ),
'show_in_rest' => true,
'rest_base' => 'services',
'rest_controller_class' => 'WP_REST_Posts_Controller',
);
register_post_type( self::POST_TYPE, $args );
}
/**
* Add meta boxes.
*
* @return void
*/
public static function add_meta_boxes(): void {
add_meta_box(
'bnb_service_pricing',
__( 'Pricing', 'wp-bnb' ),
array( self::class, 'render_pricing_meta_box' ),
self::POST_TYPE,
'normal',
'high'
);
add_meta_box(
'bnb_service_settings',
__( 'Service Settings', 'wp-bnb' ),
array( self::class, 'render_settings_meta_box' ),
self::POST_TYPE,
'side',
'default'
);
}
/**
* Render pricing meta box.
*
* @param \WP_Post $post Current post object.
* @return void
*/
public static function render_pricing_meta_box( \WP_Post $post ): void {
wp_nonce_field( 'bnb_service_meta', 'bnb_service_meta_nonce' );
$pricing_type = get_post_meta( $post->ID, self::META_PREFIX . 'pricing_type', true ) ?: 'per_booking';
$price = get_post_meta( $post->ID, self::META_PREFIX . 'price', true );
$currency = get_option( 'wp_bnb_currency', 'CHF' );
?>
<table class="form-table">
<tr>
<th scope="row">
<label for="bnb_service_pricing_type"><?php esc_html_e( 'Pricing Type', 'wp-bnb' ); ?></label>
</th>
<td>
<fieldset>
<label>
<input type="radio" name="bnb_service_pricing_type" value="included"
<?php checked( $pricing_type, 'included' ); ?>>
<?php esc_html_e( 'Included (Free)', 'wp-bnb' ); ?>
<p class="description"><?php esc_html_e( 'Service is included at no extra cost.', 'wp-bnb' ); ?></p>
</label>
<br><br>
<label>
<input type="radio" name="bnb_service_pricing_type" value="per_booking"
<?php checked( $pricing_type, 'per_booking' ); ?>>
<?php esc_html_e( 'Per Booking (One-time)', 'wp-bnb' ); ?>
<p class="description"><?php esc_html_e( 'Fixed price charged once per booking.', 'wp-bnb' ); ?></p>
</label>
<br><br>
<label>
<input type="radio" name="bnb_service_pricing_type" value="per_night"
<?php checked( $pricing_type, 'per_night' ); ?>>
<?php esc_html_e( 'Per Night', 'wp-bnb' ); ?>
<p class="description"><?php esc_html_e( 'Price multiplied by the number of nights.', 'wp-bnb' ); ?></p>
</label>
</fieldset>
</td>
</tr>
<tr id="bnb-service-price-row" <?php echo 'included' === $pricing_type ? 'style="display:none;"' : ''; ?>>
<th scope="row">
<label for="bnb_service_price"><?php esc_html_e( 'Price', 'wp-bnb' ); ?></label>
</th>
<td>
<div class="bnb-price-input-wrapper">
<input type="number" id="bnb_service_price" name="bnb_service_price"
value="<?php echo esc_attr( $price ); ?>" class="small-text"
min="0" step="0.01">
<span class="bnb-price-unit"><?php echo esc_html( $currency ); ?></span>
<span id="bnb-service-price-suffix"></span>
</div>
<p class="description" id="bnb-service-price-description">
<?php
if ( 'per_night' === $pricing_type ) {
esc_html_e( 'This price will be charged per night of the stay.', 'wp-bnb' );
} else {
esc_html_e( 'This price will be charged once for the booking.', 'wp-bnb' );
}
?>
</p>
</td>
</tr>
</table>
<?php
}
/**
* Render settings meta box.
*
* @param \WP_Post $post Current post object.
* @return void
*/
public static function render_settings_meta_box( \WP_Post $post ): void {
$status = get_post_meta( $post->ID, self::META_PREFIX . 'status', true ) ?: 'active';
$sort_order = get_post_meta( $post->ID, self::META_PREFIX . 'sort_order', true ) ?: 0;
$max_qty = get_post_meta( $post->ID, self::META_PREFIX . 'max_quantity', true ) ?: 1;
?>
<p>
<label for="bnb_service_status"><strong><?php esc_html_e( 'Status', 'wp-bnb' ); ?></strong></label>
</p>
<select id="bnb_service_status" name="bnb_service_status" class="widefat">
<option value="active" <?php selected( $status, 'active' ); ?>><?php esc_html_e( 'Active', 'wp-bnb' ); ?></option>
<option value="inactive" <?php selected( $status, 'inactive' ); ?>><?php esc_html_e( 'Inactive', 'wp-bnb' ); ?></option>
</select>
<p class="description"><?php esc_html_e( 'Inactive services cannot be added to bookings.', 'wp-bnb' ); ?></p>
<hr>
<p>
<label for="bnb_service_sort_order"><strong><?php esc_html_e( 'Sort Order', 'wp-bnb' ); ?></strong></label>
</p>
<input type="number" id="bnb_service_sort_order" name="bnb_service_sort_order"
value="<?php echo esc_attr( $sort_order ); ?>" class="small-text" min="0">
<p class="description"><?php esc_html_e( 'Lower numbers appear first.', 'wp-bnb' ); ?></p>
<hr>
<p>
<label for="bnb_service_max_quantity"><strong><?php esc_html_e( 'Maximum Quantity', 'wp-bnb' ); ?></strong></label>
</p>
<input type="number" id="bnb_service_max_quantity" name="bnb_service_max_quantity"
value="<?php echo esc_attr( $max_qty ); ?>" class="small-text" min="1" max="99">
<p class="description"><?php esc_html_e( 'Maximum times this service can be added to a booking.', 'wp-bnb' ); ?></p>
<?php
}
/**
* Save post meta.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @return void
*/
public static function save_meta( int $post_id, \WP_Post $post ): void {
// Verify nonce.
if ( ! isset( $_POST['bnb_service_meta_nonce'] ) ||
! wp_verify_nonce( sanitize_key( $_POST['bnb_service_meta_nonce'] ), 'bnb_service_meta' ) ) {
return;
}
// Check autosave.
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
// Check permissions.
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
// Pricing type.
$valid_pricing_types = array( 'included', 'per_booking', 'per_night' );
$pricing_type = isset( $_POST['bnb_service_pricing_type'] )
? sanitize_text_field( wp_unslash( $_POST['bnb_service_pricing_type'] ) )
: 'per_booking';
if ( in_array( $pricing_type, $valid_pricing_types, true ) ) {
update_post_meta( $post_id, self::META_PREFIX . 'pricing_type', $pricing_type );
}
// Price (not required for 'included').
if ( 'included' === $pricing_type ) {
update_post_meta( $post_id, self::META_PREFIX . 'price', 0 );
} elseif ( isset( $_POST['bnb_service_price'] ) ) {
update_post_meta( $post_id, self::META_PREFIX . 'price', floatval( $_POST['bnb_service_price'] ) );
}
// Status.
$valid_statuses = array( 'active', 'inactive' );
$status = isset( $_POST['bnb_service_status'] )
? sanitize_text_field( wp_unslash( $_POST['bnb_service_status'] ) )
: 'active';
if ( in_array( $status, $valid_statuses, true ) ) {
update_post_meta( $post_id, self::META_PREFIX . 'status', $status );
}
// Sort order.
if ( isset( $_POST['bnb_service_sort_order'] ) ) {
update_post_meta( $post_id, self::META_PREFIX . 'sort_order', absint( $_POST['bnb_service_sort_order'] ) );
}
// Max quantity.
if ( isset( $_POST['bnb_service_max_quantity'] ) ) {
$max_qty = absint( $_POST['bnb_service_max_quantity'] );
$max_qty = max( 1, min( 99, $max_qty ) );
update_post_meta( $post_id, self::META_PREFIX . 'max_quantity', $max_qty );
}
}
/**
* Add custom columns to the post list.
*
* @param array $columns Existing columns.
* @return array
*/
public static function add_columns( array $columns ): array {
$new_columns = array();
foreach ( $columns as $key => $value ) {
$new_columns[ $key ] = $value;
if ( 'title' === $key ) {
$new_columns['pricing_type'] = __( 'Pricing Type', 'wp-bnb' );
$new_columns['price'] = __( 'Price', 'wp-bnb' );
$new_columns['service_status'] = __( 'Status', 'wp-bnb' );
}
}
// Remove date column.
unset( $new_columns['date'] );
return $new_columns;
}
/**
* Render custom column content.
*
* @param string $column Column name.
* @param int $post_id Post ID.
* @return void
*/
public static function render_column( string $column, int $post_id ): void {
switch ( $column ) {
case 'pricing_type':
$pricing_type = get_post_meta( $post_id, self::META_PREFIX . 'pricing_type', true ) ?: 'per_booking';
$labels = self::get_pricing_type_labels();
$icons = array(
'included' => 'yes-alt',
'per_booking' => 'tag',
'per_night' => 'calendar-alt',
);
$colors = array(
'included' => '#00a32a',
'per_booking' => '#135e96',
'per_night' => '#dba617',
);
echo '<span class="dashicons dashicons-' . esc_attr( $icons[ $pricing_type ] ?? 'admin-generic' ) . '" style="color: ' . esc_attr( $colors[ $pricing_type ] ?? '#646970' ) . '; vertical-align: middle; margin-right: 3px;"></span>';
echo esc_html( $labels[ $pricing_type ] ?? $pricing_type );
break;
case 'price':
$pricing_type = get_post_meta( $post_id, self::META_PREFIX . 'pricing_type', true ) ?: 'per_booking';
if ( 'included' === $pricing_type ) {
echo '<span class="bnb-service-included">' . esc_html__( 'Included', 'wp-bnb' ) . '</span>';
} else {
$price = get_post_meta( $post_id, self::META_PREFIX . 'price', true );
if ( $price ) {
echo esc_html( Calculator::formatPrice( (float) $price ) );
if ( 'per_night' === $pricing_type ) {
echo ' <small style="color: #646970;">' . esc_html__( '/ night', 'wp-bnb' ) . '</small>';
}
} else {
echo '<span class="bnb-no-price">' . esc_html__( 'Not set', 'wp-bnb' ) . '</span>';
}
}
break;
case 'service_status':
$status = get_post_meta( $post_id, self::META_PREFIX . 'status', true ) ?: 'active';
$classes = array(
'active' => 'bnb-service-status-active',
'inactive' => 'bnb-service-status-inactive',
);
$labels = array(
'active' => __( 'Active', 'wp-bnb' ),
'inactive' => __( 'Inactive', 'wp-bnb' ),
);
echo '<span class="bnb-service-status ' . esc_attr( $classes[ $status ] ?? '' ) . '">';
echo esc_html( $labels[ $status ] ?? $status );
echo '</span>';
break;
}
}
/**
* Add sortable columns.
*
* @param array $columns Existing sortable columns.
* @return array
*/
public static function sortable_columns( array $columns ): array {
$columns['price'] = 'price';
$columns['service_status'] = 'status';
return $columns;
}
/**
* Add filter dropdowns to admin list.
*
* @param string $post_type Current post type.
* @return void
*/
public static function add_filters( string $post_type ): void {
if ( self::POST_TYPE !== $post_type ) {
return;
}
// Status filter.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter display only.
$selected_status = isset( $_GET['service_status'] ) ? sanitize_text_field( wp_unslash( $_GET['service_status'] ) ) : '';
?>
<select name="service_status">
<option value=""><?php esc_html_e( 'All Statuses', 'wp-bnb' ); ?></option>
<option value="active" <?php selected( $selected_status, 'active' ); ?>><?php esc_html_e( 'Active', 'wp-bnb' ); ?></option>
<option value="inactive" <?php selected( $selected_status, 'inactive' ); ?>><?php esc_html_e( 'Inactive', 'wp-bnb' ); ?></option>
</select>
<?php
// Pricing type filter.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter display only.
$selected_pricing = isset( $_GET['pricing_type'] ) ? sanitize_text_field( wp_unslash( $_GET['pricing_type'] ) ) : '';
$labels = self::get_pricing_type_labels();
?>
<select name="pricing_type">
<option value=""><?php esc_html_e( 'All Pricing Types', 'wp-bnb' ); ?></option>
<?php foreach ( $labels as $value => $label ) : ?>
<option value="<?php echo esc_attr( $value ); ?>" <?php selected( $selected_pricing, $value ); ?>>
<?php echo esc_html( $label ); ?>
</option>
<?php endforeach; ?>
</select>
<?php
}
/**
* Filter services by status and pricing type in admin list.
*
* @param \WP_Query $query Current query.
* @return void
*/
public static function filter_query( \WP_Query $query ): void {
if ( ! is_admin() || ! $query->is_main_query() ) {
return;
}
if ( self::POST_TYPE !== $query->get( 'post_type' ) ) {
return;
}
$meta_query = array();
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter query only.
if ( ! empty( $_GET['service_status'] ) ) {
$meta_query[] = array(
'key' => self::META_PREFIX . 'status',
'value' => sanitize_text_field( wp_unslash( $_GET['service_status'] ) ),
);
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter query only.
if ( ! empty( $_GET['pricing_type'] ) ) {
$meta_query[] = array(
'key' => self::META_PREFIX . 'pricing_type',
'value' => sanitize_text_field( wp_unslash( $_GET['pricing_type'] ) ),
);
}
if ( ! empty( $meta_query ) ) {
$meta_query['relation'] = 'AND';
$query->set( 'meta_query', $meta_query );
}
// Handle sorting.
$orderby = $query->get( 'orderby' );
if ( 'price' === $orderby ) {
$query->set( 'meta_key', self::META_PREFIX . 'price' );
$query->set( 'orderby', 'meta_value_num' );
} elseif ( 'status' === $orderby ) {
$query->set( 'meta_key', self::META_PREFIX . 'status' );
$query->set( 'orderby', 'meta_value' );
}
}
/**
* Change title placeholder.
*
* @param string $placeholder Default placeholder.
* @param \WP_Post $post Current post.
* @return string
*/
public static function change_title_placeholder( string $placeholder, \WP_Post $post ): string {
if ( self::POST_TYPE === $post->post_type ) {
return __( 'Service name', 'wp-bnb' );
}
return $placeholder;
}
/**
* Get pricing type labels.
*
* @return array<string, string>
*/
public static function get_pricing_type_labels(): array {
return array(
'included' => __( 'Included (Free)', 'wp-bnb' ),
'per_booking' => __( 'Per Booking', 'wp-bnb' ),
'per_night' => __( 'Per Night', 'wp-bnb' ),
);
}
/**
* Get all active services.
*
* @param array $args Additional query args.
* @return array<\WP_Post>
*/
public static function get_active_services( array $args = array() ): array {
$defaults = array(
'post_type' => self::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'meta_query' => array(
array(
'key' => self::META_PREFIX . 'status',
'value' => 'active',
),
),
'meta_key' => self::META_PREFIX . 'sort_order',
'orderby' => 'meta_value_num',
'order' => 'ASC',
);
return get_posts( array_merge( $defaults, $args ) );
}
/**
* Get service data for a service.
*
* @param int $service_id Service post ID.
* @return array|null Service data or null if not found.
*/
public static function get_service_data( int $service_id ): ?array {
$service = get_post( $service_id );
if ( ! $service || self::POST_TYPE !== $service->post_type ) {
return null;
}
return array(
'id' => $service_id,
'name' => $service->post_title,
'description' => $service->post_content,
'pricing_type' => get_post_meta( $service_id, self::META_PREFIX . 'pricing_type', true ) ?: 'per_booking',
'price' => (float) get_post_meta( $service_id, self::META_PREFIX . 'price', true ),
'status' => get_post_meta( $service_id, self::META_PREFIX . 'status', true ) ?: 'active',
'sort_order' => (int) get_post_meta( $service_id, self::META_PREFIX . 'sort_order', true ),
'max_quantity' => (int) get_post_meta( $service_id, self::META_PREFIX . 'max_quantity', true ) ?: 1,
);
}
/**
* Calculate service price for a booking.
*
* @param int $service_id Service post ID.
* @param int $quantity Quantity of the service.
* @param int $nights Number of nights (for per-night pricing).
* @return float Calculated price.
*/
public static function calculate_service_price( int $service_id, int $quantity = 1, int $nights = 1 ): float {
$data = self::get_service_data( $service_id );
if ( ! $data ) {
return 0.0;
}
if ( 'included' === $data['pricing_type'] ) {
return 0.0;
}
$base_price = $data['price'];
if ( 'per_night' === $data['pricing_type'] ) {
return $base_price * $quantity * max( 1, $nights );
}
// per_booking.
return $base_price * $quantity;
}
/**
* Get services for booking display/selection.
*
* @return array Array of services with their data.
*/
public static function get_services_for_booking(): array {
$services = self::get_active_services();
$result = array();
foreach ( $services as $service ) {
$data = self::get_service_data( $service->ID );
if ( $data ) {
$data['formatted_price'] = self::format_service_price( $data );
$result[] = $data;
}
}
return $result;
}
/**
* Format service price for display.
*
* @param array $service_data Service data array.
* @return string Formatted price string.
*/
public static function format_service_price( array $service_data ): string {
if ( 'included' === $service_data['pricing_type'] ) {
return __( 'Included', 'wp-bnb' );
}
$formatted = Calculator::formatPrice( $service_data['price'] );
if ( 'per_night' === $service_data['pricing_type'] ) {
/* translators: %s: Formatted price */
return sprintf( __( '%s / night', 'wp-bnb' ), $formatted );
}
return $formatted;
}
}