From 0c601df568afc9518ecabadf8f16676a2603a28d Mon Sep 17 00:00:00 2001 From: magdev Date: Sat, 31 Jan 2026 14:37:48 +0100 Subject: [PATCH] Add booking system with calendar and email notifications (v0.3.0) - Booking Custom Post Type with full management features - Room and guest relationship tracking - Check-in/check-out date management with validation - Booking status workflow (pending, confirmed, checked_in, checked_out, cancelled) - Automatic price calculation using existing Calculator - Availability system with real-time conflict detection - AJAX endpoint for instant availability validation - Calendar admin page with monthly view and room/building filters - Color-coded booking status display with legend - Email notifications for new bookings, confirmations, and cancellations - HTML email templates with placeholder-based system - Auto-generated booking references (BNB-YYYY-NNNNN) Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 68 ++ CLAUDE.md | 71 ++ PLAN.md | 32 +- assets/css/admin.css | 365 +++++++++++ assets/js/admin.js | 302 +++++++++ src/Admin/Calendar.php | 359 +++++++++++ src/Booking/Availability.php | 444 +++++++++++++ src/Booking/EmailNotifier.php | 587 +++++++++++++++++ src/Plugin.php | 78 ++- src/PostTypes/Booking.php | 1137 +++++++++++++++++++++++++++++++++ wp-bnb.php | 4 +- 11 files changed, 3419 insertions(+), 28 deletions(-) create mode 100644 src/Admin/Calendar.php create mode 100644 src/Booking/Availability.php create mode 100644 src/Booking/EmailNotifier.php create mode 100644 src/PostTypes/Booking.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d7faafc..da29b72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,73 @@ 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.3.0] - 2026-01-31 + +### Added + +- Booking System with full management features: + - Custom Post Type: Bookings (`bnb_booking`) + - Room and guest relationship tracking + - Check-in/check-out date management + - Booking status workflow (pending, confirmed, checked_in, checked_out, cancelled) + - Status transitions with validation + - Automatic price calculation using existing Calculator + - Price override option for manual adjustments + - Guest information storage (name, email, phone, notes) + - Guest count tracking (adults, children) + - Internal notes field for staff + - Auto-generated booking references (BNB-YYYY-NNNNN) +- Availability System (`src/Booking/Availability.php`) + - Real-time availability checking + - Conflict detection for overlapping bookings + - AJAX endpoint for instant availability validation + - Calendar data generation for rooms and buildings + - Support for excluding booking from conflict check (for editing) + - Utility methods: get upcoming bookings, current bookings, today's check-ins/outs +- Calendar Admin Page (WP BnB > Calendar) + - Monthly calendar view with availability visualization + - Room and building filter dropdowns + - Color-coded booking status display + - Month navigation (previous/next/today) + - Click-to-edit booking functionality + - Hover tooltips with booking details + - Legend for status colors + - Single room and multi-room views +- Email Notifications (`src/Booking/EmailNotifier.php`) + - Admin notification for new bookings + - Guest confirmation email on booking confirmation + - Admin notification on booking confirmation + - Cancellation emails to guest and admin + - HTML email templates with styling + - Placeholder-based template system + - Filter hooks for customizing recipients, subject, and content +- Booking Admin List Enhancements + - Custom columns: room, guest, dates, nights, price, status + - Status badges with color coding + - Filter by room and status + - Sortable columns for dates, guest, status + - Price override indicator +- Booking Meta Boxes + - Room & Dates: room selection, date pickers, nights display, availability check + - Guest Information: contact details, guest count, notes + - Pricing: calculated price, breakdown display, recalculate button, override + - Status & Notes: status dropdown with preview, internal notes + +### Changed + +- Plugin.php enhanced with AJAX handlers and component initialization +- Admin JavaScript updated with booking form functionality +- Admin CSS updated with booking and calendar styles +- Asset enqueuing now includes Booking post type screens + +### Security + +- Conflict detection prevents double-booking +- Date validation ensures check-out is after check-in +- Status transition validation prevents invalid state changes +- Nonce verification on availability AJAX requests +- Capability checks on all booking operations + ## [0.2.0] - 2026-01-31 ### Added @@ -124,6 +191,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.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 [0.1.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.1.0 [0.0.1]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.0.1 diff --git a/CLAUDE.md b/CLAUDE.md index 14a861a..244d59a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,10 +207,15 @@ wp-bnb/ ├── src/ # PHP source code (PSR-4: Magdev\WpBnb) │ ├── Plugin.php # Main plugin singleton │ ├── Admin/ # Admin pages +│ │ ├── Calendar.php # Availability calendar page │ │ └── Seasons.php # Seasons management page +│ ├── Booking/ # Booking system +│ │ ├── Availability.php # Availability checking +│ │ └── EmailNotifier.php # Email notifications │ ├── License/ │ │ └── Manager.php # License management │ ├── PostTypes/ # Custom post types +│ │ ├── Booking.php # Booking post type │ │ ├── Building.php # Building post type │ │ └── Room.php # Room post type │ ├── Pricing/ # Pricing system @@ -397,3 +402,69 @@ Admin features always work; frontend requires valid license. - Price modifiers as multipliers are more flexible than percentages - Calculator class separates concerns from post type class - Weekend days stored as comma-separated string in options + +### 2026-01-31 - Version 0.3.0 (Booking System) + +**Completed:** + +- Created `src/PostTypes/Booking.php` custom post type + - Room and guest relationship tracking + - Check-in/check-out date management with validation + - Status workflow (pending, confirmed, checked_in, checked_out, cancelled) + - Auto-generated booking references (BNB-YYYY-NNNNN) + - Four meta boxes: Room & Dates, Guest Info, Pricing, Status & Notes + - Conflict detection prevents double-booking + - Price calculation using existing Calculator class + - Admin columns with room, guest, dates, nights, price, status + - Filters by room and status + - Status badges with color coding +- Created `src/Booking/Availability.php` class + - Real-time availability checking via AJAX + - Conflict detection algorithm + - Calendar data generation for rooms and buildings + - Utility methods for upcoming bookings, today's check-ins/outs +- Created `src/Admin/Calendar.php` admin page + - Monthly calendar view with room/building filters + - Color-coded booking status display + - Month navigation (previous/next/today) + - Click-to-edit booking functionality + - Hover tooltips with booking details + - Legend for status colors +- Created `src/Booking/EmailNotifier.php` class + - Admin notification for new bookings + - Guest confirmation email on booking confirmation + - Cancellation emails to guest and admin + - HTML email templates with inline styles + - Placeholder-based template system + - Filter hooks for customizing emails +- Updated `src/Plugin.php` + - Registered Booking post type + - Initialized Calendar admin page + - Initialized EmailNotifier + - Added AJAX handler for availability checking + - Updated asset enqueuing for Booking screens +- Updated `assets/js/admin.js` + - Booking form with AJAX availability checking + - Real-time nights display + - Price calculation and display + - Status preview update + - Date validation (check-out after check-in) + - Calendar page interactivity +- Updated `assets/css/admin.css` + - Booking info display styles + - Availability status indicators + - Price breakdown styles + - Calendar grid and cell styles + - Legend and filter styles + - Responsive design for calendar +- Updated version to 0.3.0 + +**Learnings:** + +- Booking conflicts use overlap detection: `A.check_in < B.check_out AND A.check_out > B.check_in` +- Excluding cancelled bookings from conflict checks allows rebooking same dates +- Guest info stored in booking meta (Phase 4 will add separate Guest CPT) +- AJAX availability check returns price calculation for immediate feedback +- Calendar displays bookings color-coded by status for quick visual overview +- HTML email templates with inline CSS for better email client compatibility +- Status transitions can trigger different email notifications via hooks diff --git a/PLAN.md b/PLAN.md index 75e62a8..afe53d4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -59,30 +59,30 @@ This document outlines the implementation plan for the WP BnB Management plugin. - [x] Price breakdown display - [x] Discount handling (via seasonal modifiers) -## Phase 3: Booking System (v0.3.0) +## Phase 3: Booking System (v0.3.0) - Complete ### Custom Post Type: Bookings -- [ ] Guest reference -- [ ] Room reference -- [ ] Check-in/check-out dates -- [ ] Status (pending, confirmed, checked-in, checked-out, cancelled) -- [ ] Price calculation and storage -- [ ] Notes field +- [x] Guest reference +- [x] Room reference +- [x] Check-in/check-out dates +- [x] Status (pending, confirmed, checked-in, checked-out, cancelled) +- [x] Price calculation and storage +- [x] Notes field ### Calendar Integration -- [ ] Availability calendar per room -- [ ] Availability calendar per building -- [ ] Date range picker for bookings -- [ ] Conflict detection +- [x] Availability calendar per room +- [x] Availability calendar per building +- [x] Date range picker for bookings +- [x] Conflict detection ### Booking Workflow -- [ ] Booking creation (admin) -- [ ] Status transitions -- [ ] Email notifications -- [ ] Booking confirmation +- [x] Booking creation (admin) +- [x] Status transitions +- [x] Email notifications +- [x] Booking confirmation ## Phase 4: Guest Management (v0.4.0) @@ -290,7 +290,7 @@ The plugin will provide extensive hooks for customization: | 0.0.1 | Initial setup | Complete | | 0.1.0 | Data structures | Complete | | 0.2.0 | Pricing | Complete | -| 0.3.0 | Bookings | TBD | +| 0.3.0 | Bookings | Complete | | 0.4.0 | Guests | TBD | | 0.5.0 | Services | TBD | | 0.6.0 | Frontend | TBD | diff --git a/assets/css/admin.css b/assets/css/admin.css index 8902068..d6f61d3 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -323,3 +323,368 @@ .bnb-season-form input[type="text"].small-text { width: 80px; } + +/* ========================================================================== + Booking System Styles + ========================================================================== */ + +/* Booking Info Display */ +.bnb-booking-info { + padding: 8px 12px; + background: #f6f7f7; + border: 1px solid #c3c4c7; + border-radius: 4px; + font-size: 13px; +} + +.bnb-booking-info.bnb-checking { + color: #646970; + font-style: italic; +} + +.bnb-booking-info.bnb-available { + background: #d4edda; + border-color: #c3e6cb; + color: #155724; +} + +.bnb-booking-info.bnb-not-available { + background: #f8d7da; + border-color: #f5c6cb; + color: #721c24; +} + +.bnb-booking-info .dashicons { + font-size: 16px; + width: 16px; + height: 16px; + vertical-align: text-bottom; + margin-right: 3px; +} + +/* Booking Price Display */ +.bnb-booking-price { + padding: 12px 15px; + background: #f0f6fc; + border: 1px solid #c3c4c7; + border-radius: 4px; + margin-bottom: 10px; +} + +.bnb-booking-price strong { + font-size: 18px; + color: #135e96; +} + +/* Price Breakdown */ +.bnb-booking-breakdown, +.bnb-breakdown-list { + margin: 0; + padding: 0; + list-style: none; +} + +.bnb-breakdown-list li { + padding: 5px 0; + border-bottom: 1px dotted #c3c4c7; +} + +.bnb-breakdown-list li:last-child { + border-bottom: none; + font-weight: 600; +} + +/* Price Override Indicator */ +.bnb-price-override { + color: #dba617; + font-weight: bold; + cursor: help; +} + +/* Status Preview */ +.bnb-status-preview { + margin-top: 10px; +} + +.bnb-status-timestamp { + font-size: 11px; + color: #646970; + margin-top: 5px; +} + +/* Required Field Indicator */ +.required { + color: #d63638; +} + +/* Booking Admin Columns */ +.column-room, +.column-guest, +.column-dates, +.column-nights, +.column-price { + width: 120px; +} + +.column-room small, +.column-guest small, +.column-dates small { + display: block; + color: #646970; + font-size: 11px; +} + +/* ========================================================================== + Calendar Page Styles + ========================================================================== */ + +/* Calendar Container */ +.bnb-calendar-container { + background: #fff; + border: 1px solid #c3c4c7; + border-radius: 4px; + margin-top: 20px; +} + +/* Calendar Header */ +.bnb-calendar-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 20px; + background: #f6f7f7; + border-bottom: 1px solid #c3c4c7; +} + +.bnb-calendar-header h2 { + margin: 0; + font-size: 18px; +} + +.bnb-calendar-nav { + display: flex; + gap: 5px; +} + +/* Calendar Filters */ +.bnb-calendar-filters { + display: flex; + gap: 15px; + padding: 15px 20px; + border-bottom: 1px solid #c3c4c7; + background: #f9f9f9; +} + +.bnb-calendar-filters label { + display: flex; + align-items: center; + gap: 8px; +} + +.bnb-calendar-filters select { + min-width: 200px; +} + +/* Calendar Grid */ +.bnb-calendar-grid { + padding: 20px; + overflow-x: auto; +} + +.bnb-calendar-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.bnb-calendar-table th, +.bnb-calendar-table td { + border: 1px solid #c3c4c7; + text-align: center; + padding: 0; + min-width: 35px; +} + +.bnb-calendar-table th { + background: #f6f7f7; + font-weight: 600; + padding: 8px 4px; + font-size: 11px; +} + +.bnb-calendar-table th.room-header { + text-align: left; + padding-left: 10px; + min-width: 150px; +} + +/* Calendar Day Cell */ +.bnb-calendar-day { + height: 35px; + vertical-align: middle; + position: relative; + cursor: default; +} + +.bnb-calendar-day.past { + background: #f0f0f1; + color: #a7aaad; +} + +.bnb-calendar-day.today { + background: #f0f6fc; + font-weight: 600; +} + +.bnb-calendar-day.today::after { + content: ""; + position: absolute; + bottom: 2px; + left: 50%; + transform: translateX(-50%); + width: 4px; + height: 4px; + background: #2271b1; + border-radius: 50%; +} + +.bnb-calendar-day.available { + background: #d4edda; +} + +.bnb-calendar-day.booked { + background: #d63638; + color: #fff; + cursor: pointer; +} + +.bnb-calendar-day.booked.booking-hover { + background: #a02424; +} + +.bnb-calendar-day.booked-start { + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; +} + +.bnb-calendar-day.booked-end { + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; +} + +/* Booking Status Colors in Calendar */ +.bnb-calendar-day.status-pending { + background: #dba617; +} + +.bnb-calendar-day.status-confirmed { + background: #00a32a; +} + +.bnb-calendar-day.status-checked_in { + background: #72aee6; +} + +/* Room Row in Multi-Room Calendar */ +.bnb-calendar-room { + font-weight: 600; + text-align: left; + padding: 8px 10px; + background: #f6f7f7; +} + +.bnb-calendar-room small { + font-weight: normal; + color: #646970; +} + +/* Calendar Legend */ +.bnb-calendar-legend { + display: flex; + gap: 20px; + padding: 15px 20px; + border-top: 1px solid #c3c4c7; + background: #f6f7f7; +} + +.bnb-calendar-legend-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; +} + +.bnb-calendar-legend-color { + width: 16px; + height: 16px; + border-radius: 3px; + border: 1px solid rgba(0, 0, 0, 0.1); +} + +.bnb-calendar-legend-color.available { + background: #d4edda; +} + +.bnb-calendar-legend-color.booked { + background: #d63638; +} + +.bnb-calendar-legend-color.pending { + background: #dba617; +} + +.bnb-calendar-legend-color.confirmed { + background: #00a32a; +} + +.bnb-calendar-legend-color.checked-in { + background: #72aee6; +} + +/* Tooltip for booking details */ +.bnb-calendar-day[title] { + cursor: help; +} + +/* No Rooms Message */ +.bnb-no-rooms { + padding: 40px; + text-align: center; + color: #646970; +} + +.bnb-no-rooms .dashicons { + font-size: 48px; + width: 48px; + height: 48px; + margin-bottom: 15px; +} + +/* Calendar Single Room View */ +.bnb-calendar-single-room .bnb-calendar-day { + font-size: 12px; +} + +.bnb-calendar-single-room .bnb-calendar-day.booked .guest-name { + font-size: 10px; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Responsive */ +@media screen and (max-width: 782px) { + .bnb-calendar-filters { + flex-direction: column; + } + + .bnb-calendar-filters select { + width: 100%; + } + + .bnb-calendar-header { + flex-direction: column; + gap: 10px; + } +} diff --git a/assets/js/admin.js b/assets/js/admin.js index 6aa6703..dc0ef03 100644 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -277,6 +277,306 @@ }); } + /** + * Initialize booking form functionality. + */ + function initBookingForm() { + var $roomSelect = $('#bnb_booking_room_id'); + var $checkInInput = $('#bnb_booking_check_in'); + var $checkOutInput = $('#bnb_booking_check_out'); + var $nightsDisplay = $('#bnb-booking-nights-display'); + var $availabilityDisplay = $('#bnb-booking-availability-display'); + var $priceDisplay = $('#bnb-booking-price-display'); + var $calculatedPriceInput = $('#bnb_booking_calculated_price'); + var $priceBreakdownInput = $('#bnb_booking_price_breakdown'); + var $breakdownDisplay = $('#bnb-booking-breakdown-display'); + var $recalculateBtn = $('#bnb-recalculate-price'); + var $statusSelect = $('#bnb_booking_status'); + var $statusPreview = $('#bnb-status-preview .bnb-status-badge'); + + // Check if we're on a booking edit page. + if (!$roomSelect.length || !$checkInInput.length) { + return; + } + + // Get current booking ID if editing. + var bookingId = null; + var $postId = $('input[name="post_ID"]'); + if ($postId.length) { + bookingId = parseInt($postId.val(), 10); + } + + // Debounce timer for availability check. + var availabilityTimer = null; + + /** + * Update nights display based on selected dates. + */ + function updateNightsDisplay() { + var checkIn = $checkInInput.val(); + var checkOut = $checkOutInput.val(); + + if (checkIn && checkOut) { + var startDate = new Date(checkIn); + var endDate = new Date(checkOut); + var nights = Math.ceil((endDate - startDate) / (1000 * 60 * 60 * 24)); + + if (nights > 0) { + var nightText = nights === 1 ? wpBnbAdmin.i18n.night : wpBnbAdmin.i18n.nights; + $nightsDisplay.text(nights + ' ' + nightText); + } else { + $nightsDisplay.text(wpBnbAdmin.i18n.error || 'Invalid date range'); + $nightsDisplay.css('color', '#d63638'); + } + } else { + $nightsDisplay.text(wpBnbAdmin.i18n.selectRoomAndDates); + $nightsDisplay.css('color', ''); + } + } + + /** + * Check availability via AJAX. + */ + function checkAvailability() { + var roomId = $roomSelect.val(); + var checkIn = $checkInInput.val(); + var checkOut = $checkOutInput.val(); + + if (!roomId || !checkIn || !checkOut) { + $availabilityDisplay + .text(wpBnbAdmin.i18n.selectRoomAndDates) + .removeClass('bnb-available bnb-not-available') + .addClass('bnb-checking'); + return; + } + + // Validate dates. + var startDate = new Date(checkIn); + var endDate = new Date(checkOut); + if (endDate <= startDate) { + $availabilityDisplay + .text(wpBnbAdmin.i18n.error || 'Check-out must be after check-in') + .removeClass('bnb-available bnb-checking') + .addClass('bnb-not-available'); + return; + } + + // Show checking status. + $availabilityDisplay + .text(wpBnbAdmin.i18n.checking) + .removeClass('bnb-available bnb-not-available') + .addClass('bnb-checking'); + + // Make AJAX request. + $.ajax({ + url: wpBnbAdmin.ajaxUrl, + type: 'POST', + data: { + action: 'wp_bnb_check_availability', + nonce: wpBnbAdmin.nonce, + room_id: roomId, + check_in: checkIn, + check_out: checkOut, + exclude_booking: bookingId + }, + success: function(response) { + if (response.success) { + var data = response.data; + + if (data.available) { + $availabilityDisplay + .html(' ' + wpBnbAdmin.i18n.available) + .removeClass('bnb-not-available bnb-checking') + .addClass('bnb-available'); + + // Update price display. + if (data.price_formatted) { + $priceDisplay.html('' + data.price_formatted + ''); + $calculatedPriceInput.val(data.price); + + if (data.breakdown) { + $priceBreakdownInput.val(JSON.stringify(data.breakdown)); + updateBreakdownDisplay(data.breakdown); + } + } + } else { + var conflictText = wpBnbAdmin.i18n.notAvailable; + if (data.conflicts && data.conflicts.length > 0) { + conflictText += ' (' + data.conflicts[0].reference + ')'; + } + $availabilityDisplay + .html(' ' + conflictText) + .removeClass('bnb-available bnb-checking') + .addClass('bnb-not-available'); + } + + // Update nights display with response data. + if (data.nights) { + var nightText = data.nights === 1 ? wpBnbAdmin.i18n.night : wpBnbAdmin.i18n.nights; + $nightsDisplay.text(data.nights + ' ' + nightText); + } + } else { + $availabilityDisplay + .text(response.data.message || wpBnbAdmin.i18n.error) + .removeClass('bnb-available bnb-checking') + .addClass('bnb-not-available'); + } + }, + error: function() { + $availabilityDisplay + .text(wpBnbAdmin.i18n.error) + .removeClass('bnb-available bnb-checking') + .addClass('bnb-not-available'); + } + }); + } + + /** + * Update breakdown display with formatted data. + * + * @param {Object} breakdown Price breakdown data. + */ + function updateBreakdownDisplay(breakdown) { + if (!$breakdownDisplay.length) { + return; + } + + var html = ''; + + $breakdownDisplay.html(html); + } + + /** + * Format price for display. + * + * @param {number} price Price value. + * @return {string} Formatted price. + */ + function formatPrice(price) { + // Simple formatting - server-side Calculator::formatPrice is more complete. + return parseFloat(price).toFixed(2); + } + + /** + * Debounced availability check. + */ + function debouncedAvailabilityCheck() { + updateNightsDisplay(); + + // Clear existing timer. + if (availabilityTimer) { + clearTimeout(availabilityTimer); + } + + // Set new timer to check availability after 500ms. + availabilityTimer = setTimeout(checkAvailability, 500); + } + + // Bind change events to trigger availability check. + $roomSelect.on('change', debouncedAvailabilityCheck); + $checkInInput.on('change', debouncedAvailabilityCheck); + $checkOutInput.on('change', debouncedAvailabilityCheck); + + // Recalculate price button. + if ($recalculateBtn.length) { + $recalculateBtn.on('click', function(e) { + e.preventDefault(); + checkAvailability(); + }); + } + + // Status preview update. + if ($statusSelect.length && $statusPreview.length) { + $statusSelect.on('change', function() { + var $selected = $(this).find('option:selected'); + var color = $selected.data('color') || '#ccc'; + var text = $selected.text(); + + $statusPreview + .css('background-color', color) + .text(text); + }); + } + + // Set min date for check-in to today. + var today = new Date().toISOString().split('T')[0]; + $checkInInput.attr('min', today); + + // Update check-out min date when check-in changes. + $checkInInput.on('change', function() { + var checkIn = $(this).val(); + if (checkIn) { + var nextDay = new Date(checkIn); + nextDay.setDate(nextDay.getDate() + 1); + var minCheckOut = nextDay.toISOString().split('T')[0]; + $checkOutInput.attr('min', minCheckOut); + + // If check-out is before new min, update it. + if ($checkOutInput.val() && $checkOutInput.val() <= checkIn) { + $checkOutInput.val(minCheckOut); + } + } + }); + } + + /** + * Initialize calendar page functionality. + */ + function initCalendarPage() { + var $calendar = $('.bnb-calendar-grid'); + + if (!$calendar.length) { + return; + } + + // Add hover effect for booking cells. + $calendar.on('mouseenter', '.bnb-calendar-day.booked', function() { + var bookingId = $(this).data('booking-id'); + if (bookingId) { + $calendar.find('.bnb-calendar-day[data-booking-id="' + bookingId + '"]') + .addClass('booking-hover'); + } + }).on('mouseleave', '.bnb-calendar-day.booked', function() { + $calendar.find('.bnb-calendar-day.booking-hover') + .removeClass('booking-hover'); + }); + + // Click to edit booking. + $calendar.on('click', '.bnb-calendar-day.booked', function() { + var bookingId = $(this).data('booking-id'); + if (bookingId) { + window.location.href = wpBnbAdmin.ajaxUrl.replace('admin-ajax.php', 'post.php?post=' + bookingId + '&action=edit'); + } + }); + } + // Initialize on document ready. $(document).ready(function() { initLicenseManagement(); @@ -284,6 +584,8 @@ initPricingSettings(); initSeasonForm(); initPricingMetaBox(); + initBookingForm(); + initCalendarPage(); }); })(jQuery); diff --git a/src/Admin/Calendar.php b/src/Admin/Calendar.php new file mode 100644 index 0000000..9ab0322 --- /dev/null +++ b/src/Admin/Calendar.php @@ -0,0 +1,359 @@ + 12 ) { + $month = (int) gmdate( 'n' ); + } + + // Get buildings and rooms for filters. + $buildings = get_posts( + array( + 'post_type' => Building::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'orderby' => 'title', + 'order' => 'ASC', + ) + ); + + $rooms = get_posts( + array( + 'post_type' => Room::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'orderby' => 'title', + 'order' => 'ASC', + ) + ); + + // If room is selected, get its building. + if ( $room_id && ! $building_id ) { + $building = Room::get_building( $room_id ); + if ( $building ) { + $building_id = $building->ID; + } + } + + // Get rooms to display. + $display_rooms = array(); + if ( $room_id ) { + $room = get_post( $room_id ); + if ( $room ) { + $display_rooms[] = $room; + } + } elseif ( $building_id ) { + $display_rooms = Room::get_rooms_for_building( $building_id ); + } else { + $display_rooms = $rooms; + } + + // Calculate navigation dates. + $prev_month = $month === 1 ? 12 : $month - 1; + $prev_year = $month === 1 ? $year - 1 : $year; + $next_month = $month === 12 ? 1 : $month + 1; + $next_year = $month === 12 ? $year + 1 : $year; + + $month_name = gmdate( 'F', mktime( 0, 0, 0, $month, 1, $year ) ); + + ?> +
+

+ +
+ +
+ +

+
+
+ + +
+
+ + + + + + + +
+
+ + +
+ +
+ +

+ + + +
+ + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + + + + + + + + + + + + + ID, '_bnb_room_room_number', true ); + $booked_dates = Availability::get_booked_dates( $room->ID, $year, $month ); + ?> + + + + + + + + + +
+
+ +
+ + post_title ); ?> + + +
# + +
+ data-booking-id="" + + + title="" + > + + + +
+ 'wp-bnb-calendar', + 'year' => $year, + 'month' => $month, + ); + + if ( $building_id ) { + $args['building_id'] = $building_id; + } + + if ( $room_id ) { + $args['room_id'] = $room_id; + } + + return add_query_arg( $args, admin_url( 'admin.php' ) ); + } +} diff --git a/src/Booking/Availability.php b/src/Booking/Availability.php new file mode 100644 index 0000000..e54455a --- /dev/null +++ b/src/Booking/Availability.php @@ -0,0 +1,444 @@ + Array of dates (Y-m-d) with booking info. + */ + public static function get_booked_dates( int $room_id, int $year, int $month ): array { + $month_start = sprintf( '%04d-%02d-01', $year, $month ); + $month_end = gmdate( 'Y-m-t', strtotime( $month_start ) ); + + $bookings = get_posts( + array( + 'post_type' => Booking::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'meta_query' => array( + 'relation' => 'AND', + array( + 'key' => '_bnb_booking_room_id', + 'value' => $room_id, + ), + array( + 'key' => '_bnb_booking_status', + 'value' => 'cancelled', + 'compare' => '!=', + ), + // Booking overlaps with month. + array( + 'key' => '_bnb_booking_check_in', + 'value' => $month_end, + 'compare' => '<=', + 'type' => 'DATE', + ), + array( + 'key' => '_bnb_booking_check_out', + 'value' => $month_start, + 'compare' => '>=', + 'type' => 'DATE', + ), + ), + ) + ); + + $booked_dates = array(); + + foreach ( $bookings as $booking ) { + $check_in = get_post_meta( $booking->ID, '_bnb_booking_check_in', true ); + $check_out = get_post_meta( $booking->ID, '_bnb_booking_check_out', true ); + $status = get_post_meta( $booking->ID, '_bnb_booking_status', true ); + $guest = get_post_meta( $booking->ID, '_bnb_booking_guest_name', true ); + + // Iterate through each night of the booking. + $current = new \DateTimeImmutable( $check_in ); + $end = new \DateTimeImmutable( $check_out ); + + while ( $current < $end ) { + $date_str = $current->format( 'Y-m-d' ); + + // Only include dates within the requested month. + if ( $current->format( 'Y-m' ) === sprintf( '%04d-%02d', $year, $month ) ) { + $booked_dates[ $date_str ] = array( + 'booking_id' => $booking->ID, + 'reference' => $booking->post_title, + 'guest' => $guest, + 'status' => $status, + 'check_in' => $check_in, + 'check_out' => $check_out, + 'is_start' => $date_str === $check_in, + 'is_end' => $current->modify( '+1 day' )->format( 'Y-m-d' ) === $check_out, + ); + } + + $current = $current->modify( '+1 day' ); + } + } + + return $booked_dates; + } + + /** + * Get calendar data for a room for a specific month. + * + * @param int $room_id Room post ID. + * @param int $year Year. + * @param int $month Month (1-12). + * @return array Calendar data including days and bookings. + */ + public static function get_calendar_data( int $room_id, int $year, int $month ): array { + $month_start = new \DateTimeImmutable( sprintf( '%04d-%02d-01', $year, $month ) ); + $days_in_month = (int) $month_start->format( 't' ); + $first_day_of_week = (int) $month_start->format( 'w' ); // 0 = Sunday. + + $booked_dates = self::get_booked_dates( $room_id, $year, $month ); + $today = gmdate( 'Y-m-d' ); + + $days = array(); + for ( $day = 1; $day <= $days_in_month; $day++ ) { + $date_str = sprintf( '%04d-%02d-%02d', $year, $month, $day ); + $is_booked = isset( $booked_dates[ $date_str ] ); + + $days[ $day ] = array( + 'date' => $date_str, + 'day' => $day, + 'is_booked' => $is_booked, + 'is_past' => $date_str < $today, + 'is_today' => $date_str === $today, + 'booking' => $is_booked ? $booked_dates[ $date_str ] : null, + ); + } + + return array( + 'room_id' => $room_id, + 'year' => $year, + 'month' => $month, + 'month_name' => $month_start->format( 'F' ), + 'days_in_month' => $days_in_month, + 'first_day_of_week' => $first_day_of_week, + 'days' => $days, + 'prev_month' => array( + 'year' => $month === 1 ? $year - 1 : $year, + 'month' => $month === 1 ? 12 : $month - 1, + ), + 'next_month' => array( + 'year' => $month === 12 ? $year + 1 : $year, + 'month' => $month === 12 ? 1 : $month + 1, + ), + ); + } + + /** + * Get availability summary for a building (all rooms). + * + * @param int $building_id Building post ID. + * @param int $year Year. + * @param int $month Month (1-12). + * @return array Availability data for all rooms in the building. + */ + public static function get_building_availability( int $building_id, int $year, int $month ): array { + $rooms = Room::get_rooms_for_building( $building_id ); + $data = array(); + + foreach ( $rooms as $room ) { + $room_number = get_post_meta( $room->ID, '_bnb_room_room_number', true ); + $data[ $room->ID ] = array( + 'room_id' => $room->ID, + 'room_name' => $room->post_title, + 'room_number' => $room_number, + 'calendar' => self::get_calendar_data( $room->ID, $year, $month ), + ); + } + + return $data; + } + + /** + * Get conflicts for a proposed booking. + * + * @param int $room_id Room post ID. + * @param string $check_in Check-in date (Y-m-d). + * @param string $check_out Check-out date (Y-m-d). + * @param int|null $exclude_booking Booking ID to exclude. + * @return array<\WP_Post> Array of conflicting booking posts. + */ + public static function get_conflicts( int $room_id, string $check_in, string $check_out, ?int $exclude_booking = null ): array { + $args = array( + 'post_type' => Booking::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'meta_query' => array( + 'relation' => 'AND', + array( + 'key' => '_bnb_booking_room_id', + 'value' => $room_id, + ), + array( + 'key' => '_bnb_booking_status', + 'value' => 'cancelled', + 'compare' => '!=', + ), + array( + 'key' => '_bnb_booking_check_in', + 'value' => $check_out, + 'compare' => '<', + 'type' => 'DATE', + ), + array( + 'key' => '_bnb_booking_check_out', + 'value' => $check_in, + 'compare' => '>', + 'type' => 'DATE', + ), + ), + ); + + if ( $exclude_booking ) { + $args['post__not_in'] = array( $exclude_booking ); + } + + return get_posts( $args ); + } + + /** + * Get availability check result with pricing. + * + * @param int $room_id Room post ID. + * @param string $check_in Check-in date (Y-m-d). + * @param string $check_out Check-out date (Y-m-d). + * @param int|null $exclude_booking Booking ID to exclude. + * @return array Result with availability, pricing, and conflicts. + */ + public static function check_availability_with_price( int $room_id, string $check_in, string $check_out, ?int $exclude_booking = null ): array { + $conflicts = self::get_conflicts( $room_id, $check_in, $check_out, $exclude_booking ); + $available = empty( $conflicts ); + + $result = array( + 'available' => $available, + 'room_id' => $room_id, + 'check_in' => $check_in, + 'check_out' => $check_out, + 'nights' => Booking::calculate_nights( $check_in, $check_out ), + 'conflicts' => array(), + ); + + if ( ! $available ) { + foreach ( $conflicts as $conflict ) { + $result['conflicts'][] = array( + 'booking_id' => $conflict->ID, + 'reference' => $conflict->post_title, + 'check_in' => get_post_meta( $conflict->ID, '_bnb_booking_check_in', true ), + 'check_out' => get_post_meta( $conflict->ID, '_bnb_booking_check_out', true ), + ); + } + } + + // Calculate price if available. + if ( $available ) { + try { + $calculator = new Calculator( $room_id, $check_in, $check_out ); + $price = $calculator->calculate(); + $breakdown = $calculator->getBreakdown(); + + $result['price'] = $price; + $result['price_formatted'] = Calculator::formatPrice( $price ); + $result['breakdown'] = $breakdown; + } catch ( \Exception $e ) { + $result['price'] = null; + $result['price_formatted'] = null; + $result['price_error'] = $e->getMessage(); + } + } + + return $result; + } + + /** + * Get upcoming bookings for a room. + * + * @param int $room_id Room post ID. + * @param int $limit Maximum number of bookings to return. + * @return array<\WP_Post> Array of upcoming bookings. + */ + public static function get_upcoming_bookings( int $room_id, int $limit = 5 ): array { + $today = gmdate( 'Y-m-d' ); + + return get_posts( + array( + 'post_type' => Booking::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => $limit, + 'meta_query' => array( + 'relation' => 'AND', + array( + 'key' => '_bnb_booking_room_id', + 'value' => $room_id, + ), + array( + 'key' => '_bnb_booking_status', + 'value' => array( 'pending', 'confirmed' ), + 'compare' => 'IN', + ), + array( + 'key' => '_bnb_booking_check_in', + 'value' => $today, + 'compare' => '>=', + 'type' => 'DATE', + ), + ), + 'orderby' => 'meta_value', + 'meta_key' => '_bnb_booking_check_in', + 'order' => 'ASC', + ) + ); + } + + /** + * Get current bookings (guests currently checked in). + * + * @param int|null $room_id Optional room ID to filter by. + * @return array<\WP_Post> Array of current bookings. + */ + public static function get_current_bookings( ?int $room_id = null ): array { + $today = gmdate( 'Y-m-d' ); + + $meta_query = array( + 'relation' => 'AND', + array( + 'key' => '_bnb_booking_status', + 'value' => 'checked_in', + ), + array( + 'key' => '_bnb_booking_check_in', + 'value' => $today, + 'compare' => '<=', + 'type' => 'DATE', + ), + array( + 'key' => '_bnb_booking_check_out', + 'value' => $today, + 'compare' => '>', + 'type' => 'DATE', + ), + ); + + if ( $room_id ) { + $meta_query[] = array( + 'key' => '_bnb_booking_room_id', + 'value' => $room_id, + ); + } + + return get_posts( + array( + 'post_type' => Booking::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'meta_query' => $meta_query, + 'orderby' => 'meta_value', + 'meta_key' => '_bnb_booking_check_out', + 'order' => 'ASC', + ) + ); + } + + /** + * Get today's check-ins. + * + * @return array<\WP_Post> Array of bookings with check-in today. + */ + public static function get_todays_checkins(): array { + $today = gmdate( 'Y-m-d' ); + + return get_posts( + array( + 'post_type' => Booking::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'meta_query' => array( + 'relation' => 'AND', + array( + 'key' => '_bnb_booking_status', + 'value' => 'confirmed', + ), + array( + 'key' => '_bnb_booking_check_in', + 'value' => $today, + 'type' => 'DATE', + ), + ), + 'orderby' => 'meta_value', + 'meta_key' => '_bnb_booking_guest_name', + 'order' => 'ASC', + ) + ); + } + + /** + * Get today's check-outs. + * + * @return array<\WP_Post> Array of bookings with check-out today. + */ + public static function get_todays_checkouts(): array { + $today = gmdate( 'Y-m-d' ); + + return get_posts( + array( + 'post_type' => Booking::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'meta_query' => array( + 'relation' => 'AND', + array( + 'key' => '_bnb_booking_status', + 'value' => 'checked_in', + ), + array( + 'key' => '_bnb_booking_check_out', + 'value' => $today, + 'type' => 'DATE', + ), + ), + 'orderby' => 'meta_value', + 'meta_key' => '_bnb_booking_guest_name', + 'order' => 'ASC', + ) + ); + } +} diff --git a/src/Booking/EmailNotifier.php b/src/Booking/EmailNotifier.php new file mode 100644 index 0000000..9aa571a --- /dev/null +++ b/src/Booking/EmailNotifier.php @@ -0,0 +1,587 @@ + 30 ) { + return; + } + + // Check if we've already sent this notification. + $sent = get_post_meta( $post_id, '_bnb_booking_new_email_sent', true ); + if ( $sent ) { + return; + } + + // Mark as sent before sending to prevent duplicates. + update_post_meta( $post_id, '_bnb_booking_new_email_sent', '1' ); + + // Send admin notification for new booking. + self::send_admin_new_booking( $post_id ); + } + + /** + * Send new booking notification to admin. + * + * @param int $booking_id Booking post ID. + * @return bool Whether email was sent. + */ + public static function send_admin_new_booking( int $booking_id ): bool { + $booking = get_post( $booking_id ); + if ( ! $booking ) { + return false; + } + + $data = self::get_booking_data( $booking_id ); + $to = get_option( 'admin_email' ); + $subject = sprintf( + /* translators: 1: Site name, 2: Booking reference */ + __( '[%1$s] New Booking: %2$s', 'wp-bnb' ), + get_bloginfo( 'name' ), + $data['booking_reference'] + ); + + $message = self::get_email_template( 'admin-new-booking', $data ); + + return self::send_email( $to, $subject, $message ); + } + + /** + * Send confirmation email to guest. + * + * @param int $booking_id Booking post ID. + * @return bool Whether email was sent. + */ + public static function send_guest_confirmation( int $booking_id ): bool { + $data = self::get_booking_data( $booking_id ); + + if ( empty( $data['guest_email'] ) ) { + return false; + } + + $subject = sprintf( + /* translators: 1: Site name, 2: Booking reference */ + __( '[%1$s] Booking Confirmed: %2$s', 'wp-bnb' ), + get_bloginfo( 'name' ), + $data['booking_reference'] + ); + + $message = self::get_email_template( 'booking-confirmed', $data ); + + return self::send_email( $data['guest_email'], $subject, $message ); + } + + /** + * Send confirmation notification to admin. + * + * @param int $booking_id Booking post ID. + * @return bool Whether email was sent. + */ + public static function send_admin_confirmation( int $booking_id ): bool { + $data = self::get_booking_data( $booking_id ); + $to = get_option( 'admin_email' ); + $subject = sprintf( + /* translators: 1: Site name, 2: Booking reference */ + __( '[%1$s] Booking Confirmed: %2$s', 'wp-bnb' ), + get_bloginfo( 'name' ), + $data['booking_reference'] + ); + + $message = self::get_email_template( 'admin-booking-confirmed', $data ); + + return self::send_email( $to, $subject, $message ); + } + + /** + * Send cancellation email. + * + * @param int $booking_id Booking post ID. + * @return bool Whether emails were sent. + */ + public static function send_cancellation( int $booking_id ): bool { + $data = self::get_booking_data( $booking_id ); + $result = true; + + // Send to admin. + $admin_subject = sprintf( + /* translators: 1: Site name, 2: Booking reference */ + __( '[%1$s] Booking Cancelled: %2$s', 'wp-bnb' ), + get_bloginfo( 'name' ), + $data['booking_reference'] + ); + + $admin_message = self::get_email_template( 'admin-booking-cancelled', $data ); + $result = self::send_email( get_option( 'admin_email' ), $admin_subject, $admin_message ) && $result; + + // Send to guest if email exists. + if ( ! empty( $data['guest_email'] ) ) { + $guest_subject = sprintf( + /* translators: 1: Site name, 2: Booking reference */ + __( '[%1$s] Booking Cancelled: %2$s', 'wp-bnb' ), + get_bloginfo( 'name' ), + $data['booking_reference'] + ); + + $guest_message = self::get_email_template( 'booking-cancelled', $data ); + $result = self::send_email( $data['guest_email'], $guest_subject, $guest_message ) && $result; + } + + return $result; + } + + /** + * Get booking data for email templates. + * + * @param int $booking_id Booking post ID. + * @return array Booking data. + */ + private static function get_booking_data( int $booking_id ): array { + $booking = get_post( $booking_id ); + $room = Booking::get_room( $booking_id ); + $building = Booking::get_building( $booking_id ); + + $check_in = get_post_meta( $booking_id, '_bnb_booking_check_in', true ); + $check_out = get_post_meta( $booking_id, '_bnb_booking_check_out', true ); + $status = get_post_meta( $booking_id, '_bnb_booking_status', true ); + $price = get_post_meta( $booking_id, '_bnb_booking_calculated_price', true ); + $adults = get_post_meta( $booking_id, '_bnb_booking_adults', true ); + $children = get_post_meta( $booking_id, '_bnb_booking_children', true ); + + $nights = 0; + if ( $check_in && $check_out ) { + $nights = Booking::calculate_nights( $check_in, $check_out ); + } + + $statuses = Booking::get_booking_statuses(); + + return array( + 'booking_id' => $booking_id, + 'booking_reference' => $booking ? $booking->post_title : '', + 'guest_name' => get_post_meta( $booking_id, '_bnb_booking_guest_name', true ), + 'guest_email' => get_post_meta( $booking_id, '_bnb_booking_guest_email', true ), + 'guest_phone' => get_post_meta( $booking_id, '_bnb_booking_guest_phone', true ), + 'guest_notes' => get_post_meta( $booking_id, '_bnb_booking_guest_notes', true ), + 'adults' => $adults ?: 1, + 'children' => $children ?: 0, + 'room_name' => $room ? $room->post_title : '', + 'room_id' => $room ? $room->ID : 0, + 'building_name' => $building ? $building->post_title : '', + 'building_id' => $building ? $building->ID : 0, + 'check_in_date' => $check_in ? wp_date( get_option( 'date_format' ), strtotime( $check_in ) ) : '', + 'check_out_date' => $check_out ? wp_date( get_option( 'date_format' ), strtotime( $check_out ) ) : '', + 'check_in_raw' => $check_in, + 'check_out_raw' => $check_out, + 'nights' => $nights, + 'total_price' => $price ? Calculator::formatPrice( (float) $price ) : '', + 'status' => $statuses[ $status ] ?? $status, + 'status_raw' => $status, + 'site_name' => get_bloginfo( 'name' ), + 'site_url' => home_url(), + 'admin_email' => get_option( 'admin_email' ), + 'booking_url' => admin_url( 'post.php?post=' . $booking_id . '&action=edit' ), + ); + } + + /** + * Get email template with placeholders replaced. + * + * @param string $template_name Template name. + * @param array $data Template data. + * @return string HTML email content. + */ + private static function get_email_template( string $template_name, array $data ): string { + $template = self::get_template_content( $template_name ); + + // Replace placeholders. + foreach ( $data as $key => $value ) { + $template = str_replace( '{' . $key . '}', (string) $value, $template ); + } + + return $template; + } + + /** + * Get template content. + * + * @param string $template_name Template name. + * @return string Template HTML. + */ + private static function get_template_content( string $template_name ): string { + // Built-in templates. Could be extended to load from files. + $templates = array( + 'admin-new-booking' => self::template_admin_new_booking(), + 'booking-confirmed' => self::template_booking_confirmed(), + 'admin-booking-confirmed' => self::template_admin_booking_confirmed(), + 'booking-cancelled' => self::template_booking_cancelled(), + 'admin-booking-cancelled' => self::template_admin_booking_cancelled(), + ); + + return $templates[ $template_name ] ?? ''; + } + + /** + * Send HTML email. + * + * @param string $to Recipient email. + * @param string $subject Email subject. + * @param string $message HTML message. + * @return bool Whether email was sent. + */ + private static function send_email( string $to, string $subject, string $message ): bool { + $headers = array( + 'Content-Type: text/html; charset=UTF-8', + 'From: ' . get_bloginfo( 'name' ) . ' <' . get_option( 'admin_email' ) . '>', + ); + + /** + * Filter email recipients. + * + * @param string $to Recipient email. + * @param string $subject Email subject. + * @param string $message Email message. + */ + $to = apply_filters( 'wp_bnb_booking_email_recipients', $to, $subject, $message ); + + /** + * Filter email subject. + * + * @param string $subject Email subject. + */ + $subject = apply_filters( 'wp_bnb_booking_email_subject', $subject ); + + /** + * Filter email content. + * + * @param string $message Email message. + */ + $message = apply_filters( 'wp_bnb_booking_email_content', $message ); + + return wp_mail( $to, $subject, $message, $headers ); + } + + /** + * Get base email styles. + * + * @return string CSS styles. + */ + private static function get_email_styles(): string { + return ' + body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; font-size: 14px; line-height: 1.6; color: #333; } + .email-container { max-width: 600px; margin: 0 auto; padding: 20px; } + .email-header { background: #135e96; color: #fff; padding: 20px; text-align: center; } + .email-header h1 { margin: 0; font-size: 24px; } + .email-body { background: #fff; padding: 30px; border: 1px solid #ddd; } + .email-footer { padding: 20px; text-align: center; font-size: 12px; color: #666; } + .booking-details { background: #f9f9f9; padding: 15px; margin: 20px 0; border-left: 4px solid #135e96; } + .booking-details h3 { margin-top: 0; color: #135e96; } + .detail-row { margin: 8px 0; } + .detail-label { font-weight: 600; display: inline-block; min-width: 120px; } + .btn { display: inline-block; padding: 10px 20px; background: #135e96; color: #fff; text-decoration: none; border-radius: 4px; margin-top: 15px; } + .status-badge { display: inline-block; padding: 4px 10px; border-radius: 3px; font-size: 12px; font-weight: 600; text-transform: uppercase; } + .status-pending { background: #dba617; color: #fff; } + .status-confirmed { background: #00a32a; color: #fff; } + .status-cancelled { background: #d63638; color: #fff; } + '; + } + + /** + * Template: Admin new booking notification. + * + * @return string Template HTML. + */ + private static function template_admin_new_booking(): string { + $styles = self::get_email_styles(); + + return << + + + + + + + + + +HTML; + } + + /** + * Template: Guest booking confirmed. + * + * @return string Template HTML. + */ + private static function template_booking_confirmed(): string { + $styles = self::get_email_styles(); + + return << + + + + + + + + + +HTML; + } + + /** + * Template: Admin booking confirmed. + * + * @return string Template HTML. + */ + private static function template_admin_booking_confirmed(): string { + $styles = self::get_email_styles(); + + return << + + + + + + + + + +HTML; + } + + /** + * Template: Guest booking cancelled. + * + * @return string Template HTML. + */ + private static function template_booking_cancelled(): string { + $styles = self::get_email_styles(); + + return << + + + + + + + + + +HTML; + } + + /** + * Template: Admin booking cancelled. + * + * @return string Template HTML. + */ + private static function template_admin_booking_cancelled(): string { + $styles = self::get_email_styles(); + + return << + + + + + + + + + +HTML; + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 323d882..b1e2b07 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -9,8 +9,12 @@ declare( strict_types=1 ); namespace Magdev\WpBnb; +use Magdev\WpBnb\Admin\Calendar as CalendarAdmin; use Magdev\WpBnb\Admin\Seasons as SeasonsAdmin; +use Magdev\WpBnb\Booking\Availability; +use Magdev\WpBnb\Booking\EmailNotifier; use Magdev\WpBnb\License\Manager as LicenseManager; +use Magdev\WpBnb\PostTypes\Booking; use Magdev\WpBnb\PostTypes\Building; use Magdev\WpBnb\PostTypes\Room; use Magdev\WpBnb\Pricing\Season; @@ -87,6 +91,7 @@ final class Plugin { private function register_post_types(): void { Building::init(); Room::init(); + Booking::init(); } /** @@ -132,6 +137,15 @@ final class Plugin { // Initialize seasons admin page. SeasonsAdmin::init(); + + // Initialize calendar admin page. + CalendarAdmin::init(); + + // Initialize email notifier. + EmailNotifier::init(); + + // Register AJAX handlers. + add_action( 'wp_ajax_wp_bnb_check_availability', array( $this, 'ajax_check_availability' ) ); } /** @@ -167,7 +181,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 ), true ); + $is_our_post_type = in_array( $post_type, array( Building::POST_TYPE, Room::POST_TYPE, Booking::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 ) ) { @@ -205,15 +219,22 @@ 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' ), + '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' ), ), ) ); @@ -817,6 +838,43 @@ final class Plugin { settings_errors( 'wp_bnb_settings' ); } + /** + * AJAX handler for checking room availability. + * + * @return void + */ + public function ajax_check_availability(): void { + check_ajax_referer( 'wp_bnb_admin_nonce', 'nonce' ); + + if ( ! current_user_can( 'edit_posts' ) ) { + wp_send_json_error( + array( 'message' => __( 'You do not have permission to perform this action.', 'wp-bnb' ) ) + ); + } + + $room_id = isset( $_POST['room_id'] ) ? absint( $_POST['room_id'] ) : 0; + $check_in = isset( $_POST['check_in'] ) ? sanitize_text_field( wp_unslash( $_POST['check_in'] ) ) : ''; + $check_out = isset( $_POST['check_out'] ) ? sanitize_text_field( wp_unslash( $_POST['check_out'] ) ) : ''; + $exclude = isset( $_POST['exclude_booking'] ) ? absint( $_POST['exclude_booking'] ) : null; + + if ( ! $room_id || ! $check_in || ! $check_out ) { + wp_send_json_error( + array( 'message' => __( 'Missing required parameters.', 'wp-bnb' ) ) + ); + } + + // Validate dates. + if ( strtotime( $check_out ) <= strtotime( $check_in ) ) { + wp_send_json_error( + array( 'message' => __( 'Check-out date must be after check-in date.', 'wp-bnb' ) ) + ); + } + + $result = Availability::check_availability_with_price( $room_id, $check_in, $check_out, $exclude ); + + wp_send_json_success( $result ); + } + /** * Get Twig environment. * diff --git a/src/PostTypes/Booking.php b/src/PostTypes/Booking.php new file mode 100644 index 0000000..2b9ca1a --- /dev/null +++ b/src/PostTypes/Booking.php @@ -0,0 +1,1137 @@ + _x( 'Bookings', 'post type general name', 'wp-bnb' ), + 'singular_name' => _x( 'Booking', 'post type singular name', 'wp-bnb' ), + 'menu_name' => _x( 'Bookings', 'admin menu', 'wp-bnb' ), + 'name_admin_bar' => _x( 'Booking', 'add new on admin bar', 'wp-bnb' ), + 'add_new' => _x( 'Add New', 'booking', 'wp-bnb' ), + 'add_new_item' => __( 'Add New Booking', 'wp-bnb' ), + 'new_item' => __( 'New Booking', 'wp-bnb' ), + 'edit_item' => __( 'Edit Booking', 'wp-bnb' ), + 'view_item' => __( 'View Booking', 'wp-bnb' ), + 'all_items' => __( 'Bookings', 'wp-bnb' ), + 'search_items' => __( 'Search Bookings', 'wp-bnb' ), + 'parent_item_colon' => __( 'Parent Bookings:', 'wp-bnb' ), + 'not_found' => __( 'No bookings found.', 'wp-bnb' ), + 'not_found_in_trash' => __( 'No bookings found in Trash.', 'wp-bnb' ), + 'archives' => __( 'Booking archives', 'wp-bnb' ), + 'insert_into_item' => __( 'Insert into booking', 'wp-bnb' ), + 'uploaded_to_this_item' => __( 'Uploaded to this booking', 'wp-bnb' ), + 'filter_items_list' => __( 'Filter bookings list', 'wp-bnb' ), + 'items_list_navigation' => __( 'Bookings list navigation', 'wp-bnb' ), + 'items_list' => __( 'Bookings 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-calendar-alt', + 'supports' => array( 'revisions' ), + 'show_in_rest' => true, + 'rest_base' => 'bookings', + '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_booking_room_dates', + __( 'Room & Dates', 'wp-bnb' ), + array( self::class, 'render_room_dates_meta_box' ), + self::POST_TYPE, + 'normal', + 'high' + ); + + add_meta_box( + 'bnb_booking_guest', + __( 'Guest Information', 'wp-bnb' ), + array( self::class, 'render_guest_meta_box' ), + self::POST_TYPE, + 'normal', + 'high' + ); + + add_meta_box( + 'bnb_booking_pricing', + __( 'Pricing', 'wp-bnb' ), + array( self::class, 'render_pricing_meta_box' ), + self::POST_TYPE, + 'normal', + 'default' + ); + + add_meta_box( + 'bnb_booking_status', + __( 'Status & Notes', 'wp-bnb' ), + array( self::class, 'render_status_meta_box' ), + self::POST_TYPE, + 'side', + 'high' + ); + } + + /** + * Render room and dates meta box. + * + * @param \WP_Post $post Current post object. + * @return void + */ + public static function render_room_dates_meta_box( \WP_Post $post ): void { + wp_nonce_field( 'bnb_booking_meta', 'bnb_booking_meta_nonce' ); + + $room_id = get_post_meta( $post->ID, self::META_PREFIX . 'room_id', true ); + $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 ); + + $rooms = get_posts( + array( + 'post_type' => Room::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'orderby' => 'title', + 'order' => 'ASC', + ) + ); + + // Group rooms by building. + $rooms_by_building = array(); + foreach ( $rooms as $room ) { + $building = Room::get_building( $room->ID ); + $building_name = $building ? $building->post_title : __( 'No Building', 'wp-bnb' ); + $building_id = $building ? $building->ID : 0; + if ( ! isset( $rooms_by_building[ $building_id ] ) ) { + $rooms_by_building[ $building_id ] = array( + 'name' => $building_name, + 'rooms' => array(), + ); + } + $rooms_by_building[ $building_id ]['rooms'][] = $room; + } + ?> + + + + + + + + + + + + + + + + + + + + + +
+ + + + +

+ ' . esc_html__( 'Add a room', 'wp-bnb' ) . '' + ); + ?> +

+ +
+ + + +
+ + + +
+ + +
+ +
+
+ + +
+ +
+
+ ID, self::META_PREFIX . 'guest_name', true ); + $guest_email = get_post_meta( $post->ID, self::META_PREFIX . 'guest_email', true ); + $guest_phone = get_post_meta( $post->ID, self::META_PREFIX . 'guest_phone', true ); + $adults = get_post_meta( $post->ID, self::META_PREFIX . 'adults', true ); + $children = get_post_meta( $post->ID, self::META_PREFIX . 'children', true ); + $guest_notes = get_post_meta( $post->ID, self::META_PREFIX . 'guest_notes', true ); + ?> + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ + + +
+ + + +
+ + + + +    + + +
+ + + +

+
+ ID, self::META_PREFIX . 'calculated_price', true ); + $price_breakdown = get_post_meta( $post->ID, self::META_PREFIX . 'price_breakdown', true ); + $override_price = get_post_meta( $post->ID, self::META_PREFIX . 'override_price', true ); + $currency = get_option( 'wp_bnb_currency', 'CHF' ); + ?> + + + + + + + + + + + + + + + +
+ + +
+ ' . esc_html( Calculator::formatPrice( (float) $calculated_price ) ) . ''; + } else { + esc_html_e( 'Price will be calculated when room and dates are selected.', 'wp-bnb' ); + } + ?> +
+ + + +
+ + +
+ +
+
+ + + + +

+
+ ID, self::META_PREFIX . 'status', true ) ?: 'pending'; + $notes = get_post_meta( $post->ID, self::META_PREFIX . 'notes', true ); + $confirmed_at = get_post_meta( $post->ID, self::META_PREFIX . 'confirmed_at', true ); + $statuses = self::get_booking_statuses(); + $colors = self::get_status_colors(); + ?> +

+ +

+ +
+ + + +
+ +

+ +

+ + +
+ +

+ +

+ +

+ calculate(); + $breakdown = $calculator->getBreakdown(); + + update_post_meta( $post_id, self::META_PREFIX . 'calculated_price', $price ); + update_post_meta( $post_id, self::META_PREFIX . 'price_breakdown', $breakdown ); + } catch ( \Exception $e ) { + // Keep existing price if calculation fails. + } + } + } + + // Trigger status change action. + if ( $old_status && $status !== $old_status ) { + /** + * Fires when a booking status changes. + * + * @param int $post_id Booking post ID. + * @param string $old_status Previous status. + * @param string $status New status. + */ + do_action( 'wp_bnb_booking_status_changed', $post_id, $old_status, $status ); + } + } + + /** + * 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['room'] = __( 'Room', 'wp-bnb' ); + $new_columns['guest'] = __( 'Guest', 'wp-bnb' ); + $new_columns['dates'] = __( 'Dates', 'wp-bnb' ); + $new_columns['nights'] = __( 'Nights', 'wp-bnb' ); + $new_columns['price'] = __( 'Price', 'wp-bnb' ); + $new_columns['status'] = __( 'Status', 'wp-bnb' ); + } + } + // Remove date column, we have our own dates. + 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 'room': + $room_id = get_post_meta( $post_id, self::META_PREFIX . 'room_id', true ); + if ( $room_id ) { + $room = get_post( $room_id ); + if ( $room ) { + $building = Room::get_building( $room_id ); + printf( + '%s', + esc_url( get_edit_post_link( $room_id ) ), + esc_html( $room->post_title ) + ); + if ( $building ) { + echo '
' . esc_html( $building->post_title ) . ''; + } + } else { + echo '' . esc_html__( 'Room deleted', 'wp-bnb' ) . ''; + } + } else { + echo '—'; + } + break; + + case 'guest': + $guest_name = get_post_meta( $post_id, self::META_PREFIX . 'guest_name', true ); + $guest_email = get_post_meta( $post_id, self::META_PREFIX . 'guest_email', true ); + if ( $guest_name ) { + echo esc_html( $guest_name ); + if ( $guest_email ) { + echo '
' . esc_html( $guest_email ) . ''; + } + } else { + echo '—'; + } + break; + + case 'dates': + $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 ); + if ( $check_in && $check_out ) { + $format = get_option( 'date_format' ); + echo esc_html( wp_date( $format, strtotime( $check_in ) ) ); + echo '
' . esc_html__( 'to', 'wp-bnb' ) . ' ' . esc_html( wp_date( $format, strtotime( $check_out ) ) ) . ''; + } else { + echo '—'; + } + break; + + case 'nights': + $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 ); + if ( $check_in && $check_out ) { + $nights = self::calculate_nights( $check_in, $check_out ); + echo esc_html( $nights ); + } else { + echo '—'; + } + break; + + case 'price': + $price = get_post_meta( $post_id, self::META_PREFIX . 'calculated_price', true ); + if ( $price ) { + echo esc_html( Calculator::formatPrice( (float) $price ) ); + $override = get_post_meta( $post_id, self::META_PREFIX . 'override_price', true ); + if ( $override ) { + echo ' *'; + } + } else { + echo '—'; + } + break; + + case 'status': + $status = get_post_meta( $post_id, self::META_PREFIX . 'status', true ) ?: 'pending'; + $statuses = self::get_booking_statuses(); + $colors = self::get_status_colors(); + ?> + + + + Room::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'orderby' => 'title', + 'order' => 'ASC', + ) + ); + + if ( ! empty( $rooms ) ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter display only. + $selected_room = isset( $_GET['room_id'] ) ? absint( $_GET['room_id'] ) : 0; + ?> + + + + 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['room_id'] ) ) { + $meta_query[] = array( + 'key' => self::META_PREFIX . 'room_id', + 'value' => absint( $_GET['room_id'] ), + ); + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter query only. + if ( ! empty( $_GET['booking_status'] ) ) { + $meta_query[] = array( + 'key' => self::META_PREFIX . 'status', + 'value' => sanitize_text_field( wp_unslash( $_GET['booking_status'] ) ), + ); + } + + if ( ! empty( $meta_query ) ) { + $meta_query['relation'] = 'AND'; + $query->set( 'meta_query', $meta_query ); + } + + // Handle sorting. + $orderby = $query->get( 'orderby' ); + if ( 'check_in' === $orderby ) { + $query->set( 'meta_key', self::META_PREFIX . 'check_in' ); + $query->set( 'orderby', 'meta_value' ); + } elseif ( 'guest_name' === $orderby ) { + $query->set( 'meta_key', self::META_PREFIX . 'guest_name' ); + $query->set( 'orderby', 'meta_value' ); + } 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 __( 'Booking reference (auto-generated)', 'wp-bnb' ); + } + return $placeholder; + } + + /** + * Auto-generate booking reference as title. + * + * @param array $data Post data. + * @param array $postarr Post array. + * @return array + */ + public static function auto_generate_title( array $data, array $postarr ): array { + if ( self::POST_TYPE !== $data['post_type'] ) { + return $data; + } + + // Only generate if title is empty or matches auto-generated pattern. + if ( empty( $data['post_title'] ) || preg_match( '/^BNB-\d{4}-\d{5}$/', $data['post_title'] ) ) { + $data['post_title'] = self::generate_reference(); + } + + return $data; + } + + /** + * Show conflict notice in admin. + * + * @return void + */ + public static function show_conflict_notice(): void { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Notice display only. + if ( isset( $_GET['booking_conflict'] ) && '1' === $_GET['booking_conflict'] ) { + ?> +
+

+ + +

+
+ +
+

+ + +

+
+ + */ + public static function get_booking_statuses(): array { + return array( + 'pending' => __( 'Pending', 'wp-bnb' ), + 'confirmed' => __( 'Confirmed', 'wp-bnb' ), + 'checked_in' => __( 'Checked In', 'wp-bnb' ), + 'checked_out' => __( 'Checked Out', 'wp-bnb' ), + 'cancelled' => __( 'Cancelled', 'wp-bnb' ), + ); + } + + /** + * Get status color codes. + * + * @return array + */ + public static function get_status_colors(): array { + return array( + 'pending' => '#dba617', + 'confirmed' => '#00a32a', + 'checked_in' => '#72aee6', + 'checked_out' => '#646970', + 'cancelled' => '#d63638', + ); + } + + /** + * Get valid status transitions. + * + * @return array> + */ + public static function get_status_transitions(): array { + return array( + 'pending' => array( 'confirmed', 'cancelled' ), + 'confirmed' => array( 'checked_in', 'cancelled' ), + 'checked_in' => array( 'checked_out' ), + 'checked_out' => array(), + 'cancelled' => array( 'pending' ), + ); + } + + /** + * Check if a status transition is valid. + * + * @param string $from Current status. + * @param string $to New status. + * @return bool + */ + public static function can_transition_to( string $from, string $to ): bool { + if ( $from === $to ) { + return true; + } + $transitions = self::get_status_transitions(); + return isset( $transitions[ $from ] ) && in_array( $to, $transitions[ $from ], true ); + } + + /** + * Generate a booking reference number. + * + * @return string + */ + public static function generate_reference(): string { + $year = gmdate( 'Y' ); + $count = wp_count_posts( self::POST_TYPE ); + $total = ( $count->publish ?? 0 ) + ( $count->draft ?? 0 ) + ( $count->pending ?? 0 ) + ( $count->trash ?? 0 ) + 1; + return sprintf( 'BNB-%s-%05d', $year, $total ); + } + + /** + * Calculate number of nights between two dates. + * + * @param string $check_in Check-in date (Y-m-d). + * @param string $check_out Check-out date (Y-m-d). + * @return int + */ + public static function calculate_nights( string $check_in, string $check_out ): int { + $start = new \DateTimeImmutable( $check_in ); + $end = new \DateTimeImmutable( $check_out ); + return max( 1, (int) $start->diff( $end )->days ); + } + + /** + * Check if dates conflict with existing bookings. + * + * @param int $room_id Room post ID. + * @param string $check_in Check-in date (Y-m-d). + * @param string $check_out Check-out date (Y-m-d). + * @param int|null $exclude_id Booking ID to exclude (for editing). + * @return bool + */ + public static function has_conflict( int $room_id, string $check_in, string $check_out, ?int $exclude_id = null ): bool { + $args = array( + 'post_type' => self::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => 1, + 'fields' => 'ids', + 'meta_query' => array( + 'relation' => 'AND', + array( + 'key' => self::META_PREFIX . 'room_id', + 'value' => $room_id, + ), + array( + 'key' => self::META_PREFIX . 'status', + 'value' => 'cancelled', + 'compare' => '!=', + ), + // Overlap detection: existing.check_in < new.check_out AND existing.check_out > new.check_in. + array( + 'key' => self::META_PREFIX . 'check_in', + 'value' => $check_out, + 'compare' => '<', + 'type' => 'DATE', + ), + array( + 'key' => self::META_PREFIX . 'check_out', + 'value' => $check_in, + 'compare' => '>', + 'type' => 'DATE', + ), + ), + ); + + if ( $exclude_id ) { + $args['post__not_in'] = array( $exclude_id ); + } + + $conflicts = get_posts( $args ); + return ! empty( $conflicts ); + } + + /** + * Get room for a booking. + * + * @param int $booking_id Booking post ID. + * @return \WP_Post|null + */ + public static function get_room( int $booking_id ): ?\WP_Post { + $room_id = get_post_meta( $booking_id, self::META_PREFIX . 'room_id', true ); + if ( ! $room_id ) { + return null; + } + return get_post( $room_id ); + } + + /** + * Get building for a booking (through room). + * + * @param int $booking_id Booking post ID. + * @return \WP_Post|null + */ + public static function get_building( int $booking_id ): ?\WP_Post { + $room = self::get_room( $booking_id ); + if ( ! $room ) { + return null; + } + return Room::get_building( $room->ID ); + } + + /** + * Get all bookings for a room. + * + * @param int $room_id Room post ID. + * @param array $args Additional query args. + * @return array<\WP_Post> + */ + public static function get_bookings_for_room( int $room_id, 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 . 'room_id', + 'value' => $room_id, + ), + ), + 'orderby' => 'meta_value', + 'meta_key' => self::META_PREFIX . 'check_in', + 'order' => 'ASC', + ); + + return get_posts( array_merge( $defaults, $args ) ); + } + + /** + * Format price breakdown for display. + * + * @param array $breakdown Price breakdown array. + * @return string HTML output. + */ + private static function format_price_breakdown( array $breakdown ): string { + $output = '
    '; + + if ( isset( $breakdown['tier'] ) ) { + $output .= '
  • ' . esc_html__( 'Pricing Tier:', 'wp-bnb' ) . ' '; + $output .= esc_html( ucfirst( str_replace( '_', ' ', $breakdown['tier'] ) ) ) . '
  • '; + } + + if ( isset( $breakdown['nights'] ) && is_array( $breakdown['nights'] ) ) { + $output .= '
  • ' . esc_html__( 'Nights:', 'wp-bnb' ) . ' ' . count( $breakdown['nights'] ) . '
  • '; + $output .= '
  • ' . esc_html__( 'Nightly Rate:', 'wp-bnb' ) . ' '; + $output .= esc_html( Calculator::formatPrice( (float) ( $breakdown['nightly_rate'] ?? 0 ) ) ) . '
  • '; + } elseif ( isset( $breakdown['weeks'] ) ) { + $output .= '
  • ' . esc_html__( 'Weeks:', 'wp-bnb' ) . ' ' . esc_html( $breakdown['weeks'] ) . '
  • '; + $output .= '
  • ' . esc_html__( 'Weekly Rate:', 'wp-bnb' ) . ' '; + $output .= esc_html( Calculator::formatPrice( (float) ( $breakdown['weekly_rate'] ?? 0 ) ) ) . '
  • '; + } elseif ( isset( $breakdown['months'] ) ) { + $output .= '
  • ' . esc_html__( 'Months:', 'wp-bnb' ) . ' ' . esc_html( $breakdown['months'] ) . '
  • '; + $output .= '
  • ' . esc_html__( 'Monthly Rate:', 'wp-bnb' ) . ' '; + $output .= esc_html( Calculator::formatPrice( (float) ( $breakdown['monthly_rate'] ?? 0 ) ) ) . '
  • '; + } + + if ( isset( $breakdown['total'] ) ) { + $output .= '
  • ' . esc_html__( 'Total:', 'wp-bnb' ) . ' '; + $output .= esc_html( Calculator::formatPrice( (float) $breakdown['total'] ) ) . '
  • '; + } + + $output .= '
'; + + return $output; + } +} diff --git a/wp-bnb.php b/wp-bnb.php index b95b4c5..ab3ab15 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.2.0 + * Version: 0.3.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.2.0' ); +define( 'WP_BNB_VERSION', '0.3.0' ); // Plugin path constants. define( 'WP_BNB_PATH', plugin_dir_path( __FILE__ ) );