diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cfaaa6..e6a298b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,51 @@ 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.7.0] - 2026-02-03 + +### Added + +- Contact Form 7 Integration: + - New `src/Integration/CF7.php` class for CF7 integration + - Custom form tags: `[bnb_building_select]`, `[bnb_room_select]`, `[bnb_date_checkin]`, `[bnb_date_checkout]`, `[bnb_guests]` + - Server-side validation for all custom tags + - Availability checking before form submission + - Automatic booking creation on form submission with 'pending' status + - Guest record creation/linking using existing `find_or_create_guest` pattern + - Price calculation using existing Calculator class + - Email notifications via existing EmailNotifier +- CF7 Frontend Assets: + - `assets/js/cf7-integration.js` for dynamic form behavior + - Building-based room filtering + - Date linking (checkout min = checkin + 1) + - Capacity validation against selected room + - AJAX availability checking with status display + - Dynamic price calculation display + - `assets/css/cf7-integration.css` for form styling + - Availability status indicators (checking/available/unavailable) + - Price display formatting + - Capacity warning styling + - Responsive design with dark mode support +- Custom CF7 Mail Tags: + - `[_bnb_booking_reference]` - Generated booking reference + - `[_bnb_booking_id]` - Booking post ID + - `[_bnb_room_name]` - Selected room title + - `[_bnb_calculated_price]` - Formatted price + - `[_bnb_nights]` - Number of nights +- Form Type Detection: + - Auto-detects booking forms by presence of `[bnb_room_select]`, `[bnb_date_checkin]`, `[bnb_date_checkout]` + - CSS class `wp-bnb-booking-form` for explicit form type declaration + - Inquiry forms use default CF7 email handling without booking creation + +### Changed + +- Plugin.php updated to conditionally initialize CF7 integration when CF7 is active +- Frontend assets now include CF7-specific CSS and JavaScript when CF7 is detected + +### Dependencies + +- Contact Form 7 plugin required for CF7 integration features (optional) + ## [0.6.1] - 2026-02-03 ### Added diff --git a/PLAN.md b/PLAN.md index 361781d..709848f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -149,20 +149,20 @@ This document outlines the implementation plan for the WP BnB Management plugin. - [x] Building rooms widget - [x] Availability calendar widget -## Phase 7: Contact Form 7 Integration (v0.7.0) +## Phase 7: Contact Form 7 Integration (v0.7.0) - Complete ### Booking Request Form -- [ ] Custom CF7 tags for rooms/dates -- [ ] Form validation -- [ ] Booking creation on submission -- [ ] Email notifications +- [x] Custom CF7 tags for rooms/dates +- [x] Form validation +- [x] Booking creation on submission +- [x] Email notifications ### Inquiry Form -- [ ] General inquiry handling -- [ ] Room-specific inquiries -- [ ] Auto-response templates +- [x] General inquiry handling +- [x] Room-specific inquiries +- [x] Auto-response templates (uses default CF7 mail templates) ## Phase 8: Dashboard & Reports (v0.8.0) @@ -306,7 +306,7 @@ The plugin will provide extensive hooks for customization: | 0.4.0 | Guests | Complete | | 0.5.0 | Services | Complete | | 0.6.0 | Frontend | Complete | -| 0.7.0 | CF7 Integration | TBD | +| 0.7.0 | CF7 Integration | Complete | | 0.8.0 | Dashboard | TBD | | 0.9.0 | Prometheus Metrics | TBD | | 0.10.0 | Security Audit | TBD | diff --git a/README.md b/README.md index 2330018..1274208 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,14 @@ WP BnB Management enables WordPress to act as a full management system for B&B h - **Frontend Integration**: Gutenberg blocks, widgets, and shortcodes - **Auto-Updates**: Automatic update checks and installation from license server - **Development Mode**: License bypass for local development environments -- **Contact Form 7 Integration**: Accept booking requests through forms (planned) +- **Contact Form 7 Integration**: Accept booking requests and inquiries through CF7 forms ### Requirements - WordPress 6.0 or higher - PHP 8.3 or higher - Valid license key +- Contact Form 7 (optional, for booking forms) ## Installation @@ -143,6 +144,152 @@ Available sidebar widgets: - **Building Rooms** - List all rooms in a building - **Availability Calendar** - Mini calendar showing booking status +## Contact Form 7 Integration + +The plugin integrates with Contact Form 7 to accept booking requests and inquiries. Custom form tags are provided for room selection, date pickers, and guest counts. + +### Custom Form Tags + +Use these tags in your CF7 forms: + +- `[bnb_building_select name]` - Building dropdown (optional filter for rooms) +- `[bnb_room_select* name]` - Room dropdown with capacity data +- `[bnb_date_checkin* name]` - Check-in date picker +- `[bnb_date_checkout* name]` - Check-out date picker +- `[bnb_guests* name]` - Guest count input + +### Tag Options + +**`[bnb_building_select]`**: + +- `first_as_label:"text"` - Placeholder text (default: "All Locations") + +**`[bnb_room_select]`**: + +- `building_field:"name"` - Link to building field for filtering +- `first_as_label:"text"` - Placeholder text (default: "Select Room") + +**`[bnb_guests]`**: + +- `min:N` - Minimum guests (default: 1) +- `max:N` - Maximum guests (default: 10) +- `default:N` - Default value (default: 1) + +### Example Booking Form + +```txt +
+

Book Your Stay

+ +
+[bnb_building_select building first_as_label:"All Locations"] +
+ +
+[bnb_room_select* room building_field:"building" first_as_label:"Select a Room"] +
+ +
+
+ +[bnb_date_checkin* check_in] +
+
+ +[bnb_date_checkout* check_out] +
+
+ +
+ +
+ +[bnb_guests* guests min:1 max:10 default:2] +
+ +
+ +

Your Information

+ +
+
+ +[text* first_name] +
+
+ +[text* last_name] +
+
+ +
+ +[email* your_email] +
+ +
+ +[tel your_phone] +
+ +
+ +[textarea your_message] +
+ +[submit "Request Booking"] +
+``` + +### Example Inquiry Form + +For room-specific inquiries, add the `wp-bnb-inquiry-form` class: + +```txt +
+

Inquire About This Room

+ +[hidden room default:123] + +
+ +[text* your_name] +
+ +
+ +[email* your_email] +
+ +
+ +[textarea* your_message] +
+ +[submit "Send Inquiry"] +
+``` + +### Form Features + +- **Availability Checking**: Real-time AJAX validation shows room availability +- **Price Display**: Estimated total calculated and displayed automatically +- **Room Filtering**: Rooms filter by building selection +- **Date Validation**: Check-out must be after check-in, no past dates +- **Capacity Validation**: Guest count validated against room capacity +- **Automatic Booking**: Booking record created with "pending" status on submission +- **Guest Linking**: Guest records created or linked by email address + +### Custom Mail Tags + +Use these in your CF7 mail templates: + +- `[_bnb_room_name]` - Room title +- `[_bnb_building_name]` - Building name +- `[_bnb_calculated_price]` - Formatted price +- `[_bnb_nights]` - Number of nights +- `[_bnb_booking_reference]` - Booking reference (after creation) + ## Hooks and Filters Developers can customize behavior using these hooks: diff --git a/assets/css/cf7-integration.css b/assets/css/cf7-integration.css new file mode 100644 index 0000000..1dca1de --- /dev/null +++ b/assets/css/cf7-integration.css @@ -0,0 +1,344 @@ +/** + * WP BnB Contact Form 7 Integration Styles + * + * Styling for CF7 booking forms. + * + * @package Magdev\WpBnb + */ + +/* Custom Properties */ +:root { + --wp-bnb-cf7-primary: #2271b1; + --wp-bnb-cf7-success: #00a32a; + --wp-bnb-cf7-warning: #dba617; + --wp-bnb-cf7-error: #d63638; + --wp-bnb-cf7-text: #1d2327; + --wp-bnb-cf7-text-light: #646970; + --wp-bnb-cf7-border: #c3c4c7; + --wp-bnb-cf7-bg: #f0f0f1; + --wp-bnb-cf7-radius: 4px; + --wp-bnb-cf7-spacing: 1rem; +} + +/* Form Layout */ +.wp-bnb-booking-form, +.wp-bnb-inquiry-form { + max-width: 700px; + margin: 0 auto; +} + +.wp-bnb-booking-form h3, +.wp-bnb-booking-form h4, +.wp-bnb-inquiry-form h3, +.wp-bnb-inquiry-form h4 { + margin-top: 1.5em; + margin-bottom: 0.75em; + padding-bottom: 0.5em; + border-bottom: 1px solid var(--wp-bnb-cf7-border); +} + +.wp-bnb-booking-form h3:first-child, +.wp-bnb-inquiry-form h3:first-child { + margin-top: 0; +} + +/* Form Rows */ +.wp-bnb-form-row { + margin-bottom: var(--wp-bnb-cf7-spacing); +} + +.wp-bnb-form-row-2col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--wp-bnb-cf7-spacing); +} + +@media (max-width: 480px) { + .wp-bnb-form-row-2col { + grid-template-columns: 1fr; + } +} + +/* Form Fields */ +.wp-bnb-form-field { + display: flex; + flex-direction: column; +} + +.wp-bnb-form-field label { + display: block; + margin-bottom: 0.25rem; + font-weight: 600; + font-size: 0.875rem; + color: var(--wp-bnb-cf7-text); +} + +/* Custom CF7 Tags Styling */ +.wp-bnb-building-select, +.wp-bnb-room-select, +.wp-bnb-date-checkin, +.wp-bnb-date-checkout, +.wp-bnb-guests { + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 1rem; + line-height: 1.5; + border: 1px solid var(--wp-bnb-cf7-border); + border-radius: var(--wp-bnb-cf7-radius); + background-color: #fff; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} + +.wp-bnb-building-select:focus, +.wp-bnb-room-select:focus, +.wp-bnb-date-checkin:focus, +.wp-bnb-date-checkout:focus, +.wp-bnb-guests:focus { + outline: none; + border-color: var(--wp-bnb-cf7-primary); + box-shadow: 0 0 0 2px rgba(34, 113, 177, 0.25); +} + +/* Select dropdown */ +.wp-bnb-room-select optgroup { + font-weight: 600; + font-style: normal; + color: var(--wp-bnb-cf7-text); +} + +/* Date inputs */ +.wp-bnb-date-checkin, +.wp-bnb-date-checkout { + cursor: pointer; +} + +/* Number input */ +.wp-bnb-guests { + max-width: 120px; +} + +/* Availability Status */ +.wp-bnb-availability-status { + padding: var(--wp-bnb-cf7-spacing); + margin: var(--wp-bnb-cf7-spacing) 0; + background-color: var(--wp-bnb-cf7-bg); + border-radius: var(--wp-bnb-cf7-radius); + text-align: center; + min-height: 50px; + display: flex; + align-items: center; + justify-content: center; +} + +.wp-bnb-availability-status:empty { + display: none; +} + +.wp-bnb-checking { + color: var(--wp-bnb-cf7-text-light); + font-style: italic; +} + +.wp-bnb-checking::before { + content: ""; + display: inline-block; + width: 16px; + height: 16px; + margin-right: 8px; + border: 2px solid var(--wp-bnb-cf7-border); + border-top-color: var(--wp-bnb-cf7-primary); + border-radius: 50%; + animation: wp-bnb-spin 0.8s linear infinite; + vertical-align: middle; +} + +@keyframes wp-bnb-spin { + to { + transform: rotate(360deg); + } +} + +.wp-bnb-available { + color: var(--wp-bnb-cf7-success); + font-weight: 600; +} + +.wp-bnb-available::before { + content: "\2713"; + display: inline-block; + margin-right: 8px; + font-size: 1.25em; +} + +.wp-bnb-unavailable { + color: var(--wp-bnb-cf7-error); + font-weight: 600; +} + +.wp-bnb-unavailable::before { + content: "\2717"; + display: inline-block; + margin-right: 8px; + font-size: 1.25em; +} + +/* Price Display */ +.wp-bnb-price-display { + padding: var(--wp-bnb-cf7-spacing); + margin: var(--wp-bnb-cf7-spacing) 0; + background-color: #e7f5ea; + border: 1px solid var(--wp-bnb-cf7-success); + border-radius: var(--wp-bnb-cf7-radius); + text-align: center; +} + +.wp-bnb-price-display:empty { + display: none; +} + +.wp-bnb-price-label { + color: var(--wp-bnb-cf7-text-light); + font-size: 0.875rem; +} + +.wp-bnb-price-amount { + font-size: 1.5rem; + font-weight: 700; + color: var(--wp-bnb-cf7-success); + margin: 0 0.5rem; +} + +.wp-bnb-nights { + color: var(--wp-bnb-cf7-text-light); + font-size: 0.875rem; +} + +/* Capacity Warning */ +.wp-bnb-capacity-warning { + display: block; + margin-top: 0.25rem; + padding: 0.25rem 0.5rem; + font-size: 0.8125rem; + color: var(--wp-bnb-cf7-error); + background-color: #fcf0f1; + border-radius: var(--wp-bnb-cf7-radius); +} + +/* Validation Errors */ +.wpcf7-form-control-wrap .wpcf7-not-valid-tip { + color: var(--wp-bnb-cf7-error); + font-size: 0.8125rem; + margin-top: 0.25rem; +} + +.wpcf7-form-control.wpcf7-not-valid { + border-color: var(--wp-bnb-cf7-error); +} + +/* Response Messages */ +.wpcf7 form.sent .wpcf7-response-output { + border-color: var(--wp-bnb-cf7-success); + background-color: #e7f5ea; + color: var(--wp-bnb-cf7-success); +} + +.wpcf7 form.failed .wpcf7-response-output, +.wpcf7 form.aborted .wpcf7-response-output, +.wpcf7 form.spam .wpcf7-response-output, +.wpcf7 form.invalid .wpcf7-response-output { + border-color: var(--wp-bnb-cf7-error); + background-color: #fcf0f1; + color: var(--wp-bnb-cf7-error); +} + +/* Submit Button */ +.wp-bnb-booking-form .wpcf7-submit, +.wp-bnb-inquiry-form .wpcf7-submit { + display: inline-block; + padding: 0.75rem 1.5rem; + font-size: 1rem; + font-weight: 600; + color: #fff; + background-color: var(--wp-bnb-cf7-primary); + border: none; + border-radius: var(--wp-bnb-cf7-radius); + cursor: pointer; + transition: background-color 0.15s ease-in-out; +} + +.wp-bnb-booking-form .wpcf7-submit:hover, +.wp-bnb-inquiry-form .wpcf7-submit:hover { + background-color: #135e96; +} + +.wp-bnb-booking-form .wpcf7-submit:disabled, +.wp-bnb-inquiry-form .wpcf7-submit:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +/* Spinner */ +.wpcf7 .wpcf7-spinner { + margin-left: 0.5rem; +} + +/* Hidden Room Field (for inquiry forms) */ +.wp-bnb-inquiry-form input[type="hidden"] + .wpcf7-form-control-wrap { + display: none; +} + +/* Form Section Headers */ +.wp-bnb-booking-form hr, +.wp-bnb-inquiry-form hr { + margin: 1.5rem 0; + border: none; + border-top: 1px solid var(--wp-bnb-cf7-border); +} + +/* Dark Mode Support */ +@media (prefers-color-scheme: dark) { + :root { + --wp-bnb-cf7-text: #f0f0f1; + --wp-bnb-cf7-text-light: #a7aaad; + --wp-bnb-cf7-border: #50575e; + --wp-bnb-cf7-bg: #2c3338; + } + + .wp-bnb-building-select, + .wp-bnb-room-select, + .wp-bnb-date-checkin, + .wp-bnb-date-checkout, + .wp-bnb-guests { + background-color: #3c434a; + color: var(--wp-bnb-cf7-text); + } + + .wp-bnb-price-display { + background-color: #1a3320; + border-color: var(--wp-bnb-cf7-success); + } + + .wp-bnb-capacity-warning { + background-color: #3c1618; + } + + .wpcf7 form.sent .wpcf7-response-output { + background-color: #1a3320; + } + + .wpcf7 form.failed .wpcf7-response-output, + .wpcf7 form.aborted .wpcf7-response-output, + .wpcf7 form.spam .wpcf7-response-output, + .wpcf7 form.invalid .wpcf7-response-output { + background-color: #3c1618; + } +} + +/* Print Styles */ +@media print { + .wp-bnb-availability-status, + .wp-bnb-price-display, + .wpcf7-submit { + display: none; + } +} diff --git a/assets/js/cf7-integration.js b/assets/js/cf7-integration.js new file mode 100644 index 0000000..e9342b2 --- /dev/null +++ b/assets/js/cf7-integration.js @@ -0,0 +1,375 @@ +/** + * WP BnB Contact Form 7 Integration + * + * Handles dynamic form behavior for booking forms. + * + * @package Magdev\WpBnb + */ +(function() { + 'use strict'; + + const WpBnbCF7 = { + config: window.wpBnbCF7 || {}, + + /** + * Initialize all CF7 integration features. + */ + init: function() { + this.initBuildingRoomFilter(); + this.initDateValidation(); + this.initCapacityValidation(); + this.initAvailabilityCheck(); + this.initPriceDisplay(); + }, + + /** + * Filter rooms dropdown when building is selected. + */ + initBuildingRoomFilter: function() { + document.querySelectorAll('[data-bnb-building-select]').forEach(function(buildingSelect) { + const form = buildingSelect.closest('form'); + const roomSelect = form ? form.querySelector('[data-bnb-room-select]') : null; + + if (!roomSelect) return; + + // Store all options for filtering + const allOptions = Array.from(roomSelect.querySelectorAll('option, optgroup')); + const originalHTML = roomSelect.innerHTML; + + buildingSelect.addEventListener('change', function() { + const selectedBuilding = buildingSelect.value; + + // Show all options if no building selected + if (!selectedBuilding) { + roomSelect.innerHTML = originalHTML; + roomSelect.dispatchEvent(new Event('change')); + return; + } + + // Filter options by building + roomSelect.innerHTML = ''; + + // Add placeholder option + const placeholder = document.createElement('option'); + placeholder.value = ''; + placeholder.textContent = WpBnbCF7.config.i18n?.selectRoom || '-- Select Room --'; + roomSelect.appendChild(placeholder); + + allOptions.forEach(function(el) { + if (el.tagName === 'OPTGROUP') { + // Check if any options in this optgroup match + const matchingOptions = Array.from(el.querySelectorAll('option')).filter(function(opt) { + return opt.dataset.building === selectedBuilding; + }); + + if (matchingOptions.length > 0) { + const clonedGroup = el.cloneNode(false); + matchingOptions.forEach(function(opt) { + clonedGroup.appendChild(opt.cloneNode(true)); + }); + roomSelect.appendChild(clonedGroup); + } + } + }); + + // Trigger change to update dependent fields + roomSelect.dispatchEvent(new Event('change')); + }); + }); + }, + + /** + * Validate and link check-in/check-out dates. + */ + initDateValidation: function() { + document.querySelectorAll('[data-bnb-checkin]').forEach(function(checkinInput) { + const form = checkinInput.closest('form'); + const checkoutInput = form ? form.querySelector('[data-bnb-checkout]') : null; + + if (!checkoutInput) return; + + // Set minimum check-in to today + const today = WpBnbCF7.formatDate(new Date()); + if (!checkinInput.getAttribute('min') || checkinInput.getAttribute('min') < today) { + checkinInput.setAttribute('min', today); + } + + checkinInput.addEventListener('change', function() { + if (checkinInput.value) { + // Set checkout minimum to checkin + 1 day + const minCheckout = new Date(checkinInput.value); + minCheckout.setDate(minCheckout.getDate() + 1); + checkoutInput.setAttribute('min', WpBnbCF7.formatDate(minCheckout)); + + // Clear checkout if it's now invalid + if (checkoutInput.value && checkoutInput.value <= checkinInput.value) { + checkoutInput.value = ''; + } + + // Trigger availability check + WpBnbCF7.triggerAvailabilityCheck(form); + } + }); + + checkoutInput.addEventListener('change', function() { + if (checkoutInput.value && checkinInput.value) { + if (checkoutInput.value <= checkinInput.value) { + alert(WpBnbCF7.config.i18n?.invalidDateRange || 'Check-out must be after check-in'); + checkoutInput.value = ''; + return; + } + + // Trigger availability check + WpBnbCF7.triggerAvailabilityCheck(form); + } + }); + }); + }, + + /** + * Validate guest count against room capacity. + */ + initCapacityValidation: function() { + document.querySelectorAll('[data-bnb-guests]').forEach(function(guestsInput) { + const form = guestsInput.closest('form'); + const roomSelect = form ? form.querySelector('[data-bnb-room-select]') : null; + + if (!roomSelect) return; + + const validateCapacity = function() { + const selectedOption = roomSelect.selectedOptions[0]; + const capacity = parseInt(selectedOption?.dataset.capacity || 99, 10); + const guests = parseInt(guestsInput.value || 0, 10); + + // Update max attribute + guestsInput.setAttribute('max', capacity); + + // Show warning if over capacity + const wrapper = guestsInput.closest('.wpcf7-form-control-wrap'); + let warning = wrapper ? wrapper.querySelector('.wp-bnb-capacity-warning') : null; + + if (guests > capacity) { + if (!warning && wrapper) { + warning = document.createElement('span'); + warning.className = 'wp-bnb-capacity-warning'; + wrapper.appendChild(warning); + } + if (warning) { + warning.textContent = (WpBnbCF7.config.i18n?.capacityExceeded || 'Maximum %d guests for this room').replace('%d', capacity); + } + } else if (warning) { + warning.remove(); + } + }; + + roomSelect.addEventListener('change', validateCapacity); + guestsInput.addEventListener('change', validateCapacity); + guestsInput.addEventListener('input', validateCapacity); + }); + }, + + /** + * Initialize AJAX availability checking. + */ + initAvailabilityCheck: function() { + // Find forms with availability display + document.querySelectorAll('.wp-bnb-availability-status').forEach(function(statusEl) { + const form = statusEl.closest('form'); + if (form) { + form._availabilityStatus = statusEl; + } + }); + }, + + /** + * Trigger availability check for a form. + * + * @param {HTMLFormElement} form Form element. + */ + triggerAvailabilityCheck: function(form) { + const roomSelect = form.querySelector('[data-bnb-room-select]'); + const checkinInput = form.querySelector('[data-bnb-checkin]'); + const checkoutInput = form.querySelector('[data-bnb-checkout]'); + const statusEl = form._availabilityStatus || form.querySelector('.wp-bnb-availability-status'); + const priceEl = form.querySelector('.wp-bnb-price-display'); + + if (!roomSelect || !checkinInput || !checkoutInput) return; + + const roomId = roomSelect.value; + const checkIn = checkinInput.value; + const checkOut = checkoutInput.value; + + if (!roomId || !checkIn || !checkOut) { + if (statusEl) statusEl.innerHTML = ''; + if (priceEl) priceEl.innerHTML = ''; + return; + } + + // Show loading state + if (statusEl) { + statusEl.innerHTML = '' + (WpBnbCF7.config.i18n?.checking || 'Checking availability...') + ''; + } + + // Make AJAX request + WpBnbCF7.ajax('wp_bnb_get_availability', { + room_id: roomId, + check_in: checkIn, + check_out: checkOut + }) + .then(function(response) { + if (statusEl) { + if (response.available) { + let html = '' + (WpBnbCF7.config.i18n?.available || 'Room is available!') + ''; + statusEl.innerHTML = html; + } else { + statusEl.innerHTML = '' + (WpBnbCF7.config.i18n?.unavailable || 'Room is not available for these dates') + ''; + } + } + + // Update price display + if (priceEl && response.available && response.price_formatted) { + priceEl.innerHTML = '' + (WpBnbCF7.config.i18n?.estimatedTotal || 'Estimated Total') + ': ' + + '' + response.price_formatted + ' ' + + '(' + response.nights + ' ' + (WpBnbCF7.config.i18n?.nights || 'nights') + ')'; + } else if (priceEl) { + priceEl.innerHTML = ''; + } + }) + .catch(function(error) { + console.error('Availability check failed:', error); + if (statusEl) { + statusEl.innerHTML = ''; + } + }); + }, + + /** + * Initialize price display updates. + */ + initPriceDisplay: function() { + const self = this; + + document.querySelectorAll('.wp-bnb-price-display').forEach(function(priceEl) { + const form = priceEl.closest('form'); + if (!form) return; + + const updatePrice = self.debounce(function() { + const roomSelect = form.querySelector('[data-bnb-room-select]'); + const checkinInput = form.querySelector('[data-bnb-checkin]'); + const checkoutInput = form.querySelector('[data-bnb-checkout]'); + + if (!roomSelect?.value || !checkinInput?.value || !checkoutInput?.value) { + priceEl.innerHTML = ''; + return; + } + + self.ajax('wp_bnb_calculate_price', { + room_id: roomSelect.value, + check_in: checkinInput.value, + check_out: checkoutInput.value + }) + .then(function(response) { + priceEl.innerHTML = '' + (self.config.i18n?.estimatedTotal || 'Estimated Total') + ': ' + + '' + response.price_formatted + ' ' + + '(' + response.nights + ' ' + (self.config.i18n?.nights || 'nights') + ')'; + }) + .catch(function() { + priceEl.innerHTML = ''; + }); + }, 500); + + // Bind to relevant field changes + form.querySelectorAll('[data-bnb-room-select], [data-bnb-checkin], [data-bnb-checkout]') + .forEach(function(input) { + input.addEventListener('change', updatePrice); + }); + }); + }, + + /** + * Make AJAX request. + * + * @param {string} action AJAX action name. + * @param {object} data Request data. + * @return {Promise} + */ + ajax: function(action, data) { + data = data || {}; + const formData = new FormData(); + formData.append('action', action); + formData.append('nonce', this.config.nonce || ''); + + Object.keys(data).forEach(function(key) { + if (data[key] !== null && data[key] !== undefined) { + formData.append(key, data[key]); + } + }); + + return fetch(this.config.ajaxUrl || '/wp-admin/admin-ajax.php', { + method: 'POST', + body: formData, + credentials: 'same-origin' + }) + .then(function(response) { + return response.json(); + }) + .then(function(responseData) { + if (!responseData.success) { + throw new Error(responseData.data?.message || 'Request failed'); + } + return responseData.data; + }); + }, + + /** + * Format date as YYYY-MM-DD. + * + * @param {Date} date Date object. + * @return {string} + */ + formatDate: function(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return year + '-' + month + '-' + day; + }, + + /** + * Debounce function. + * + * @param {function} func Function to debounce. + * @param {number} wait Milliseconds to wait. + * @return {function} + */ + debounce: function(func, wait) { + let timeout; + return function() { + const context = this; + const args = arguments; + clearTimeout(timeout); + timeout = setTimeout(function() { + func.apply(context, args); + }, wait); + }; + } + }; + + // Initialize on DOM ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function() { + WpBnbCF7.init(); + }); + } else { + WpBnbCF7.init(); + } + + // Re-initialize on CF7 form reset + document.addEventListener('wpcf7reset', function(event) { + setTimeout(function() { + WpBnbCF7.init(); + }, 100); + }); + + // Export to window for external access + window.WpBnbCF7 = WpBnbCF7; +})(); diff --git a/src/Integration/CF7.php b/src/Integration/CF7.php new file mode 100644 index 0000000..c245317 --- /dev/null +++ b/src/Integration/CF7.php @@ -0,0 +1,1087 @@ + true ) + ); + + // Room selector. + wpcf7_add_form_tag( + array( 'bnb_room_select', 'bnb_room_select*' ), + array( self::class, 'render_room_select_tag' ), + array( 'name-attr' => true ) + ); + + // Check-in date. + wpcf7_add_form_tag( + array( 'bnb_date_checkin', 'bnb_date_checkin*' ), + array( self::class, 'render_date_checkin_tag' ), + array( 'name-attr' => true ) + ); + + // Check-out date. + wpcf7_add_form_tag( + array( 'bnb_date_checkout', 'bnb_date_checkout*' ), + array( self::class, 'render_date_checkout_tag' ), + array( 'name-attr' => true ) + ); + + // Guests count. + wpcf7_add_form_tag( + array( 'bnb_guests', 'bnb_guests*' ), + array( self::class, 'render_guests_tag' ), + array( 'name-attr' => true ) + ); + } + + /** + * Render building select tag. + * + * @param \WPCF7_FormTag $tag Form tag object. + * @return string HTML output. + */ + public static function render_building_select_tag( $tag ): string { + if ( empty( $tag->name ) ) { + return ''; + } + + $validation_error = wpcf7_get_validation_error( $tag->name ); + $class = wpcf7_form_controls_class( $tag->type ); + + if ( $validation_error ) { + $class .= ' wpcf7-not-valid'; + } + + $atts = array( + 'class' => trim( $class . ' wp-bnb-building-select' ), + 'id' => $tag->get_id_option(), + 'name' => $tag->name, + 'aria-required' => $tag->is_required() ? 'true' : 'false', + 'aria-invalid' => $validation_error ? 'true' : 'false', + 'data-bnb-building-select' => 'true', + ); + + // Get first_as_label option. + $first_label = __( '-- Select Building --', 'wp-bnb' ); + foreach ( $tag->options as $option ) { + if ( strpos( $option, 'first_as_label:' ) === 0 ) { + $first_label = str_replace( array( 'first_as_label:', '"', "'" ), '', $option ); + break; + } + } + + // Get buildings that have rooms. + $buildings = get_posts( + array( + 'post_type' => Building::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'orderby' => 'title', + 'order' => 'ASC', + ) + ); + + // Build options. + $options = sprintf( '', esc_html( $first_label ) ); + + foreach ( $buildings as $building ) { + $room_count = count( Room::get_rooms_for_building( $building->ID ) ); + if ( $room_count > 0 ) { + $options .= sprintf( + '', + esc_attr( $building->ID ), + esc_html( $building->post_title ) + ); + } + } + + $atts_html = wpcf7_format_atts( $atts ); + + $html = sprintf( '', esc_attr( $tag->name ) ); + $html .= sprintf( '', $atts_html, $options ); + $html .= $validation_error; + $html .= ''; + + return $html; + } + + /** + * Render room select tag. + * + * @param \WPCF7_FormTag $tag Form tag object. + * @return string HTML output. + */ + public static function render_room_select_tag( $tag ): string { + if ( empty( $tag->name ) ) { + return ''; + } + + $validation_error = wpcf7_get_validation_error( $tag->name ); + $class = wpcf7_form_controls_class( $tag->type ); + + if ( $validation_error ) { + $class .= ' wpcf7-not-valid'; + } + + // Parse options. + $building_field = ''; + $include_price = false; + + foreach ( $tag->options as $option ) { + if ( strpos( $option, 'building_field:' ) === 0 ) { + $building_field = str_replace( array( 'building_field:', '"', "'" ), '', $option ); + } + if ( 'include_price:true' === $option || 'include_price' === $option ) { + $include_price = true; + } + } + + $atts = array( + 'class' => trim( $class . ' wp-bnb-room-select' ), + 'id' => $tag->get_id_option(), + 'name' => $tag->name, + 'aria-required' => $tag->is_required() ? 'true' : 'false', + 'aria-invalid' => $validation_error ? 'true' : 'false', + 'data-bnb-room-select' => 'true', + ); + + if ( $building_field ) { + $atts['data-building-field'] = $building_field; + } + + if ( $tag->is_required() ) { + $atts['required'] = 'required'; + } + + // Get all rooms grouped by building. + $rooms = get_posts( + array( + 'post_type' => Room::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'orderby' => 'title', + 'order' => 'ASC', + ) + ); + + $rooms_by_building = array(); + foreach ( $rooms as $room ) { + $building = Room::get_building( $room->ID ); + $building_id = $building ? $building->ID : 0; + + if ( ! isset( $rooms_by_building[ $building_id ] ) ) { + $rooms_by_building[ $building_id ] = array( + 'name' => $building ? $building->post_title : __( 'No Building', 'wp-bnb' ), + 'rooms' => array(), + ); + } + + $rooms_by_building[ $building_id ]['rooms'][] = $room; + } + + // Build options. + $options = sprintf( '', esc_html__( '-- Select Room --', 'wp-bnb' ) ); + $currency = get_option( 'wp_bnb_currency', 'CHF' ); + + foreach ( $rooms_by_building as $building_id => $data ) { + $options .= sprintf( '', esc_attr( $data['name'] ) ); + + foreach ( $data['rooms'] as $room ) { + $capacity = (int) get_post_meta( $room->ID, '_bnb_room_capacity', true ) ?: 2; + $nightly_rate = (float) get_post_meta( $room->ID, '_bnb_room_price_short_term', true ); + $room_status = get_post_meta( $room->ID, '_bnb_room_status', true ) ?: 'available'; + + // Skip rooms that are not available. + if ( 'available' !== $room_status ) { + continue; + } + + $label = $room->post_title; + $label .= sprintf( ' (%d %s)', $capacity, _n( 'guest', 'guests', $capacity, 'wp-bnb' ) ); + + if ( $include_price && $nightly_rate > 0 ) { + $label .= sprintf( ' - %s %s/%s', $currency, number_format( $nightly_rate, 2 ), __( 'night', 'wp-bnb' ) ); + } + + $options .= sprintf( + '', + esc_attr( $room->ID ), + esc_attr( $capacity ), + esc_attr( $building_id ), + esc_attr( $nightly_rate ), + esc_html( $label ) + ); + } + + $options .= ''; + } + + $atts_html = wpcf7_format_atts( $atts ); + + $html = sprintf( '', esc_attr( $tag->name ) ); + $html .= sprintf( '', $atts_html, $options ); + $html .= $validation_error; + $html .= ''; + + return $html; + } + + /** + * Render check-in date tag. + * + * @param \WPCF7_FormTag $tag Form tag object. + * @return string HTML output. + */ + public static function render_date_checkin_tag( $tag ): string { + if ( empty( $tag->name ) ) { + return ''; + } + + $validation_error = wpcf7_get_validation_error( $tag->name ); + $class = wpcf7_form_controls_class( $tag->type, 'wpcf7-date' ); + + if ( $validation_error ) { + $class .= ' wpcf7-not-valid'; + } + + // Parse options. + $min_advance = 0; + $max_advance = 365; + + foreach ( $tag->options as $option ) { + if ( strpos( $option, 'min_advance:' ) === 0 ) { + $min_advance = (int) str_replace( 'min_advance:', '', $option ); + } + if ( strpos( $option, 'max_advance:' ) === 0 ) { + $max_advance = (int) str_replace( 'max_advance:', '', $option ); + } + } + + $min_date = gmdate( 'Y-m-d', strtotime( "+{$min_advance} days" ) ); + $max_date = gmdate( 'Y-m-d', strtotime( "+{$max_advance} days" ) ); + + $atts = array( + 'type' => 'date', + 'class' => trim( $class . ' wp-bnb-date-checkin' ), + 'id' => $tag->get_id_option(), + 'name' => $tag->name, + 'min' => $min_date, + 'max' => $max_date, + 'aria-required' => $tag->is_required() ? 'true' : 'false', + 'aria-invalid' => $validation_error ? 'true' : 'false', + 'data-bnb-checkin' => 'true', + ); + + if ( $tag->is_required() ) { + $atts['required'] = 'required'; + } + + // Handle default value from POST. + if ( isset( $_POST[ $tag->name ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing + $atts['value'] = sanitize_text_field( wp_unslash( $_POST[ $tag->name ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing + } + + $atts_html = wpcf7_format_atts( $atts ); + + $html = sprintf( '', esc_attr( $tag->name ) ); + $html .= sprintf( '', $atts_html ); + $html .= $validation_error; + $html .= ''; + + return $html; + } + + /** + * Render check-out date tag. + * + * @param \WPCF7_FormTag $tag Form tag object. + * @return string HTML output. + */ + public static function render_date_checkout_tag( $tag ): string { + if ( empty( $tag->name ) ) { + return ''; + } + + $validation_error = wpcf7_get_validation_error( $tag->name ); + $class = wpcf7_form_controls_class( $tag->type, 'wpcf7-date' ); + + if ( $validation_error ) { + $class .= ' wpcf7-not-valid'; + } + + // Parse options. + $checkin_field = 'check_in'; + $min_nights = 1; + $max_nights = 365; + + foreach ( $tag->options as $option ) { + if ( strpos( $option, 'checkin_field:' ) === 0 ) { + $checkin_field = str_replace( array( 'checkin_field:', '"', "'" ), '', $option ); + } + if ( strpos( $option, 'min_nights:' ) === 0 ) { + $min_nights = (int) str_replace( 'min_nights:', '', $option ); + } + if ( strpos( $option, 'max_nights:' ) === 0 ) { + $max_nights = (int) str_replace( 'max_nights:', '', $option ); + } + } + + // Default min is tomorrow. + $min_date = gmdate( 'Y-m-d', strtotime( '+1 day' ) ); + $max_date = gmdate( 'Y-m-d', strtotime( '+366 days' ) ); + + $atts = array( + 'type' => 'date', + 'class' => trim( $class . ' wp-bnb-date-checkout' ), + 'id' => $tag->get_id_option(), + 'name' => $tag->name, + 'min' => $min_date, + 'max' => $max_date, + 'aria-required' => $tag->is_required() ? 'true' : 'false', + 'aria-invalid' => $validation_error ? 'true' : 'false', + 'data-bnb-checkout' => 'true', + 'data-checkin-field' => $checkin_field, + 'data-min-nights' => $min_nights, + 'data-max-nights' => $max_nights, + ); + + if ( $tag->is_required() ) { + $atts['required'] = 'required'; + } + + // Handle default value from POST. + if ( isset( $_POST[ $tag->name ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing + $atts['value'] = sanitize_text_field( wp_unslash( $_POST[ $tag->name ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing + } + + $atts_html = wpcf7_format_atts( $atts ); + + $html = sprintf( '', esc_attr( $tag->name ) ); + $html .= sprintf( '', $atts_html ); + $html .= $validation_error; + $html .= ''; + + return $html; + } + + /** + * Render guests count tag. + * + * @param \WPCF7_FormTag $tag Form tag object. + * @return string HTML output. + */ + public static function render_guests_tag( $tag ): string { + if ( empty( $tag->name ) ) { + return ''; + } + + $validation_error = wpcf7_get_validation_error( $tag->name ); + $class = wpcf7_form_controls_class( $tag->type, 'wpcf7-number' ); + + if ( $validation_error ) { + $class .= ' wpcf7-not-valid'; + } + + // Parse options. + $min = 1; + $max = 10; + $default = 1; + $room_field = ''; + + foreach ( $tag->options as $option ) { + if ( strpos( $option, 'min:' ) === 0 ) { + $min = (int) str_replace( 'min:', '', $option ); + } + if ( strpos( $option, 'max:' ) === 0 ) { + $max = (int) str_replace( 'max:', '', $option ); + } + if ( strpos( $option, 'default:' ) === 0 ) { + $default = (int) str_replace( 'default:', '', $option ); + } + if ( strpos( $option, 'room_field:' ) === 0 ) { + $room_field = str_replace( array( 'room_field:', '"', "'" ), '', $option ); + } + } + + $atts = array( + 'type' => 'number', + 'class' => trim( $class . ' wp-bnb-guests' ), + 'id' => $tag->get_id_option(), + 'name' => $tag->name, + 'min' => $min, + 'max' => $max, + 'value' => $default, + 'aria-required' => $tag->is_required() ? 'true' : 'false', + 'aria-invalid' => $validation_error ? 'true' : 'false', + 'data-bnb-guests' => 'true', + ); + + if ( $room_field ) { + $atts['data-room-field'] = $room_field; + } + + if ( $tag->is_required() ) { + $atts['required'] = 'required'; + } + + // Handle default value from POST. + if ( isset( $_POST[ $tag->name ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing + $atts['value'] = absint( $_POST[ $tag->name ] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing + } + + $atts_html = wpcf7_format_atts( $atts ); + + $html = sprintf( '', esc_attr( $tag->name ) ); + $html .= sprintf( '', $atts_html ); + $html .= $validation_error; + $html .= ''; + + return $html; + } + + /** + * Validate room select field. + * + * @param \WPCF7_Validation $result Validation result. + * @param \WPCF7_FormTag $tag Form tag. + * @return \WPCF7_Validation + */ + public static function validate_room_select( $result, $tag ) { + $name = $tag->name; + $room_id = isset( $_POST[ $name ] ) ? absint( $_POST[ $name ] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing + + if ( $tag->is_required() && ! $room_id ) { + $result->invalidate( $tag, __( 'Please select a room.', 'wp-bnb' ) ); + return $result; + } + + if ( $room_id ) { + $room = get_post( $room_id ); + if ( ! $room || Room::POST_TYPE !== $room->post_type ) { + $result->invalidate( $tag, __( 'Invalid room selected.', 'wp-bnb' ) ); + } + } + + return $result; + } + + /** + * Validate check-in date field. + * + * @param \WPCF7_Validation $result Validation result. + * @param \WPCF7_FormTag $tag Form tag. + * @return \WPCF7_Validation + */ + public static function validate_date_checkin( $result, $tag ) { + $name = $tag->name; + $date = isset( $_POST[ $name ] ) ? sanitize_text_field( wp_unslash( $_POST[ $name ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing + + if ( $tag->is_required() && empty( $date ) ) { + $result->invalidate( $tag, __( 'Please select a check-in date.', 'wp-bnb' ) ); + return $result; + } + + if ( $date ) { + // Validate format. + if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) { + $result->invalidate( $tag, __( 'Invalid date format.', 'wp-bnb' ) ); + return $result; + } + + // Validate not in past. + $check_in = strtotime( $date ); + $today = strtotime( 'today' ); + + if ( $check_in < $today ) { + $result->invalidate( $tag, __( 'Check-in date cannot be in the past.', 'wp-bnb' ) ); + } + } + + return $result; + } + + /** + * Validate check-out date field. + * + * @param \WPCF7_Validation $result Validation result. + * @param \WPCF7_FormTag $tag Form tag. + * @return \WPCF7_Validation + */ + public static function validate_date_checkout( $result, $tag ) { + $name = $tag->name; + $check_out = isset( $_POST[ $name ] ) ? sanitize_text_field( wp_unslash( $_POST[ $name ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing + + // Try common check-in field names. + $check_in = ''; + $checkin_fields = array( 'check_in', 'checkin', 'check-in', 'arrival' ); + foreach ( $checkin_fields as $field ) { + if ( isset( $_POST[ $field ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing + $check_in = sanitize_text_field( wp_unslash( $_POST[ $field ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing + break; + } + } + + if ( $tag->is_required() && empty( $check_out ) ) { + $result->invalidate( $tag, __( 'Please select a check-out date.', 'wp-bnb' ) ); + return $result; + } + + if ( $check_out ) { + // Validate format. + if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $check_out ) ) { + $result->invalidate( $tag, __( 'Invalid date format.', 'wp-bnb' ) ); + return $result; + } + + // Validate check-out is after check-in. + if ( $check_in && strtotime( $check_out ) <= strtotime( $check_in ) ) { + $result->invalidate( $tag, __( 'Check-out must be after check-in.', 'wp-bnb' ) ); + } + } + + return $result; + } + + /** + * Validate guests field. + * + * @param \WPCF7_Validation $result Validation result. + * @param \WPCF7_FormTag $tag Form tag. + * @return \WPCF7_Validation + */ + public static function validate_guests( $result, $tag ) { + $name = $tag->name; + $guests = isset( $_POST[ $name ] ) ? absint( $_POST[ $name ] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing + + if ( $tag->is_required() && $guests < 1 ) { + $result->invalidate( $tag, __( 'Please enter number of guests.', 'wp-bnb' ) ); + return $result; + } + + // Try to get room_id for capacity validation. + $room_id = 0; + $room_fields = array( 'room', 'room_id', 'room-id' ); + foreach ( $room_fields as $field ) { + if ( isset( $_POST[ $field ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing + $room_id = absint( $_POST[ $field ] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing + break; + } + } + + // Validate against room capacity if room is selected. + if ( $room_id && $guests > 0 ) { + $capacity = (int) get_post_meta( $room_id, '_bnb_room_capacity', true ); + if ( $capacity > 0 && $guests > $capacity ) { + $result->invalidate( + $tag, + sprintf( + /* translators: %d: Maximum guest capacity */ + __( 'This room has a maximum capacity of %d guests.', 'wp-bnb' ), + $capacity + ) + ); + } + } + + return $result; + } + + /** + * Validate availability before sending mail. + * + * @param \WPCF7_ContactForm $contact_form Contact form object. + * @param bool $abort Whether to abort. + * @param \WPCF7_Submission $submission Submission object. + * @return void + */ + public static function validate_availability_before_mail( $contact_form, &$abort, $submission ): void { + if ( ! self::is_booking_form( $contact_form ) ) { + return; + } + + $posted_data = $submission->get_posted_data(); + + // Get room and dates. + $room_id = self::get_field_value( $posted_data, array( 'room', 'room_id', 'room-id' ), 'int' ); + $check_in = self::get_field_value( $posted_data, array( 'check_in', 'checkin', 'check-in', 'arrival' ) ); + $check_out = self::get_field_value( $posted_data, array( 'check_out', 'checkout', 'check-out', 'departure' ) ); + + if ( ! $room_id || ! $check_in || ! $check_out ) { + return; + } + + // Check availability. + if ( ! Availability::is_available( $room_id, $check_in, $check_out ) ) { + $abort = true; + $submission->set_status( 'validation_failed' ); + $submission->set_response( + __( 'Sorry, this room is not available for the selected dates. Please choose different dates.', 'wp-bnb' ) + ); + } + } + + /** + * Handle mail sent event - create booking. + * + * @param \WPCF7_ContactForm $contact_form Contact form object. + * @return void + */ + public static function on_mail_sent( $contact_form ): void { + if ( ! self::is_booking_form( $contact_form ) ) { + return; + } + + $submission = \WPCF7_Submission::get_instance(); + if ( ! $submission ) { + return; + } + + $posted_data = $submission->get_posted_data(); + + // Extract booking data. + $room_id = self::get_field_value( $posted_data, array( 'room', 'room_id', 'room-id' ), 'int' ); + $check_in = self::get_field_value( $posted_data, array( 'check_in', 'checkin', 'check-in', 'arrival' ) ); + $check_out = self::get_field_value( $posted_data, array( 'check_out', 'checkout', 'check-out', 'departure' ) ); + + if ( ! $room_id || ! $check_in || ! $check_out ) { + return; + } + + // Guest data. + $guest_name = self::get_field_value( $posted_data, array( 'your-name', 'name', 'guest_name', 'guest-name' ) ); + $guest_email = self::get_field_value( $posted_data, array( 'your-email', 'email', 'guest_email', 'guest-email' ) ); + $guest_phone = self::get_field_value( $posted_data, array( 'your-phone', 'phone', 'tel', 'guest_phone', 'guest-phone' ) ); + + // Guest counts. + $guests = self::get_field_value( $posted_data, array( 'guests', 'guest_count', 'guest-count' ), 'int' ); + $adults = self::get_field_value( $posted_data, array( 'adults', 'adult_count' ), 'int' ); + $children = self::get_field_value( $posted_data, array( 'children', 'child_count' ), 'int' ); + + // If single guests field, use it as adults. + if ( $guests && ! $adults ) { + $adults = $guests; + } + if ( ! $adults ) { + $adults = 1; + } + if ( ! $children ) { + $children = 0; + } + + // Notes from message field. + $notes = self::get_field_value( $posted_data, array( 'your-message', 'message', 'notes', 'special_requests', 'special-requests' ) ); + + // Create the booking. + $booking_id = self::create_booking( + array( + 'room_id' => $room_id, + 'check_in' => $check_in, + 'check_out' => $check_out, + 'guest_name' => $guest_name, + 'guest_email' => $guest_email, + 'guest_phone' => $guest_phone, + 'adults' => $adults, + 'children' => $children, + 'notes' => $notes, + 'source' => 'cf7_form_' . $contact_form->id(), + ) + ); + + if ( $booking_id ) { + // Store booking ID for potential use in mail tags. + $submission->add_extra_var( 'bnb_booking_id', (string) $booking_id ); + $submission->add_extra_var( 'bnb_booking_reference', get_the_title( $booking_id ) ); + + /** + * Fires after a booking is created from a CF7 form. + * + * @param int $booking_id Created booking post ID. + * @param \WPCF7_ContactForm $contact_form CF7 form object. + * @param array $posted_data Form submission data. + */ + do_action( 'wp_bnb_cf7_booking_created', $booking_id, $contact_form, $posted_data ); + } + } + + /** + * Create a booking from form data. + * + * @param array $data Booking data. + * @return int|false Booking post ID or false on failure. + */ + private static function create_booking( array $data ) { + // Find or create guest. + $guest_id = null; + if ( ! empty( $data['guest_name'] ) ) { + $guest_id = self::find_or_create_guest( + $data['guest_name'], + $data['guest_email'] ?? '', + $data['guest_phone'] ?? '' + ); + } + + // Calculate price. + $price = 0; + $breakdown = array(); + if ( $data['room_id'] && $data['check_in'] && $data['check_out'] ) { + try { + $calculator = new Calculator( $data['room_id'], $data['check_in'], $data['check_out'] ); + $price = $calculator->calculate(); + $breakdown = $calculator->getBreakdown(); + } catch ( \Exception $e ) { + // Price calculation failed, continue without price. + } + } + + // Generate booking reference. + $reference = Booking::generate_reference(); + + // Create booking post. + $booking_id = wp_insert_post( + array( + 'post_type' => Booking::POST_TYPE, + 'post_status' => 'publish', + 'post_title' => $reference, + ) + ); + + if ( is_wp_error( $booking_id ) || ! $booking_id ) { + return false; + } + + // Save booking meta. + update_post_meta( $booking_id, self::META_PREFIX . 'room_id', $data['room_id'] ); + update_post_meta( $booking_id, self::META_PREFIX . 'check_in', $data['check_in'] ); + update_post_meta( $booking_id, self::META_PREFIX . 'check_out', $data['check_out'] ); + update_post_meta( $booking_id, self::META_PREFIX . 'status', 'pending' ); + update_post_meta( $booking_id, self::META_PREFIX . 'guest_name', $data['guest_name'] ?? '' ); + update_post_meta( $booking_id, self::META_PREFIX . 'guest_email', $data['guest_email'] ?? '' ); + update_post_meta( $booking_id, self::META_PREFIX . 'guest_phone', $data['guest_phone'] ?? '' ); + update_post_meta( $booking_id, self::META_PREFIX . 'adults', $data['adults'] ?? 1 ); + update_post_meta( $booking_id, self::META_PREFIX . 'children', $data['children'] ?? 0 ); + update_post_meta( $booking_id, self::META_PREFIX . 'guest_notes', $data['notes'] ?? '' ); + update_post_meta( $booking_id, self::META_PREFIX . 'calculated_price', $price ); + update_post_meta( $booking_id, self::META_PREFIX . 'price_breakdown', $breakdown ); + update_post_meta( $booking_id, self::META_PREFIX . 'source', $data['source'] ?? 'cf7' ); + + if ( $guest_id ) { + update_post_meta( $booking_id, self::META_PREFIX . 'guest_id', $guest_id ); + } + + // Generate comprehensive title (Guest Name (dates)). + self::update_booking_title( $booking_id, $data ); + + // Trigger the booking status changed action (for email notifications). + do_action( 'wp_bnb_booking_status_changed', $booking_id, '', 'pending' ); + + return $booking_id; + } + + /** + * Update booking title with guest name and dates. + * + * @param int $booking_id Booking post ID. + * @param array $data Booking data. + * @return void + */ + private static function update_booking_title( int $booking_id, array $data ): void { + $guest_name = $data['guest_name'] ?? __( 'Unknown Guest', 'wp-bnb' ); + + // Format dates. + $date_part = ''; + if ( ! empty( $data['check_in'] ) && ! empty( $data['check_out'] ) ) { + $check_in_date = \DateTime::createFromFormat( 'Y-m-d', $data['check_in'] ); + $check_out_date = \DateTime::createFromFormat( 'Y-m-d', $data['check_out'] ); + + if ( $check_in_date && $check_out_date ) { + if ( $check_in_date->format( 'Y' ) === $check_out_date->format( 'Y' ) ) { + $date_part = sprintf( + '%s - %s', + $check_in_date->format( 'd.m' ), + $check_out_date->format( 'd.m.Y' ) + ); + } else { + $date_part = sprintf( + '%s - %s', + $check_in_date->format( 'd.m.Y' ), + $check_out_date->format( 'd.m.Y' ) + ); + } + } + } + + $title = $guest_name; + if ( $date_part ) { + $title .= sprintf( ' (%s)', $date_part ); + } + + // Update the post title directly. + global $wpdb; + $wpdb->update( + $wpdb->posts, + array( 'post_title' => $title ), + array( 'ID' => $booking_id ), + array( '%s' ), + array( '%d' ) + ); + + clean_post_cache( $booking_id ); + } + + /** + * Find an existing guest by email or create a new one. + * + * @param string $name Guest full name. + * @param string $email Guest email. + * @param string $phone Guest phone (optional). + * @return int|null Guest post ID or null on failure. + */ + private static function find_or_create_guest( string $name, string $email, string $phone = '' ): ?int { + if ( empty( $name ) ) { + return null; + } + + // Try to find existing guest by email. + if ( ! empty( $email ) ) { + $existing_guest = Guest::get_by_email( $email ); + if ( $existing_guest ) { + return $existing_guest->ID; + } + } + + // Parse name into first/last name. + $name_parts = explode( ' ', trim( $name ), 2 ); + $first_name = $name_parts[0] ?? ''; + $last_name = $name_parts[1] ?? ''; + + // Create new guest post. + $guest_id = wp_insert_post( + array( + 'post_type' => Guest::POST_TYPE, + 'post_status' => 'publish', + 'post_title' => $name, + ) + ); + + if ( is_wp_error( $guest_id ) || ! $guest_id ) { + return null; + } + + // Save guest meta. + update_post_meta( $guest_id, '_bnb_guest_first_name', $first_name ); + update_post_meta( $guest_id, '_bnb_guest_last_name', $last_name ); + + if ( ! empty( $email ) ) { + update_post_meta( $guest_id, '_bnb_guest_email', $email ); + } + + if ( ! empty( $phone ) ) { + update_post_meta( $guest_id, '_bnb_guest_phone', $phone ); + } + + // Set default status. + update_post_meta( $guest_id, '_bnb_guest_status', 'active' ); + + return $guest_id; + } + + /** + * Check if a contact form is a booking form. + * + * @param \WPCF7_ContactForm $contact_form Contact form object. + * @return bool + */ + private static function is_booking_form( $contact_form ): bool { + // Check for CSS class. + $additional_settings = $contact_form->additional_setting( 'class', false ); + if ( is_array( $additional_settings ) ) { + foreach ( $additional_settings as $setting ) { + if ( strpos( $setting, self::BOOKING_FORM_CLASS ) !== false ) { + return true; + } + } + } + + // Auto-detect by checking for required booking fields. + $form_content = $contact_form->prop( 'form' ); + $has_room = strpos( $form_content, '[bnb_room_select' ) !== false; + $has_checkin = strpos( $form_content, '[bnb_date_checkin' ) !== false; + $has_checkout = strpos( $form_content, '[bnb_date_checkout' ) !== false; + + return $has_room && $has_checkin && $has_checkout; + } + + /** + * Get field value from posted data. + * + * @param array $posted_data Posted form data. + * @param array $field_names Possible field names. + * @param string $type Type cast ('int', 'string'). + * @return mixed + */ + private static function get_field_value( array $posted_data, array $field_names, string $type = 'string' ) { + foreach ( $field_names as $field ) { + if ( isset( $posted_data[ $field ] ) && '' !== $posted_data[ $field ] ) { + $value = is_array( $posted_data[ $field ] ) + ? $posted_data[ $field ][0] + : $posted_data[ $field ]; + + if ( 'int' === $type ) { + return absint( $value ); + } + + return sanitize_text_field( $value ); + } + } + + return 'int' === $type ? 0 : ''; + } + + /** + * Handle custom mail tags. + * + * @param string|null $output Output value. + * @param string $name Tag name. + * @param bool $html Whether HTML is allowed. + * @param array $mail_tag Mail tag data. + * @return string|null + */ + public static function custom_mail_tags( $output, $name, $html, $mail_tag ) { + $submission = \WPCF7_Submission::get_instance(); + if ( ! $submission ) { + return $output; + } + + switch ( $name ) { + case '_bnb_booking_reference': + return $submission->get_extra_var( 'bnb_booking_reference' ) ?: ''; + + case '_bnb_booking_id': + return $submission->get_extra_var( 'bnb_booking_id' ) ?: ''; + + case '_bnb_room_name': + $posted_data = $submission->get_posted_data(); + $room_id = self::get_field_value( $posted_data, array( 'room', 'room_id', 'room-id' ), 'int' ); + if ( $room_id ) { + $room = get_post( $room_id ); + return $room ? $room->post_title : ''; + } + return ''; + + case '_bnb_calculated_price': + $posted_data = $submission->get_posted_data(); + $room_id = self::get_field_value( $posted_data, array( 'room', 'room_id', 'room-id' ), 'int' ); + $check_in = self::get_field_value( $posted_data, array( 'check_in', 'checkin', 'check-in', 'arrival' ) ); + $check_out = self::get_field_value( $posted_data, array( 'check_out', 'checkout', 'check-out', 'departure' ) ); + + if ( $room_id && $check_in && $check_out ) { + try { + $calculator = new Calculator( $room_id, $check_in, $check_out ); + $price = $calculator->calculate(); + return Calculator::formatPrice( $price ); + } catch ( \Exception $e ) { + return ''; + } + } + return ''; + + case '_bnb_nights': + $posted_data = $submission->get_posted_data(); + $check_in = self::get_field_value( $posted_data, array( 'check_in', 'checkin', 'check-in', 'arrival' ) ); + $check_out = self::get_field_value( $posted_data, array( 'check_out', 'checkout', 'check-out', 'departure' ) ); + + if ( $check_in && $check_out ) { + $nights = Booking::calculate_nights( $check_in, $check_out ); + return (string) $nights; + } + return ''; + } + + return $output; + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 144e132..15c29f6 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -16,6 +16,7 @@ use Magdev\WpBnb\Booking\Availability; use Magdev\WpBnb\Booking\EmailNotifier; use Magdev\WpBnb\Frontend\Search; use Magdev\WpBnb\Frontend\Shortcodes; +use Magdev\WpBnb\Integration\CF7; use Magdev\WpBnb\Frontend\Widgets\AvailabilityCalendar; use Magdev\WpBnb\Frontend\Widgets\BuildingRooms; use Magdev\WpBnb\Frontend\Widgets\SimilarRooms; @@ -202,6 +203,11 @@ final class Plugin { // Register widgets. add_action( 'widgets_init', array( $this, 'register_widgets' ) ); + + // Initialize Contact Form 7 integration if CF7 is active. + if ( class_exists( 'WPCF7' ) ) { + CF7::init(); + } } /** @@ -362,6 +368,43 @@ final class Plugin { ), ) ); + + // Load CF7 integration assets if CF7 is active. + if ( class_exists( 'WPCF7' ) ) { + wp_enqueue_style( + 'wp-bnb-cf7', + WP_BNB_URL . 'assets/css/cf7-integration.css', + array( 'contact-form-7' ), + WP_BNB_VERSION + ); + + wp_enqueue_script( + 'wp-bnb-cf7', + WP_BNB_URL . 'assets/js/cf7-integration.js', + array( 'contact-form-7' ), + WP_BNB_VERSION, + true + ); + + wp_localize_script( + 'wp-bnb-cf7', + 'wpBnbCF7', + array( + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'wp_bnb_frontend_nonce' ), + 'i18n' => array( + 'selectRoom' => __( '-- Select Room --', 'wp-bnb' ), + 'checking' => __( 'Checking availability...', 'wp-bnb' ), + 'available' => __( 'Room is available!', 'wp-bnb' ), + 'unavailable' => __( 'Room is not available for these dates', 'wp-bnb' ), + 'invalidDateRange' => __( 'Check-out must be after check-in', 'wp-bnb' ), + 'capacityExceeded' => __( 'Maximum %d guests for this room', 'wp-bnb' ), + 'estimatedTotal' => __( 'Estimated Total', 'wp-bnb' ), + 'nights' => __( 'nights', 'wp-bnb' ), + ), + ) + ); + } } /** diff --git a/wp-bnb.php b/wp-bnb.php index 3b391e9..07d6b3a 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.6.1 + * Version: 0.7.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.6.1' ); +define( 'WP_BNB_VERSION', '0.7.0' ); // Plugin path constants. define( 'WP_BNB_PATH', plugin_dir_path( __FILE__ ) );