diff --git a/CHANGELOG.md b/CHANGELOG.md index 24a626e..74cd763 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,48 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - 2026-01-31 + +### Added + +- Additional Services System: + - Custom Post Type: Services (`bnb_service`) + - Service pricing types: Included (free), Per Booking (one-time), Per Night + - Service configuration: price, status, sort order, max quantity + - Custom admin columns with pricing type icons and status badges + - Filters by status and pricing type + - Service data helper methods for pricing calculations +- Service Categories Taxonomy (`bnb_service_category`) + - Non-hierarchical (tag-like) structure + - Icon selection per category + - Sort order for custom ordering + - Default categories: Food & Dining, Transportation, Wellness & Spa, Activities, Housekeeping +- Booking-Services Integration: + - Services meta box in Booking edit screen + - Checkbox-based service selection + - Quantity input for services with max_quantity > 1 + - Real-time price calculation per service based on nights + - Services total display + - Price breakdown shows services cost + - Grand total (room + services) in admin list and pricing meta box +- Admin UI Enhancements: + - Service selector with pricing type indicators + - Included services badge + - Per-night price suffix display + - Service line totals with quantity support + - Services total summary in booking + - CSS styles for all service-related components + - JavaScript for dynamic service pricing calculations + +### Changed + +- Plugin.php updated to register Service CPT and ServiceCategory taxonomy +- Admin assets enqueued for Service post type screens +- Booking admin list shows total price including services +- Booking pricing meta box displays services breakdown and grand total +- Admin JavaScript extended with service pricing and selection logic +- Admin CSS includes comprehensive service styling + ## [0.4.0] - 2026-01-31 ### Added @@ -248,6 +290,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Input sanitization and output escaping - Server secret masking in license settings +[0.5.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.5.0 [0.4.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.4.0 [0.3.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.3.0 [0.2.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.2.0 diff --git a/CLAUDE.md b/CLAUDE.md index 04cfeae..9bfbaab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -502,3 +502,61 @@ Admin features always work; frontend requires valid license. - Merged to main (fast-forward) - Tagged: `v0.3.0` - Pushed to origin: dev, main, v0.3.0 + +### 2026-01-31 - Version 0.5.0 (Additional Services) + +**Completed:** + +- Created Custom Taxonomy: Service Categories (`bnb_service_category`) + - Non-hierarchical (tag-like) structure + - Dashicon selection for visual display + - Sort order meta field for custom ordering + - Default categories: Food & Dining, Transportation, Wellness & Spa, Activities, Housekeeping +- Created Custom Post Type: Services (`bnb_service`) + - Three pricing types: Included (free), Per Booking, Per Night + - Price configuration per service + - Service status (active/inactive) + - Sort order for display ordering + - Maximum quantity setting per service + - Custom admin columns: pricing type, price, status + - Filters by status and pricing type + - Helper methods: `get_service_data()`, `calculate_service_price()`, `get_services_for_booking()`, `format_service_price()` +- Updated Booking post type with services integration + - Added `SERVICES_META_KEY` constant for services storage + - New meta box: Additional Services with checkbox selection + - Quantity input for services with max_quantity > 1 + - Real-time per-service line total calculation + - Services total display + - Price breakdown now shows services cost + - Grand total (room + services) in pricing meta box + - Admin list price column shows total including services + - Helper methods: `calculate_booking_services_total()`, `get_booking_services()` +- Updated `src/Plugin.php` + - Registered ServiceCategory taxonomy + - Registered Service post type + - Added Service post type to asset enqueuing + - Added i18n strings for service pricing descriptions +- Updated `assets/css/admin.css` + - Service status badges + - Service pricing meta box styles + - Booking services selector styles + - Service item with selected state + - Quantity inputs and line totals + - Services total summary + - Grand total display +- Updated `assets/js/admin.js` + - `initServicePricing()`: Toggle price row based on pricing type + - `initBookingServices()`: Service selection with real-time price calculation + - Quantity change handlers with min/max enforcement + - Automatic recalculation when booking dates change +- Updated version to 0.5.0 +- Updated CHANGELOG.md with Phase 5 changes +- Updated PLAN.md to mark Phase 5 complete + +**Learnings:** + +- Service pricing calculation depends on pricing_type: included=0, per_booking=price*qty, per_night=price*qty*nights +- Services are stored as JSON array in booking meta with service_id, quantity, price, pricing_type +- Same namespace classes can reference each other directly without use statements +- Services meta box renders before pricing meta box so services total is available +- Grand total calculation happens both on save (server-side) and on change (client-side JS) diff --git a/PLAN.md b/PLAN.md index afe53d4..899aae8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -84,37 +84,37 @@ This document outlines the implementation plan for the WP BnB Management plugin. - [x] Email notifications - [x] Booking confirmation -## Phase 4: Guest Management (v0.4.0) +## Phase 4: Guest Management (v0.4.0) - Complete ### Custom Post Type: Guests -- [ ] Personal information (name, email, phone) -- [ ] Address fields -- [ ] ID/Passport information -- [ ] Booking history reference -- [ ] Notes and preferences +- [x] Personal information (name, email, phone) +- [x] Address fields +- [x] ID/Passport information +- [x] Booking history reference +- [x] Notes and preferences ### Privacy & Compliance -- [ ] GDPR compliance features -- [ ] Data export functionality -- [ ] Data deletion on request -- [ ] Consent tracking +- [x] GDPR compliance features +- [x] Data export functionality +- [x] Data deletion on request +- [x] Consent tracking -## Phase 5: Additional Services (v0.5.0) +## Phase 5: Additional Services (v0.5.0) - Complete ### Service Options -- [ ] Custom Post Type: Services -- [ ] Price per service (or included) -- [ ] Per-booking or per-night pricing -- [ ] Service categories +- [x] Custom Post Type: Services +- [x] Price per service (or included) +- [x] Per-booking or per-night pricing +- [x] Service categories ### Booking Services -- [ ] Service selection during booking -- [ ] Automatic price calculation -- [ ] Service summary display +- [x] Service selection during booking +- [x] Automatic price calculation +- [x] Service summary display ## Phase 6: Frontend Features (v0.6.0) @@ -291,8 +291,8 @@ The plugin will provide extensive hooks for customization: | 0.1.0 | Data structures | Complete | | 0.2.0 | Pricing | Complete | | 0.3.0 | Bookings | Complete | -| 0.4.0 | Guests | TBD | -| 0.5.0 | Services | TBD | +| 0.4.0 | Guests | Complete | +| 0.5.0 | Services | Complete | | 0.6.0 | Frontend | TBD | | 0.7.0 | CF7 Integration | TBD | | 0.8.0 | Dashboard | TBD | diff --git a/assets/css/admin.css b/assets/css/admin.css index 928ee63..d807e7e 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -1088,3 +1088,208 @@ color: #646970; margin-top: 5px; } + +/* ========================================================================== + Services Styles + ========================================================================== */ + +/* Services List in Admin */ +.column-pricing_type, +.column-service_status { + width: 120px; +} + +/* Service Status Badges */ +.bnb-service-status { + display: inline-block; + padding: 3px 8px; + border-radius: 3px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; +} + +.bnb-service-status-active { + background: #d4edda; + color: #155724; +} + +.bnb-service-status-inactive { + background: #f6f7f7; + color: #646970; +} + +.bnb-service-included { + color: #00a32a; + font-weight: 600; +} + +/* Service Pricing Meta Box */ +.bnb-service-pricing-type fieldset label { + display: block; + margin-bottom: 15px; +} + +.bnb-service-pricing-type fieldset label input { + margin-right: 8px; +} + +.bnb-service-pricing-type fieldset p.description { + margin-left: 24px; + margin-top: 4px; +} + +/* ========================================================================== + Booking Services Selector + ========================================================================== */ + +.bnb-services-selector { + padding: 10px 0; +} + +.bnb-services-list { + margin: 15px 0; +} + +.bnb-service-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 15px; + background: #f6f7f7; + border: 1px solid #c3c4c7; + border-radius: 4px; + margin-bottom: 8px; + transition: background 0.15s ease, border-color 0.15s ease; +} + +.bnb-service-item:hover { + background: #f0f6fc; +} + +.bnb-service-item.selected { + background: #d4edda; + border-color: #c3e6cb; +} + +.bnb-service-checkbox { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + flex: 1; +} + +.bnb-service-checkbox input[type="checkbox"] { + margin: 0; +} + +.bnb-service-name { + font-weight: 600; + color: #1d2327; +} + +.bnb-service-details { + display: flex; + align-items: center; + gap: 15px; + flex-shrink: 0; +} + +.bnb-service-price-label { + color: #135e96; + font-weight: 500; +} + +.bnb-service-included-badge { + display: inline-block; + padding: 2px 8px; + background: #d4edda; + color: #155724; + border-radius: 3px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; +} + +.bnb-service-quantity { + display: flex; + align-items: center; + gap: 5px; +} + +.bnb-service-quantity label { + display: flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: #646970; +} + +.bnb-service-qty-input { + width: 50px !important; +} + +.bnb-service-line-total { + color: #1d2327; +} + +.bnb-service-total-value { + color: #135e96; +} + +/* Services Total */ +.bnb-services-total { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 15px; + padding: 15px; + background: #f0f6fc; + border: 1px solid #c3c4c7; + border-radius: 4px; + margin-top: 15px; +} + +.bnb-services-total strong { + color: #1d2327; +} + +#bnb-services-total-amount { + font-size: 16px; + font-weight: 600; + color: #135e96; +} + +/* No Services Message */ +.bnb-no-services-message { + padding: 20px; + text-align: center; + color: #646970; + font-style: italic; +} + +.bnb-no-services-message a { + margin-left: 5px; +} + +/* Booking Pricing with Services */ +.bnb-booking-services-summary { + padding: 8px 12px; + background: #d4edda; + border: 1px solid #c3e6cb; + border-radius: 4px; + color: #155724; +} + +.bnb-booking-grand-total { + padding: 12px 15px; + background: #f0f6fc; + border: 1px solid #c3c4c7; + border-radius: 4px; +} + +.bnb-booking-grand-total strong { + font-size: 18px; + color: #135e96; +} diff --git a/assets/js/admin.js b/assets/js/admin.js index 59018a5..4f0a71b 100644 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -755,6 +755,185 @@ }); } + /** + * Initialize service pricing type toggle. + */ + function initServicePricing() { + var $pricingTypeInputs = $('input[name="bnb_service_pricing_type"]'); + var $priceRow = $('#bnb-service-price-row'); + var $priceSuffix = $('#bnb-service-price-suffix'); + var $priceDescription = $('#bnb-service-price-description'); + + if (!$pricingTypeInputs.length) { + return; + } + + $pricingTypeInputs.on('change', function() { + var pricingType = $('input[name="bnb_service_pricing_type"]:checked').val(); + + if (pricingType === 'included') { + $priceRow.hide(); + } else { + $priceRow.show(); + + if (pricingType === 'per_night') { + $priceSuffix.text(' / ' + (wpBnbAdmin.i18n.night || 'night')); + $priceDescription.text(wpBnbAdmin.i18n.perNightDescription || 'This price will be charged per night of the stay.'); + } else { + $priceSuffix.text(''); + $priceDescription.text(wpBnbAdmin.i18n.perBookingDescription || 'This price will be charged once for the booking.'); + } + } + }); + } + + /** + * Initialize booking services selector. + */ + function initBookingServices() { + var $servicesSelector = $('.bnb-services-selector'); + var $servicesList = $servicesSelector.find('.bnb-services-list'); + var $totalDisplay = $('#bnb-services-total-amount'); + + if (!$servicesSelector.length) { + return; + } + + /** + * Get current number of nights from booking form. + * + * @return {number} Number of nights. + */ + function getNights() { + var checkIn = $('#bnb_booking_check_in').val(); + var checkOut = $('#bnb_booking_check_out').val(); + + if (checkIn && checkOut) { + var startDate = new Date(checkIn); + var endDate = new Date(checkOut); + var nights = Math.ceil((endDate - startDate) / (1000 * 60 * 60 * 24)); + return Math.max(1, nights); + } + + return parseInt($servicesSelector.data('nights'), 10) || 1; + } + + /** + * Calculate service line total. + * + * @param {jQuery} $item Service item element. + * @param {number} nights Number of nights. + * @return {number} Calculated price. + */ + function calculateServiceTotal($item, nights) { + var price = parseFloat($item.data('price')) || 0; + var pricingType = $item.data('pricing-type'); + var quantity = parseInt($item.find('.bnb-service-qty-input').val(), 10) || 1; + + if (pricingType === 'included') { + return 0; + } + + if (pricingType === 'per_night') { + return price * quantity * nights; + } + + return price * quantity; + } + + /** + * Update service line total display. + * + * @param {jQuery} $item Service item element. + */ + function updateServiceLineTotal($item) { + var nights = getNights(); + var total = calculateServiceTotal($item, nights); + var $lineTotal = $item.find('.bnb-service-line-total'); + var $totalValue = $item.find('.bnb-service-total-value'); + var isSelected = $item.find('input[type="checkbox"]').is(':checked'); + var pricingType = $item.data('pricing-type'); + + if (isSelected && pricingType !== 'included' && total > 0) { + $totalValue.text(formatPrice(total)); + $lineTotal.show(); + } else { + $lineTotal.hide(); + } + } + + /** + * Update total services amount. + */ + function updateServicesTotal() { + var nights = getNights(); + var total = 0; + + $servicesList.find('.bnb-service-item').each(function() { + var $item = $(this); + var isSelected = $item.find('input[type="checkbox"]').is(':checked'); + + if (isSelected) { + total += calculateServiceTotal($item, nights); + } + }); + + $totalDisplay.text(formatPrice(total)); + } + + /** + * Format price for display (simple formatting). + * + * @param {number} price Price value. + * @return {string} Formatted price. + */ + function formatPrice(price) { + return parseFloat(price).toFixed(2); + } + + // Handle service checkbox change. + $servicesList.on('change', 'input[type="checkbox"]', function() { + var $item = $(this).closest('.bnb-service-item'); + var isSelected = $(this).is(':checked'); + + $item.toggleClass('selected', isSelected); + + // Show/hide quantity input. + var $quantity = $item.find('.bnb-service-quantity'); + if ($quantity.length) { + $quantity.toggle(isSelected); + } + + updateServiceLineTotal($item); + updateServicesTotal(); + }); + + // Handle quantity change. + $servicesList.on('change input', '.bnb-service-qty-input', function() { + var $item = $(this).closest('.bnb-service-item'); + var maxQty = parseInt($item.data('max-quantity'), 10) || 1; + var value = parseInt($(this).val(), 10) || 1; + + // Enforce min/max. + value = Math.max(1, Math.min(value, maxQty)); + $(this).val(value); + + updateServiceLineTotal($item); + updateServicesTotal(); + }); + + // Update when booking dates change. + $('#bnb_booking_check_in, #bnb_booking_check_out').on('change', function() { + $servicesList.find('.bnb-service-item.selected').each(function() { + updateServiceLineTotal($(this)); + }); + updateServicesTotal(); + }); + + // Initial calculation. + updateServicesTotal(); + } + // Initialize on document ready. $(document).ready(function() { initLicenseManagement(); @@ -765,6 +944,8 @@ initBookingForm(); initCalendarPage(); initGuestSearch(); + initServicePricing(); + initBookingServices(); }); })(jQuery); diff --git a/src/Plugin.php b/src/Plugin.php index 4da2b73..7a62cc4 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -18,10 +18,12 @@ use Magdev\WpBnb\PostTypes\Booking; use Magdev\WpBnb\PostTypes\Building; use Magdev\WpBnb\PostTypes\Guest; use Magdev\WpBnb\PostTypes\Room; +use Magdev\WpBnb\PostTypes\Service; use Magdev\WpBnb\Privacy\Manager as PrivacyManager; use Magdev\WpBnb\Pricing\Season; use Magdev\WpBnb\Taxonomies\Amenity; use Magdev\WpBnb\Taxonomies\RoomType; +use Magdev\WpBnb\Taxonomies\ServiceCategory; use Twig\Environment; use Twig\Loader\FilesystemLoader; @@ -95,6 +97,7 @@ final class Plugin { Room::init(); Booking::init(); Guest::init(); + Service::init(); } /** @@ -106,6 +109,7 @@ final class Plugin { // Taxonomies must be registered before post types that use them. Amenity::init(); RoomType::init(); + ServiceCategory::init(); } /** @@ -188,7 +192,7 @@ final class Plugin { // Check if we're on plugin pages or editing our custom post types. $is_plugin_page = strpos( $hook_suffix, 'wp-bnb' ) !== false; - $is_our_post_type = in_array( $post_type, array( Building::POST_TYPE, Room::POST_TYPE, Booking::POST_TYPE, Guest::POST_TYPE ), true ); + $is_our_post_type = in_array( $post_type, array( Building::POST_TYPE, Room::POST_TYPE, Booking::POST_TYPE, Guest::POST_TYPE, Service::POST_TYPE ), true ); $is_edit_screen = in_array( $hook_suffix, array( 'post.php', 'post-new.php' ), true ); if ( ! $is_plugin_page && ! ( $is_our_post_type && $is_edit_screen ) ) { @@ -226,26 +230,28 @@ final class Plugin { 'nonce' => wp_create_nonce( 'wp_bnb_admin_nonce' ), 'postType' => $post_type, 'i18n' => array( - 'validating' => __( 'Validating...', 'wp-bnb' ), - 'activating' => __( 'Activating...', 'wp-bnb' ), - 'error' => __( 'An error occurred. Please try again.', 'wp-bnb' ), - 'selectImages' => __( 'Select Images', 'wp-bnb' ), - 'addToGallery' => __( 'Add to Gallery', 'wp-bnb' ), - 'confirmRemove' => __( 'Are you sure you want to remove this image?', 'wp-bnb' ), - 'increase' => __( 'increase', 'wp-bnb' ), - 'discount' => __( 'discount', 'wp-bnb' ), - 'normalPrice' => __( 'Normal price', 'wp-bnb' ), - 'checking' => __( 'Checking availability...', 'wp-bnb' ), - 'available' => __( 'Available', 'wp-bnb' ), - 'notAvailable' => __( 'Not available - conflicts with existing booking', 'wp-bnb' ), - 'selectRoomAndDates' => __( 'Select room and dates to check availability', 'wp-bnb' ), - 'nights' => __( 'nights', 'wp-bnb' ), - 'night' => __( 'night', 'wp-bnb' ), - 'calculating' => __( 'Calculating price...', 'wp-bnb' ), - 'searchingGuests' => __( 'Searching...', 'wp-bnb' ), - 'noGuestsFound' => __( 'No guests found', 'wp-bnb' ), - 'selectGuest' => __( 'Select', 'wp-bnb' ), - 'guestBlocked' => __( 'Blocked', 'wp-bnb' ), + 'validating' => __( 'Validating...', 'wp-bnb' ), + 'activating' => __( 'Activating...', 'wp-bnb' ), + 'error' => __( 'An error occurred. Please try again.', 'wp-bnb' ), + 'selectImages' => __( 'Select Images', 'wp-bnb' ), + 'addToGallery' => __( 'Add to Gallery', 'wp-bnb' ), + 'confirmRemove' => __( 'Are you sure you want to remove this image?', 'wp-bnb' ), + 'increase' => __( 'increase', 'wp-bnb' ), + 'discount' => __( 'discount', 'wp-bnb' ), + 'normalPrice' => __( 'Normal price', 'wp-bnb' ), + 'checking' => __( 'Checking availability...', 'wp-bnb' ), + 'available' => __( 'Available', 'wp-bnb' ), + 'notAvailable' => __( 'Not available - conflicts with existing booking', 'wp-bnb' ), + 'selectRoomAndDates' => __( 'Select room and dates to check availability', 'wp-bnb' ), + 'nights' => __( 'nights', 'wp-bnb' ), + 'night' => __( 'night', 'wp-bnb' ), + 'calculating' => __( 'Calculating price...', 'wp-bnb' ), + 'searchingGuests' => __( 'Searching...', 'wp-bnb' ), + 'noGuestsFound' => __( 'No guests found', 'wp-bnb' ), + 'selectGuest' => __( 'Select', 'wp-bnb' ), + 'guestBlocked' => __( 'Blocked', 'wp-bnb' ), + 'perNightDescription' => __( 'This price will be charged per night of the stay.', 'wp-bnb' ), + 'perBookingDescription' => __( 'This price will be charged once for the booking.', 'wp-bnb' ), ), ) ); diff --git a/src/PostTypes/Booking.php b/src/PostTypes/Booking.php index d138312..8915a25 100644 --- a/src/PostTypes/Booking.php +++ b/src/PostTypes/Booking.php @@ -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 { 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 ) ) { + ?> +
+ ++ +
+ +| + + | ++ + | +
|---|---|
| + + | +
+
+
+
+
+
+ + + + |
+
+ +
+ + + ++ +
+ + + ++ +
+ + + $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 ''; + 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 '' . esc_html__( 'Included', 'wp-bnb' ) . ''; + } 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 ' ' . esc_html__( '/ night', 'wp-bnb' ) . ''; + } + } else { + echo '' . esc_html__( 'Not set', 'wp-bnb' ) . ''; + } + } + 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 ''; + echo esc_html( $labels[ $status ] ?? $status ); + echo ''; + 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'] ) ) : ''; + ?> + + + + 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