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 ) ) { + ?> +

+ + + + +

+ +
+

+ +

+ +
+ + +
+ +
+ + ' . esc_html__( 'Included', 'wp-bnb' ) . ''; + } else { + echo esc_html( $service['formatted_price'] ); + } + ?> + + 1 && 'included' !== $service['pricing_type'] ) : ?> + > + + + + + + > + = + +
+
+ +
+ +
+ + + ID ); + echo esc_html( Calculator::formatPrice( $services_total ) ); + ?> + +
+
+ + ID ); + if ( $services_total > 0 ) : + ?> + + + + + +
+ +
+ + + + + + + +
+ + + +
+ + + @@ -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 ' *'; } + if ( $services_total > 0 ) { + echo '
' . esc_html__( 'incl. services', 'wp-bnb' ) . ''; + } + } 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. * diff --git a/src/PostTypes/Service.php b/src/PostTypes/Service.php new file mode 100644 index 0000000..aa83bb2 --- /dev/null +++ b/src/PostTypes/Service.php @@ -0,0 +1,623 @@ + _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' ); + ?> + + + + + + > + + + +
+ + +
+ +

+ +

+ +
+
+ + +
+ + + +
+

+ +

+
+ 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; + ?> +

+ +

+ +

+ +
+ +

+ +

+ +

+ +
+ +

+ +

+ +

+ $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 + */ + 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; + } +} diff --git a/src/Taxonomies/ServiceCategory.php b/src/Taxonomies/ServiceCategory.php new file mode 100644 index 0000000..eb7e92e --- /dev/null +++ b/src/Taxonomies/ServiceCategory.php @@ -0,0 +1,276 @@ + _x( 'Service Categories', 'taxonomy general name', 'wp-bnb' ), + 'singular_name' => _x( 'Service Category', 'taxonomy singular name', 'wp-bnb' ), + 'search_items' => __( 'Search Service Categories', 'wp-bnb' ), + 'popular_items' => __( 'Popular Service Categories', 'wp-bnb' ), + 'all_items' => __( 'All Service Categories', 'wp-bnb' ), + 'parent_item' => null, + 'parent_item_colon' => null, + 'edit_item' => __( 'Edit Service Category', 'wp-bnb' ), + 'update_item' => __( 'Update Service Category', 'wp-bnb' ), + 'add_new_item' => __( 'Add New Service Category', 'wp-bnb' ), + 'new_item_name' => __( 'New Service Category Name', 'wp-bnb' ), + 'separate_items_with_commas' => __( 'Separate categories with commas', 'wp-bnb' ), + 'add_or_remove_items' => __( 'Add or remove categories', 'wp-bnb' ), + 'choose_from_most_used' => __( 'Choose from the most used categories', 'wp-bnb' ), + 'not_found' => __( 'No service categories found.', 'wp-bnb' ), + 'menu_name' => __( 'Categories', 'wp-bnb' ), + 'back_to_items' => __( '← Back to Categories', 'wp-bnb' ), + ); + + $args = array( + 'labels' => $labels, + 'hierarchical' => false, // Non-hierarchical (like tags). + 'public' => true, + 'publicly_queryable' => true, + 'show_ui' => true, + 'show_in_menu' => true, + 'show_in_nav_menus' => true, + 'show_in_rest' => true, + 'show_tagcloud' => false, + 'show_in_quick_edit' => true, + 'show_admin_column' => true, + 'rewrite' => array( + 'slug' => 'service-category', + 'with_front' => false, + ), + 'query_var' => true, + 'capabilities' => array( + 'manage_terms' => 'manage_options', + 'edit_terms' => 'manage_options', + 'delete_terms' => 'manage_options', + 'assign_terms' => 'edit_posts', + ), + ); + + register_taxonomy( self::TAXONOMY, array( 'bnb_service' ), $args ); + } + + /** + * Add custom fields to the add term form. + * + * @return void + */ + public static function add_form_fields(): void { + ?> +
+ + +

+
+
+ + +

+
+ term_id, 'service_category_icon', true ); + $sort_order = get_term_meta( $term->term_id, 'service_category_sort_order', true ); + ?> + + + + + + +

+ + + + + + + + +

+ + + $value ) { + $new_columns[ $key ] = $value; + if ( 'name' === $key ) { + $new_columns['icon'] = __( 'Icon', 'wp-bnb' ); + $new_columns['sort_order'] = __( 'Sort Order', 'wp-bnb' ); + } + } + return $new_columns; + } + + /** + * Render custom column content. + * + * @param string $content Column content. + * @param string $column_name Column name. + * @param int $term_id Term ID. + * @return string + */ + public static function render_column( string $content, string $column_name, int $term_id ): string { + if ( 'icon' === $column_name ) { + $icon = get_term_meta( $term_id, 'service_category_icon', true ); + if ( $icon ) { + return ''; + } + return '—'; + } + if ( 'sort_order' === $column_name ) { + $sort_order = get_term_meta( $term_id, 'service_category_sort_order', true ); + return esc_html( $sort_order ?: '0' ); + } + return $content; + } + + /** + * Get available icon options. + * + * @return array + */ + public static function get_icon_options(): array { + return array( + '' => __( '— Select Icon —', 'wp-bnb' ), + 'food' => __( 'Food & Dining', 'wp-bnb' ), + 'car' => __( 'Transportation', 'wp-bnb' ), + 'heart' => __( 'Wellness & Spa', 'wp-bnb' ), + 'tickets-alt' => __( 'Activities', 'wp-bnb' ), + 'admin-home' => __( 'Housekeeping', 'wp-bnb' ), + 'admin-appearance' => __( 'Room Service', 'wp-bnb' ), + 'store' => __( 'Shopping', 'wp-bnb' ), + 'groups' => __( 'Childcare', 'wp-bnb' ), + 'pets' => __( 'Pet Services', 'wp-bnb' ), + 'businessman' => __( 'Business', 'wp-bnb' ), + 'calendar' => __( 'Events', 'wp-bnb' ), + 'camera' => __( 'Photography', 'wp-bnb' ), + 'admin-generic' => __( 'Other', 'wp-bnb' ), + ); + } + + /** + * Get default service categories to seed on activation. + * + * @return array + */ + public static function get_default_terms(): array { + return array( + __( 'Food & Dining', 'wp-bnb' ) => array( + 'icon' => 'food', + 'sort_order' => 10, + ), + __( 'Transportation', 'wp-bnb' ) => array( + 'icon' => 'car', + 'sort_order' => 20, + ), + __( 'Wellness & Spa', 'wp-bnb' ) => array( + 'icon' => 'heart', + 'sort_order' => 30, + ), + __( 'Activities', 'wp-bnb' ) => array( + 'icon' => 'tickets-alt', + 'sort_order' => 40, + ), + __( 'Housekeeping', 'wp-bnb' ) => array( + 'icon' => 'admin-home', + 'sort_order' => 50, + ), + ); + } +} diff --git a/wp-bnb.php b/wp-bnb.php index 8f35c0c..62bde2c 100644 --- a/wp-bnb.php +++ b/wp-bnb.php @@ -3,7 +3,7 @@ * Plugin Name: WP BnB Management * Plugin URI: https://src.bundespruefstelle.ch/magdev/wp-bnb * Description: A comprehensive Bed & Breakfast management system for WordPress. Manage buildings, rooms, bookings, and guests. - * Version: 0.4.0 + * Version: 0.5.0 * Requires at least: 6.0 * Requires PHP: 8.3 * Author: Marco Graetsch @@ -24,7 +24,7 @@ if ( ! defined( 'ABSPATH' ) ) { } // Plugin version constant - MUST match Version in header above. -define( 'WP_BNB_VERSION', '0.4.0' ); +define( 'WP_BNB_VERSION', '0.5.0' ); // Plugin path constants. define( 'WP_BNB_PATH', plugin_dir_path( __FILE__ ) );