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 = '
';
+
+ if (breakdown.tier) {
+ html += '- Pricing Tier: ' + breakdown.tier.replace('_', ' ') + '
';
+ }
+
+ if (breakdown.nights && Array.isArray(breakdown.nights)) {
+ html += '- Nights: ' + breakdown.nights.length + '
';
+ if (breakdown.nightly_rate) {
+ html += '- Nightly Rate: ' + formatPrice(breakdown.nightly_rate) + '
';
+ }
+ } else if (breakdown.weeks) {
+ html += '- Weeks: ' + breakdown.weeks + '
';
+ if (breakdown.weekly_rate) {
+ html += '- Weekly Rate: ' + formatPrice(breakdown.weekly_rate) + '
';
+ }
+ } else if (breakdown.months) {
+ html += '- Months: ' + breakdown.months + '
';
+ if (breakdown.monthly_rate) {
+ html += '- Monthly Rate: ' + formatPrice(breakdown.monthly_rate) + '
';
+ }
+ }
+
+ if (breakdown.total) {
+ html += '- Total: ' + formatPrice(breakdown.total) + '
';
+ }
+
+ 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 <<
+
+
+
+
+
+
+
+
+
+
A new booking has been created and is awaiting confirmation.
+
+
+
Booking Details
+
Reference: {booking_reference}
+
Status: {status}
+
Room: {room_name}
+
Building: {building_name}
+
Check-in: {check_in_date}
+
Check-out: {check_out_date}
+
Nights: {nights}
+
Total: {total_price}
+
+
+
+
Guest Information
+
Name: {guest_name}
+
Email: {guest_email}
+
Phone: {guest_phone}
+
Adults: {adults}
+
Children: {children}
+
+
+
View Booking
+
+
+
+
+
+HTML;
+ }
+
+ /**
+ * Template: Guest booking confirmed.
+ *
+ * @return string Template HTML.
+ */
+ private static function template_booking_confirmed(): string {
+ $styles = self::get_email_styles();
+
+ return <<
+
+
+
+
+
+
+
+
+
+
Dear {guest_name},
+
+
Great news! Your booking has been confirmed. We look forward to welcoming you.
+
+
+
Your Booking Details
+
Confirmation: {booking_reference}
+
Room: {room_name}
+
Location: {building_name}
+
Check-in: {check_in_date}
+
Check-out: {check_out_date}
+
Duration: {nights} nights
+
Guests: {adults} adults, {children} children
+
Total: {total_price}
+
+
+
If you have any questions or need to make changes to your reservation, please contact us at {admin_email}.
+
+
Thank you for choosing us!
+
+
+
+
+
+HTML;
+ }
+
+ /**
+ * Template: Admin booking confirmed.
+ *
+ * @return string Template HTML.
+ */
+ private static function template_admin_booking_confirmed(): string {
+ $styles = self::get_email_styles();
+
+ return <<
+
+
+
+
+
+
+
+
+
+
Booking {booking_reference} has been confirmed.
+
+
+
Booking Summary
+
Guest: {guest_name}
+
Room: {room_name}
+
Dates: {check_in_date} - {check_out_date}
+
Total: {total_price}
+
+
+
View Booking
+
+
+
+
+
+HTML;
+ }
+
+ /**
+ * Template: Guest booking cancelled.
+ *
+ * @return string Template HTML.
+ */
+ private static function template_booking_cancelled(): string {
+ $styles = self::get_email_styles();
+
+ return <<
+
+
+
+
+
+
+
+
+
+
Dear {guest_name},
+
+
We're writing to confirm that your booking has been cancelled.
+
+
+
Cancelled Booking
+
Reference: {booking_reference}
+
Room: {room_name}
+
Dates: {check_in_date} - {check_out_date}
+
+
+
If you have any questions or would like to make a new reservation, please contact us at {admin_email}.
+
+
We hope to welcome you in the future.
+
+
+
+
+
+HTML;
+ }
+
+ /**
+ * Template: Admin booking cancelled.
+ *
+ * @return string Template HTML.
+ */
+ private static function template_admin_booking_cancelled(): string {
+ $styles = self::get_email_styles();
+
+ return <<
+
+
+
+
+
+
+
+
+
+
Booking {booking_reference} has been cancelled.
+
+
+
Cancelled Booking
+
Guest: {guest_name}
+
Email: {guest_email}
+
Room: {room_name}
+
Dates: {check_in_date} - {check_out_date}
+
+
+
View Booking
+
+
+
+
+
+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;
+ }
+ ?>
+
+ 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' );
+ ?>
+
+ 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__ ) );