Add additional services system (v0.5.0)
All checks were successful
Create Release Package / build-release (push) Successful in 1m0s

- Service CPT with pricing types: Included, Per Booking, Per Night
- ServiceCategory taxonomy with default categories
- Booking-services integration with service selector
- Real-time price calculation based on nights and quantity
- Services total and grand total display in booking admin

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 15:19:56 +01:00
parent aab3a4d1aa
commit 05f24fdec7
10 changed files with 1684 additions and 46 deletions

View File

@@ -18,6 +18,13 @@ use Magdev\WpBnb\Pricing\Calculator;
*/
final class Booking {
/**
* Services meta key.
*
* @var string
*/
public const SERVICES_META_KEY = '_bnb_booking_services';
/**
* Post type slug.
*
@@ -125,6 +132,15 @@ final class Booking {
'high'
);
add_meta_box(
'bnb_booking_services',
__( 'Additional Services', 'wp-bnb' ),
array( self::class, 'render_services_meta_box' ),
self::POST_TYPE,
'normal',
'default'
);
add_meta_box(
'bnb_booking_pricing',
__( 'Pricing', 'wp-bnb' ),
@@ -399,6 +415,111 @@ final class Booking {
<?php
}
/**
* Render services meta box.
*
* @param \WP_Post $post Current post object.
* @return void
*/
public static function render_services_meta_box( \WP_Post $post ): void {
$selected_services = get_post_meta( $post->ID, self::SERVICES_META_KEY, true ) ?: array();
$check_in = get_post_meta( $post->ID, self::META_PREFIX . 'check_in', true );
$check_out = get_post_meta( $post->ID, self::META_PREFIX . 'check_out', true );
$nights = ( $check_in && $check_out ) ? self::calculate_nights( $check_in, $check_out ) : 1;
// Get all active services.
$available_services = Service::get_services_for_booking();
if ( empty( $available_services ) ) {
?>
<p class="bnb-no-services-message">
<?php esc_html_e( 'No services available.', 'wp-bnb' ); ?>
<a href="<?php echo esc_url( admin_url( 'post-new.php?post_type=' . Service::POST_TYPE ) ); ?>">
<?php esc_html_e( 'Add a service', 'wp-bnb' ); ?>
</a>
</p>
<?php
return;
}
// Build a lookup map for selected services.
$selected_map = array();
if ( is_array( $selected_services ) ) {
foreach ( $selected_services as $service ) {
if ( isset( $service['service_id'] ) ) {
$selected_map[ $service['service_id'] ] = $service;
}
}
}
?>
<div class="bnb-services-selector" data-nights="<?php echo esc_attr( $nights ); ?>">
<p class="description">
<?php esc_html_e( 'Select additional services for this booking.', 'wp-bnb' ); ?>
</p>
<div class="bnb-services-list">
<?php foreach ( $available_services as $service ) : ?>
<?php
$is_selected = isset( $selected_map[ $service['id'] ] );
$quantity = $is_selected ? ( $selected_map[ $service['id'] ]['quantity'] ?? 1 ) : 1;
$service_total = $is_selected
? Service::calculate_service_price( $service['id'], $quantity, $nights )
: 0;
?>
<div class="bnb-service-item <?php echo $is_selected ? 'selected' : ''; ?>"
data-service-id="<?php echo esc_attr( $service['id'] ); ?>"
data-price="<?php echo esc_attr( $service['price'] ); ?>"
data-pricing-type="<?php echo esc_attr( $service['pricing_type'] ); ?>"
data-max-quantity="<?php echo esc_attr( $service['max_quantity'] ); ?>">
<label class="bnb-service-checkbox">
<input type="checkbox" name="bnb_booking_services[<?php echo esc_attr( $service['id'] ); ?>][selected]"
value="1" <?php checked( $is_selected ); ?>>
<span class="bnb-service-name"><?php echo esc_html( $service['name'] ); ?></span>
</label>
<div class="bnb-service-details">
<span class="bnb-service-price-label">
<?php
if ( 'included' === $service['pricing_type'] ) {
echo '<span class="bnb-service-included-badge">' . esc_html__( 'Included', 'wp-bnb' ) . '</span>';
} else {
echo esc_html( $service['formatted_price'] );
}
?>
</span>
<?php if ( $service['max_quantity'] > 1 && 'included' !== $service['pricing_type'] ) : ?>
<span class="bnb-service-quantity" <?php echo ! $is_selected ? 'style="display:none;"' : ''; ?>>
<label>
<?php esc_html_e( 'Qty:', 'wp-bnb' ); ?>
<input type="number" name="bnb_booking_services[<?php echo esc_attr( $service['id'] ); ?>][quantity]"
value="<?php echo esc_attr( $quantity ); ?>"
min="1" max="<?php echo esc_attr( $service['max_quantity'] ); ?>"
class="small-text bnb-service-qty-input">
</label>
</span>
<?php else : ?>
<input type="hidden" name="bnb_booking_services[<?php echo esc_attr( $service['id'] ); ?>][quantity]" value="1">
<?php endif; ?>
<span class="bnb-service-line-total" <?php echo ( ! $is_selected || $service_total <= 0 ) ? 'style="display:none;"' : ''; ?>>
= <strong class="bnb-service-total-value"><?php echo esc_html( Calculator::formatPrice( $service_total ) ); ?></strong>
</span>
</div>
</div>
<?php endforeach; ?>
</div>
<div class="bnb-services-total">
<strong><?php esc_html_e( 'Services Total:', 'wp-bnb' ); ?></strong>
<span id="bnb-services-total-amount">
<?php
$services_total = self::calculate_booking_services_total( $post->ID );
echo esc_html( Calculator::formatPrice( $services_total ) );
?>
</span>
</div>
</div>
<?php
}
/**
* Render pricing meta box.
*
@@ -447,6 +568,36 @@ final class Booking {
</td>
</tr>
<?php endif; ?>
<?php
$services_total = self::calculate_booking_services_total( $post->ID );
if ( $services_total > 0 ) :
?>
<tr>
<th scope="row">
<?php esc_html_e( 'Services', 'wp-bnb' ); ?>
</th>
<td>
<div class="bnb-booking-services-summary">
<?php echo esc_html( Calculator::formatPrice( $services_total ) ); ?>
</div>
</td>
</tr>
<tr>
<th scope="row">
<?php esc_html_e( 'Grand Total', 'wp-bnb' ); ?>
</th>
<td>
<div class="bnb-booking-grand-total">
<strong>
<?php
$room_price = (float) $calculated_price;
echo esc_html( Calculator::formatPrice( $room_price + $services_total ) );
?>
</strong>
</div>
</td>
</tr>
<?php endif; ?>
<tr>
<th scope="row">
<label for="bnb_booking_override_price"><?php esc_html_e( 'Override Price', 'wp-bnb' ); ?></label>
@@ -678,6 +829,43 @@ final class Booking {
}
}
// Services.
$services_data = array();
if ( isset( $_POST['bnb_booking_services'] ) && is_array( $_POST['bnb_booking_services'] ) ) {
$nights = ( $check_in && $check_out ) ? self::calculate_nights( $check_in, $check_out ) : 1;
foreach ( $_POST['bnb_booking_services'] as $service_id => $service_input ) {
$service_id = absint( $service_id );
if ( ! $service_id ) {
continue;
}
// Only include if selected checkbox is checked.
if ( empty( $service_input['selected'] ) ) {
continue;
}
// Verify service exists and is active.
$service_data = Service::get_service_data( $service_id );
if ( ! $service_data || 'active' !== $service_data['status'] ) {
continue;
}
$quantity = isset( $service_input['quantity'] ) ? absint( $service_input['quantity'] ) : 1;
$quantity = max( 1, min( $quantity, $service_data['max_quantity'] ) );
$price = Service::calculate_service_price( $service_id, $quantity, $nights );
$services_data[] = array(
'service_id' => $service_id,
'quantity' => $quantity,
'price' => $price,
'pricing_type' => $service_data['pricing_type'],
);
}
}
update_post_meta( $post_id, self::SERVICES_META_KEY, $services_data );
// Trigger status change action.
if ( $old_status && $status !== $old_status ) {
/**
@@ -794,13 +982,21 @@ final class Booking {
break;
case 'price':
$price = get_post_meta( $post_id, self::META_PREFIX . 'calculated_price', true );
if ( $price ) {
echo esc_html( Calculator::formatPrice( (float) $price ) );
$room_price = (float) get_post_meta( $post_id, self::META_PREFIX . 'calculated_price', true );
$services_total = self::calculate_booking_services_total( $post_id );
$total_price = $room_price + $services_total;
if ( $total_price > 0 ) {
echo esc_html( Calculator::formatPrice( $total_price ) );
$override = get_post_meta( $post_id, self::META_PREFIX . 'override_price', true );
if ( $override ) {
echo ' <span class="bnb-price-override" title="' . esc_attr__( 'Price manually overridden', 'wp-bnb' ) . '">*</span>';
}
if ( $services_total > 0 ) {
echo '<br><small style="color: #646970;">' . esc_html__( 'incl. services', 'wp-bnb' ) . '</small>';
}
} elseif ( $room_price > 0 ) {
echo esc_html( Calculator::formatPrice( $room_price ) );
} else {
echo '—';
}
@@ -1232,6 +1428,56 @@ final class Booking {
return get_posts( array_merge( $defaults, $args ) );
}
/**
* Calculate total services cost for a booking.
*
* @param int $booking_id Booking post ID.
* @return float Total services cost.
*/
public static function calculate_booking_services_total( int $booking_id ): float {
$services = get_post_meta( $booking_id, self::SERVICES_META_KEY, true );
if ( ! is_array( $services ) || empty( $services ) ) {
return 0.0;
}
$total = 0.0;
foreach ( $services as $service ) {
if ( isset( $service['price'] ) ) {
$total += (float) $service['price'];
}
}
return $total;
}
/**
* Get selected services for a booking.
*
* @param int $booking_id Booking post ID.
* @return array Array of service data with names.
*/
public static function get_booking_services( int $booking_id ): array {
$services = get_post_meta( $booking_id, self::SERVICES_META_KEY, true );
if ( ! is_array( $services ) || empty( $services ) ) {
return array();
}
$result = array();
foreach ( $services as $service ) {
$service_post = get_post( $service['service_id'] ?? 0 );
if ( $service_post ) {
$result[] = array_merge(
$service,
array( 'name' => $service_post->post_title )
);
}
}
return $result;
}
/**
* Format price breakdown for display.
*