33 Commits

Author SHA1 Message Date
70d588808e Display calendar filters side by side (v0.11.3)
All checks were successful
Create Release Package / build-release (push) Successful in 1m8s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 23:40:05 +01:00
0bf7f19ac5 Improve calendar room column with building name display (v0.11.2)
All checks were successful
Create Release Package / build-release (push) Successful in 1m1s
- Widen room column to 200px with proper left alignment
- Display building name as second row under room name
- Change table-layout from fixed to auto for flexible columns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 23:31:45 +01:00
7c0016c244 Bump version to 0.11.1
All checks were successful
Create Release Package / build-release (push) Successful in 1m7s
- Add i18n translation files (German Switzerland)
- Update README with WooCommerce documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 23:21:17 +01:00
997541ab48 Add i18n translation files for German (Switzerland)
- Extract 1140 translatable strings to wp-bnb.pot
- Create wp-bnb-de_CH.po with 875 (77%) translated strings
- Compile wp-bnb-de_CH.mo for WordPress to use
- Coverage includes: admin UI, post types, taxonomies, settings,
  dashboard, reports, REST API messages, WooCommerce integration,
  CF7 integration, frontend widgets, blocks, and shortcodes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 23:19:34 +01:00
36a69b0de4 Update README with WooCommerce integration documentation
- Add WooCommerce Integration and REST API to key features
- Add WooCommerce 8.0+ to optional requirements
- Add comprehensive WooCommerce Integration section with:
  - Enabling instructions
  - Product sync, cart/checkout, booking creation features
  - Order-booking synchronization details
  - PDF invoice documentation
  - Settings subtabs description
  - HPOS compatibility note
  - Admin columns info
- Update FAQ to reflect WooCommerce is now implemented

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 23:01:45 +01:00
5d24cfa6f9 Update CLAUDE.md with v0.11.0 session history
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 22:53:54 +01:00
2865956c56 Add WooCommerce integration for payments, invoices, and order management (v0.11.0)
All checks were successful
Create Release Package / build-release (push) Successful in 1m11s
- Product sync: Virtual WC products for rooms with bidirectional linking
- Cart/Checkout: Booking data in cart items, availability validation, dynamic pricing
- Orders: Automatic booking creation on payment, status mapping, guest record creation
- Invoices: PDF generation via mPDF, auto-attach to emails, configurable numbering
- Refunds: Full refund cancels booking, partial refund records amount only
- Admin: Cross-linked columns and row actions between bookings and orders
- Settings: WooCommerce tab with subtabs (General, Products, Orders, Invoices)
- HPOS compatibility declared for High-Performance Order Storage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 22:40:36 +01:00
965060cc03 Update directory structure in CLAUDE.md and PLAN.md
- Added Api/ directory with all REST API controllers
- Added Admin/Dashboard.php and Admin/Reports.php
- Added Integration/Prometheus.php
- Added License/Updater.php
- Added PostTypes/Guest.php and PostTypes/Service.php
- Added Privacy/Manager.php
- Added Taxonomies/ServiceCategory.php
- Added assets/grafana/ directory

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 22:04:42 +01:00
0e55fae7f2 Update CLAUDE.md with v0.10.0/v0.10.1 session history
- Added comprehensive session entry for REST API Endpoints phase
- Documented all created API files and controllers
- Listed key learnings about WordPress REST API patterns
- Updated with release tags and commit references

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 22:01:32 +01:00
1b6a5a4897 Reorganize roadmap: add Phase 10 API Endpoints, renumber Security Audit
- Phase 10 (API Endpoints v0.10.0) marked complete
- WooCommerce Integration moved from Future to Phase 11 (v0.11.0)
- Security Audit renumbered to Phase 12 (v0.12.0)
- Updated version milestones table

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:59:18 +01:00
3f5adfb04e Bump version to 0.10.1 for configurable rate limiting release
All checks were successful
Create Release Package / build-release (push) Successful in 1m4s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:52:00 +01:00
b701d127f8 Add configurable API rate limits with subtabs in settings (v0.10.0)
- Make rate limiting configurable via WordPress options
- Add subtabs to API settings: General, Rate Limits, Endpoints
- Add HTTP method badges for endpoint documentation
- Update CHANGELOG with rate limiting configuration details

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:50:41 +01:00
481495805b added MARKETING.md 2026-02-03 21:36:59 +01:00
81c97c31d7 Implement Phase 10: REST API Endpoints (v0.10.0)
All checks were successful
Create Release Package / build-release (push) Successful in 1m10s
- Add complete REST API infrastructure under src/Api/
- ResponseFormatter for standardized responses
- RateLimiter with tiered limits (public 60/min, availability 30/min, booking 10/min, admin 120/min)
- AbstractController base class with common functionality
- BuildingsController: list, get, rooms endpoints
- RoomsController: list, get, availability, calendar, search endpoints
- BookingsController: CRUD + confirm/check-in/check-out status transitions
- GuestsController: list, get, search, booking history (admin only)
- ServicesController: list, get, calculate endpoints
- PricingController: calculate, seasons endpoints
- API settings tab with enable/disable toggles
- Comprehensive API documentation in README

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:24:40 +01:00
87aa89b1a6 Reorganize roadmap: add Phase 10 API Endpoints, renumber Security Audit
- API Endpoints promoted from Future Considerations to Phase 10 (v0.10.0)
- Security Audit moved from Phase 10 to Phase 11 (v0.11.0)
- Updated version milestones table accordingly

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 20:56:39 +01:00
13fd25f84c Implement Phase 9: Prometheus Metrics (v0.9.0)
All checks were successful
Create Release Package / build-release (push) Successful in 1m8s
- Add Prometheus metrics integration via wp-prometheus hooks
- Create Grafana dashboard JSON with 24 panels
- Add metrics settings tab with enable/disable toggle
- Expose inventory, booking, occupancy, revenue, and guest metrics

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 20:40:54 +01:00
b6d7eeb5ec Add detailed Dashboard and Reports documentation to README (v0.8.0)
All checks were successful
Create Release Package / build-release (push) Successful in 1m9s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 20:23:02 +01:00
b137fec4fb Implement Phase 8: Dashboard & Reports (v0.8.0)
Some checks failed
Create Release Package / build-release (push) Has been cancelled
- Add comprehensive admin dashboard with stat cards and widgets
- Add Chart.js for occupancy/revenue trend charts
- Add Reports page with Occupancy, Revenue, Guest tabs
- Add CSV and PDF export functionality (using mPDF)
- Add date range filters for reports

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 20:20:27 +01:00
992d961066 Fix CF7 tag generator buttons not appearing in admin (v0.7.2)
All checks were successful
Create Release Package / build-release (push) Successful in 56s
Moved CF7 initialization from init_frontend() to init_components()
so tag generators register in admin context via wpcf7_admin_init hook.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 17:18:46 +01:00
be6d9d68b5 Update CLAUDE.md with v0.7.1 session history
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 17:11:10 +01:00
a784d92cc9 Add CF7 tag generator buttons for admin form editor (v0.7.1)
All checks were successful
Create Release Package / build-release (push) Successful in 59s
- Register tag generators via wpcf7_admin_init hook
- Add BnB Building select tag generator with first_as_label option
- Add BnB Room select tag generator with building_field and include_price options
- Add BnB Check-in date tag generator with min/max advance options
- Add BnB Check-out date tag generator with checkin_field and min/max nights options
- Add BnB Guests count tag generator with room_field and min/max/default options
- All generators support id and class attribute configuration
- Remove bug from Known Bugs section in CLAUDE.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 17:00:26 +01:00
f61dca5f45 Update CLAUDE.md with v0.7.0 session history
Document CF7 integration implementation details:
- Directory structure updated with Integration folder
- New CF7 assets (JS, CSS) documented
- Session history with learnings and patterns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 16:34:00 +01:00
28350aabfa Implement Phase 7: Contact Form 7 Integration (v0.7.0)
All checks were successful
Create Release Package / build-release (push) Successful in 1m1s
Add custom CF7 form tags for booking requests:
- [bnb_building_select] - Building filter dropdown
- [bnb_room_select] - Room selection with capacity data
- [bnb_date_checkin/checkout] - Date pickers with validation
- [bnb_guests] - Guest count with capacity limits

Features:
- Server-side validation for all fields
- Real-time AJAX availability checking
- Automatic price calculation display
- Booking creation on form submission
- Guest record creation/linking
- Custom mail tags for CF7 templates

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 16:30:30 +01:00
3579904bad Add Phase 10: Security Audit to roadmap
- Added security audit phase (v0.10.0) to PLAN.md
- WordPress best practices review
- OWASP Top 10 review (XSS, XSRF, SQLi, etc.)
- Updated version milestones table

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 16:10:19 +01:00
602549208f Fix MD012 linting warning in CLAUDE.md
Remove duplicate blank line between session history sections.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 16:03:40 +01:00
45a73e15aa Add Phase 9: Prometheus Metrics to roadmap
- Added Prometheus metrics integration phase (v0.9.0) to PLAN.md
- Includes meaningful metrics for plugin, example Grafana dashboard
- Settings page option to enable/disable metrics
- Links to wp-prometheus README for implementation details
- Updated version milestones table

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 16:02:17 +01:00
13ba264431 Release v0.6.1 - Bug fixes and enhancements
All checks were successful
Create Release Package / build-release (push) Successful in 1m1s
New Features:
- Auto-update system with configurable check frequency
- Updates tab in settings with manual check button
- Localhost development mode bypasses license validation
- Extended general settings (address, contact, social media)
- Pricing settings split into subtabs
- Guest ID/passport encryption using AES-256-CBC
- Guest auto-creation from booking form

Bug Fixes:
- Fixed Booking admin issues with auto-draft status
- Fixed guest dropdown loading in booking form
- Fixed booking history display on Guest edit page
- Fixed service pricing meta box (Gutenberg hiding meta boxes)

Changes:
- Admin submenu reordered for better organization
- Booking title shows guest name and dates (room removed)
- Service, Guest, Booking use classic editor (not Gutenberg)
- Settings tabs flush with content (no gap)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 15:18:27 +01:00
c17dd53c5a Update CLAUDE.md with session history and learnings
- Document all bug fixes and enhancements from 2026-02-03 session
- Add learnings about Gutenberg vs classic editor for form-based post types
- Document encryption implementation for guest data
- Add notes on auto-draft handling and type safety

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 15:15:16 +01:00
be2735a3bd Update CLAUDE.md with v0.6.0 session history and directory structure
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 14:12:22 +01:00
864b8b2869 Add frontend features with search, shortcodes, widgets, and blocks (v0.6.0)
All checks were successful
Create Release Package / build-release (push) Successful in 1m20s
- Room search with availability, capacity, room type, amenity, price range, and building filters
- AJAX-powered search with pagination and load more
- Shortcodes: [bnb_buildings], [bnb_rooms], [bnb_room_search], [bnb_building], [bnb_room]
- Widgets: Similar Rooms, Building Rooms, Availability Calendar
- Gutenberg blocks: Building, Room, Room Search, Buildings List, Rooms List
- Frontend CSS with responsive design and CSS custom properties
- Frontend JavaScript with SearchForm, CalendarWidget, AvailabilityForm, PriceCalculator

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 14:08:11 +01:00
05f24fdec7 Add additional services system (v0.5.0)
All checks were successful
Create Release Package / build-release (push) Successful in 1m0s
- Service CPT with pricing types: Included, Per Booking, Per Night
- ServiceCategory taxonomy with default categories
- Booking-services integration with service selector
- Real-time price calculation based on nights and quantity
- Services total and grand total display in booking admin

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 15:19:56 +01:00
aab3a4d1aa Add guest management and GDPR privacy compliance (v0.4.0)
All checks were successful
Create Release Package / build-release (push) Successful in 1m26s
- Create Guest CPT with personal info, address, ID/passport tracking
- Add guest-booking integration with AJAX search and linking
- Implement GDPR compliance via WordPress Privacy API (export/erasure)
- Update EmailNotifier to use Guest CPT data with new placeholders
- Add CSS styles for guest search, linked display, and privacy UI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 14:59:43 +01:00
c66af8e299 Update CLAUDE.md with explicit release workflow and session learnings
- Added CRITICAL Release Workflow section with complete git commands
- Documented v0.3.0 release details (commit hash, tag, branches pushed)
- Added learnings about git fast-forward merge workflow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 14:41:43 +01:00
60 changed files with 41920 additions and 410 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ wp-plugins
wp-core
vendor/
releases/*
MARKETING.md

View File

@@ -5,6 +5,548 @@ 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.11.3] - 2026-02-03
### Changed
- Calendar filters now display side by side instead of stacked rows
## [0.11.2] - 2026-02-03
### Changed
- Calendar page room column now wider (200px) with proper left alignment
- Room column displays building name on second row for better identification
- Changed calendar table layout from fixed to auto for flexible column widths
## [0.11.1] - 2026-02-03
### Added
- Internationalization (i18n) support:
- Translation template file `languages/wp-bnb.pot` with 1,140 translatable strings
- German (Switzerland) translation `languages/wp-bnb-de_CH.po` with 77% coverage (875 strings)
- Compiled binary `languages/wp-bnb-de_CH.mo` for WordPress use
- Coverage includes: admin UI, post types, taxonomies, settings, dashboard, reports, REST API, WooCommerce, CF7, widgets, blocks, shortcodes
### Changed
- README.md updated with comprehensive WooCommerce integration documentation
- Added REST API to key features list in README
## [0.11.0] - 2026-02-03
### Added
- WooCommerce Integration System:
- New `src/Integration/WooCommerce/` directory with complete integration
- `Manager.php` - Core integration manager with HPOS compatibility declaration
- `ProductSync.php` - Room-to-WooCommerce-product synchronization
- `CartHandler.php` - Cart item data, availability validation, dynamic pricing
- `CheckoutHandler.php` - Checkout field customization and pre-fill
- `OrderHandler.php` - Booking creation on payment completion
- `InvoiceGenerator.php` - PDF invoice generation using mPDF
- `RefundHandler.php` - Booking cancellation on full refund
- `AdminColumns.php` - Admin list cross-links between bookings and orders
- Product Synchronization:
- Virtual WooCommerce products created for rooms (SKU: `bnb-room-{id}`)
- Auto-sync on room save, delete on room deletion
- Manual "Sync All Rooms" button in settings
- Bidirectional meta linking (room ↔ product)
- Cart & Checkout:
- Booking data stored in cart items (room, dates, guests, services)
- Availability validation before add-to-cart and at checkout
- Dynamic price calculation based on dates and services
- Cart item display shows booking details (dates, guests, nights)
- Special requests and arrival time fields at checkout
- Booking summary display in checkout and order received page
- Order & Booking Integration:
- Automatic booking creation on `woocommerce_payment_complete`
- Guest record creation from order billing info
- Bidirectional order-booking links via meta keys
- Status synchronization (WC status → Booking status mapping)
- Booking reference generation (BNB-YYYY-NNNNN)
- Invoice Generation:
- PDF invoices using existing mPDF dependency
- Invoice numbering with configurable prefix and start number
- Auto-attach invoices to WooCommerce order emails
- Download invoice button in admin order actions
- Secure storage in `wp-content/uploads/wp-bnb-invoices/`
- Refund Handling:
- Full refund triggers booking cancellation
- Partial refund stores amount in booking meta without cancellation
- Refund info displayed in booking admin
- `wp_bnb_wc_should_cancel_on_refund` filter for customization
- Admin Enhancements:
- "WC Order" column in bookings list with order link and status
- "Booking" column in WC orders list with dates and status
- Row actions for cross-navigation between bookings and orders
- HPOS (High-Performance Order Storage) support
- WooCommerce Settings Tab with Subtabs:
- General: Enable integration, auto-confirm on payment, WC status indicator
- Products: Auto-sync toggle, product category selection, sync button
- Orders: Status mapping reference table
- Invoices: Auto-attach, prefix, starting number, logo, footer text
- Frontend Assets:
- `assets/css/wc-integration.css` - Cart, checkout, and booking form styles
- `assets/js/wc-integration.js` - Booking form handler, AJAX operations
### Changed
- Plugin.php updated to initialize WooCommerce integration when WC is active
- Settings page now has eight tabs: General, Pricing, License, Updates, Metrics, API, WooCommerce
- HPOS compatibility declared via `FeaturesUtil::declare_compatibility()`
### Security
- Invoice storage protected with .htaccess (deny all)
- Nonce verification on all AJAX operations
- Capability checks for admin actions
- HPOS-compatible meta access using `$order->get_meta()` / `$order->update_meta_data()`
## [0.10.1] - 2026-02-03
### Added
- API Settings subtabs for better organization:
- General subtab: Enable/disable REST API, rate limiting toggle, API information
- Rate Limits subtab: Configurable time window and endpoint-specific limits
- Endpoints subtab: Full endpoint documentation with HTTP method badges
- Configurable rate limiting:
- Time window setting (10-300 seconds, default 60)
- Per-endpoint-type limits (public, availability, booking, admin)
- Settings stored in WordPress options with fallback defaults
### Changed
- RateLimiter class now loads limits from WordPress options
- README updated with configurable rate limiting documentation
## [0.10.0] - 2026-02-03
### Added
- REST API Infrastructure:
- New `src/Api/` directory with complete REST API implementation
- `ResponseFormatter.php` - Standardized response formatting (success, collection, error responses)
- `RateLimiter.php` - Transient-based rate limiting with tiered limits
- `Controllers/AbstractController.php` - Base controller with common functionality
- `RestApi.php` - Main registration class with namespace `wp-bnb/v1`
- Buildings API:
- `GET /wp-bnb/v1/buildings` - List buildings with pagination and search
- `GET /wp-bnb/v1/buildings/{id}` - Get single building with address, contact, details
- `GET /wp-bnb/v1/buildings/{id}/rooms` - Get rooms in a building with status filter
- Rooms API:
- `GET /wp-bnb/v1/rooms` - List rooms with filters (building, room_type, amenities, capacity, status)
- `GET /wp-bnb/v1/rooms/{id}` - Get room details with gallery, pricing, amenities
- `GET /wp-bnb/v1/rooms/{id}/availability` - Check availability with price calculation
- `GET /wp-bnb/v1/rooms/{id}/calendar` - Get monthly calendar data
- `POST /wp-bnb/v1/availability/search` - Search available rooms by date range and criteria
- Bookings API:
- `POST /wp-bnb/v1/bookings` - Create booking (public, creates pending status)
- `GET /wp-bnb/v1/bookings` - List bookings with filters (admin)
- `GET /wp-bnb/v1/bookings/{id}` - Get booking details (admin)
- `PATCH /wp-bnb/v1/bookings/{id}` - Update booking (admin)
- `DELETE /wp-bnb/v1/bookings/{id}` - Cancel booking (admin)
- `POST /wp-bnb/v1/bookings/{id}/confirm` - Confirm pending booking (admin)
- `POST /wp-bnb/v1/bookings/{id}/check-in` - Check in guest (admin)
- `POST /wp-bnb/v1/bookings/{id}/check-out` - Check out guest (admin)
- Guests API (admin only):
- `GET /wp-bnb/v1/guests` - List guests with pagination
- `GET /wp-bnb/v1/guests/{id}` - Get guest details (excludes encrypted ID numbers)
- `GET /wp-bnb/v1/guests/search` - Search guests by name/email
- `GET /wp-bnb/v1/guests/{id}/bookings` - Get guest's booking history
- Services API:
- `GET /wp-bnb/v1/services` - List active services with categories
- `GET /wp-bnb/v1/services/{id}` - Get service details with pricing info
- `POST /wp-bnb/v1/services/{id}/calculate` - Calculate service price for booking
- Pricing API:
- `POST /wp-bnb/v1/pricing/calculate` - Full price calculation with services
- `GET /wp-bnb/v1/pricing/seasons` - Get configured seasons and pricing modifiers
- API Settings tab in plugin settings:
- Enable/disable REST API toggle
- Enable/disable rate limiting toggle
- Endpoint documentation table
- Authentication instructions
### Changed
- Plugin.php updated to initialize REST API on `rest_api_init` hook
- Settings page now has seven tabs: General, Pricing, License, Updates, Metrics, API
- README.md updated with comprehensive REST API documentation
### Security
- Rate limiting: public (60/min), availability (30/min), booking (10/min), admin (120/min)
- Admin endpoints require `edit_posts` capability
- Supports WordPress Application Passwords for external API access
- Client identification by user ID (authenticated) or IP address (anonymous)
- Proxy/Cloudflare IP detection via X-Forwarded-For and CF-Connecting-IP headers
## [0.9.0] - 2026-02-03
### Added
- Prometheus Metrics Integration:
- New `src/Integration/Prometheus.php` class for metrics collection
- Integration with wp-prometheus plugin via `wp_prometheus_collect_metrics` hook
- Inventory metrics: buildings total, rooms by status, services by status
- Booking metrics: bookings by status, check-ins/check-outs today, upcoming 7 days, avg duration
- Guest metrics: total guests, guests by status, repeat guests, new guests this month
- Occupancy metrics: current rate, monthly rate, occupied rooms, total bed capacity
- Revenue metrics: this month, YTD, average booking value, services revenue
- Grafana Dashboard:
- Pre-configured dashboard at `assets/grafana/wp-bnb-dashboard.json`
- Automatic registration with wp-prometheus dashboard provider
- Occupancy gauges with color-coded thresholds
- Pie charts for bookings, rooms, and guests by status
- Revenue and guest statistics panels
- Responsive grid layout with 24 panels
- Settings page Metrics tab:
- Enable/disable metrics collection toggle
- WP Prometheus detection with status indicator
- Complete metrics reference table
- Dashboard file location and export info
### Changed
- Plugin.php updated to initialize Prometheus integration
- Settings page now has six tabs: General, Pricing, License, Updates, Metrics
## [0.8.0] - 2026-02-03
### Added
- Admin Dashboard with comprehensive statistics:
- Occupancy overview card with current rate and comparison to last month
- Revenue summary card with this month, YTD, and comparison
- Bookings stat card with pending/confirmed counts
- Guests stat card with total, new, and repeat counts
- Today's Activity widget showing check-ins and check-outs
- Upcoming Bookings widget (next 7 days)
- Quick Actions widget for common tasks
- Chart.js integration for visual trend charts:
- Occupancy trend line chart (30 days)
- Revenue trend bar chart (6 months)
- Reports page with three report types:
- Occupancy Report: by room, by building, with progress bars
- Revenue Report: by room, by pricing tier, with averages
- Guest Statistics: top guests, nationality breakdown
- Date range filters (this month, last month, this year, custom)
- Export functionality:
- CSV export for all report types (native PHP)
- PDF export using mPDF library with professional styling
- New Composer dependency: mpdf/mpdf ^8.2 for PDF generation
- Dashboard and Reports CSS styles in admin.css (~350 lines)
- JavaScript chart initialization and report page handlers
### Changed
- Dashboard now uses dedicated `src/Admin/Dashboard.php` class
- Admin menu now includes Reports submenu item
- Asset enqueuing conditionally loads Chart.js on dashboard page
## [0.7.2] - 2026-02-03
### Fixed
- CF7 tag generator buttons not appearing in admin form editor
- Moved CF7 initialization from frontend-only to run in both admin and frontend contexts
- Tag generators now properly register via `wpcf7_admin_init` hook
## [0.7.1] - 2026-02-03
### Added
- CF7 Admin Tag Generator buttons:
- Tag generator buttons appear in CF7 form editor for all WP BnB custom tags
- BnB Building select with first option label configuration
- BnB Room select with building field linking and price display options
- BnB Check-in date with min/max advance booking days
- BnB Check-out date with check-in field linking and min/max nights
- BnB Guests count with room field linking and min/max/default values
- All generators support id and class attribute configuration
## [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
- Auto-Update System:
- New `src/License/Updater.php` class for WordPress update integration
- Hooks into `pre_set_site_transient_update_plugins` for update detection
- Plugin info modal via `plugins_api` filter
- Configurable update check frequency (1-168 hours)
- Option to enable/disable update notifications
- Option to enable/disable automatic updates
- AJAX endpoint for manual update check
- Automatic cache clearing when license settings change
- Updates Tab in Settings:
- Enable/disable update notifications toggle
- Enable/disable automatic updates toggle
- Update check frequency setting
- Manual "Check for Updates" button
- Display of last check timestamp and current version
- Localhost Development Mode:
- License bypass for local development environments
- Detects: localhost, 127.0.0.1, ::1, .local/.test/.localhost/.dev/.ddev.site domains
- Private IP range detection (10.x.x.x, 172.16-31.x.x, 192.168.x.x)
- "Development Mode" notice on Dashboard and License settings page
- Extended General Settings:
- Business address fields (street, city, postal code, country)
- Contact fields (email, phone, website)
- Social media fields (Facebook, Instagram, X/Twitter, LinkedIn, TripAdvisor)
- Pricing Settings Subtabs:
- Split into three subtabs: Pricing Tiers, Weekend Days, Seasons
- Each subtab has its own save button
- Seasons subtab shows priority column and link to Seasons Manager
- Guest Data Encryption:
- AES-256-CBC encryption for sensitive data (ID/passport numbers)
- Uses WordPress AUTH_KEY for encryption key derivation
- `encrypt()` and `decrypt()` methods in Guest class
- Backward compatible with legacy unencrypted data
- Security notice displayed in Identification meta box
- Guest Auto-Creation from Booking:
- When new guest data is entered in booking form, guest record is automatically created
- Links booking to the new guest via guest_id meta
- Prevents duplicate guest entries
### Changed
- Admin submenu reordered for better organization:
- Dashboard at top, Settings at bottom
- Logical grouping: Buildings, Rooms, Bookings, Guests, Services, Calendar, Seasons
- Booking title auto-generates with guest name and dates (room number removed)
- Disabled Gutenberg block editor for form-based post types:
- Service, Guest, and Booking now use classic editor
- Meta boxes display properly instead of being hidden at bottom
- Form-based interfaces more appropriate than block editor for data entry
- Settings tabs now flush with tab content (no gap)
### Fixed
- Fixed Booking admin issues with auto-draft status causing type errors
- Fixed guest dropdown to always load existing guests
- Fixed booking history display on Guest edit page
- Fixed service pricing meta box not displaying radio buttons (Gutenberg hiding meta boxes)
### Security
- Guest ID/passport numbers encrypted at rest using AES-256-CBC
- Random IV generation for each encryption operation
- Secure key derivation from WordPress AUTH_KEY
## [0.6.0] - 2026-02-02
### Added
- Frontend Features System:
- Room search with multiple filters (availability, capacity, room type, amenities, price range, building)
- AJAX-powered search with pagination and "Load More" functionality
- Date validation (check-out after check-in, minimum today)
- Real-time availability checking on single room pages
- Price calculator with breakdown display
- Shortcodes:
- `[bnb_buildings]` - Display buildings list/grid with filtering and sorting
- `[bnb_rooms]` - Display rooms list/grid with multiple filter options
- `[bnb_room_search]` - Interactive room search form with results
- `[bnb_building id="X"]` - Display single building details
- `[bnb_room id="X"]` - Display single room details with availability form
- WordPress Widgets:
- Similar Rooms widget (shows rooms from same building/type)
- Building Rooms widget (lists all rooms in a building)
- Availability Calendar widget (mini calendar with booking status)
- Gutenberg Blocks:
- Building block with ID selector
- Room block with ID selector
- Room Search block with filter presets
- Buildings List block with layout options
- Rooms List block with filter options
- Server-side rendered blocks for consistent output
- Frontend Search Class (`src/Frontend/Search.php`):
- Core search functionality with availability filtering
- Price range filtering with Calculator integration
- Pagination support
- AJAX endpoints: search_rooms, get_availability, get_calendar, calculate_price
- Room data formatting for JSON responses
- Frontend Shortcodes Class (`src/Frontend/Shortcodes.php`):
- All shortcode registration and handlers
- Grid/list layout support
- Column configuration (1-4 columns)
- Sorting options (title, date, price, capacity)
- Limit and offset support
- Block Registrar Class (`src/Blocks/BlockRegistrar.php`):
- Gutenberg block registration
- Block editor assets (CSS/JS)
- Server-side render callbacks
- Block data localization for editor
- Frontend Assets:
- Comprehensive CSS with CSS custom properties for theming
- Building and room card styles
- Search form and results styling
- Calendar widget styling with availability states
- Responsive design (breakpoints: 480px, 768px, 1024px)
- JavaScript with SearchForm, CalendarWidget, AvailabilityForm, PriceCalculator classes
- AJAX integration with proper error handling
- XSS-safe DOM construction (no innerHTML with user data)
### Changed
- Plugin.php updated with frontend component initialization
- Frontend assets now include localized script data with AJAX URL, nonce, and i18n strings
- Widget registration added to init_frontend() method
- Search, Shortcodes, and BlockRegistrar initialized when license is valid
### Security
- AJAX nonce verification on all frontend requests
- Input sanitization on all search parameters
- Output escaping in shortcode and widget templates
- XSS prevention in JavaScript (textContent instead of innerHTML)
## [0.5.0] - 2026-01-31
### Added
- Additional Services System:
- Custom Post Type: Services (`bnb_service`)
- Service pricing types: Included (free), Per Booking (one-time), Per Night
- Service configuration: price, status, sort order, max quantity
- Custom admin columns with pricing type icons and status badges
- Filters by status and pricing type
- Service data helper methods for pricing calculations
- Service Categories Taxonomy (`bnb_service_category`)
- Non-hierarchical (tag-like) structure
- Icon selection per category
- Sort order for custom ordering
- Default categories: Food & Dining, Transportation, Wellness & Spa, Activities, Housekeeping
- Booking-Services Integration:
- Services meta box in Booking edit screen
- Checkbox-based service selection
- Quantity input for services with max_quantity > 1
- Real-time price calculation per service based on nights
- Services total display
- Price breakdown shows services cost
- Grand total (room + services) in admin list and pricing meta box
- Admin UI Enhancements:
- Service selector with pricing type indicators
- Included services badge
- Per-night price suffix display
- Service line totals with quantity support
- Services total summary in booking
- CSS styles for all service-related components
- JavaScript for dynamic service pricing calculations
### Changed
- Plugin.php updated to register Service CPT and ServiceCategory taxonomy
- Admin assets enqueued for Service post type screens
- Booking admin list shows total price including services
- Booking pricing meta box displays services breakdown and grand total
- Admin JavaScript extended with service pricing and selection logic
- Admin CSS includes comprehensive service styling
## [0.4.0] - 2026-01-31
### Added
- Guest Management System with dedicated CPT:
- Custom Post Type: Guests (`bnb_guest`)
- Personal information fields (name, email, phone, DOB, nationality)
- Address fields (street, city, postal code, country)
- Identification fields (ID type, number, expiry date)
- Guest status tracking (active, inactive, blocked)
- Internal notes and preferences
- GDPR consent tracking (marketing, data processing, consent date)
- Booking history display with statistics
- Helper methods: `get_by_email()`, `get_bookings()`, `get_booking_count()`, `get_total_spent()`, `get_full_name()`, `get_formatted_address()`
- Guest-Booking Integration:
- Guest search by email/name with AJAX autocomplete
- Link existing guests to bookings
- Create new guests from booking form
- Guest profile link in booking admin
- Automatic guest data sync when linked
- Backward compatibility for legacy bookings without guest_id
- GDPR/Privacy Compliance (`src/Privacy/Manager.php`):
- WordPress Privacy API integration
- Personal data exporter (guest profile + booking history)
- Personal data eraser with anonymization option
- Privacy policy content suggestion
- Support for WordPress Tools > Export/Erase Personal Data
- Guest anonymization (replaces PII with placeholder data)
- Booking anonymization for connected bookings
- Email Notifier Enhancements:
- Guest data retrieval from Guest CPT when available
- Fallback to booking meta for legacy bookings
- New placeholders: `{guest_first_name}`, `{guest_last_name}`, `{guest_full_address}`
- Admin UI Styles:
- Guest search container and results styling
- Linked guest display card
- Booking history table in Guest
- Consent status indicators
- Guest status badges
- Privacy action buttons
- Anonymized data display
### Changed
- Booking meta box updated with guest search/link functionality
- Plugin.php now initializes Guest CPT and Privacy Manager
- Admin JavaScript includes guest search with debounce
- Admin CSS extended with Guest and Privacy styles
### Security
- Guest email used as unique identifier for deduplication
- GDPR-compliant data export and erasure
- Consent tracking with timestamps
- Anonymization preserves booking records while removing PII
- AJAX endpoints secured with nonce verification
## [0.3.0] - 2026-01-31
### Added
@@ -191,6 +733,18 @@ 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.11.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.11.0
[0.10.1]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.10.1
[0.10.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.10.0
[0.9.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.9.0
[0.8.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.8.0
[0.7.2]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.7.2
[0.7.1]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.7.1
[0.7.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.7.0
[0.6.1]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.6.1
[0.6.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.6.0
[0.5.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.5.0
[0.4.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.4.0
[0.3.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.3.0
[0.2.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.2.0
[0.1.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.1.0

792
CLAUDE.md
View File

@@ -1,4 +1,4 @@
# WordPress BnB Management
# WordPress BnB Manager
**Author:** Marco Graetsch
**Author URL:** <https://src.bundespruefstelle.ch/magdev>
@@ -38,6 +38,10 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
**Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file.
### Known Bugs
(none)
## Technical Stack
- **Language:** PHP 8.3.x
@@ -128,6 +132,31 @@ for po in languages/*.po; do msgfmt -o "${po%.po}.mo" "$po"; done
- Commit messages should follow the established format with Claude Code attribution
- `.claude/settings.local.json` changes are typically local-only (stash before rebasing)
**CRITICAL - Release Workflow:**
On every new version, ALWAYS execute this complete workflow:
```bash
# 1. Commit changes to dev branch
git add <files>
git commit -m "Description of changes (vX.X.X)"
# 2. Merge dev to main
git checkout main
git merge dev --no-edit
# 3. Create annotated tag
git tag -a vX.X.X -m "Version X.X.X - Brief description"
# 4. Push everything to origin
git push origin dev main vX.X.X
# 5. Switch back to dev for continued development
git checkout dev
```
Never skip any of these steps. The release is not complete until all branches and the tag are pushed to origin.
#### What Gets Released
- All plugin source files
@@ -208,36 +237,74 @@ wp-bnb/
│ ├── Plugin.php # Main plugin singleton
│ ├── Admin/ # Admin pages
│ │ ├── Calendar.php # Availability calendar page
│ │ ├── Dashboard.php # Dashboard page with statistics
│ │ ├── Reports.php # Reports page with exports
│ │ └── Seasons.php # Seasons management page
│ ├── Api/ # REST API (v0.10.0+)
│ │ ├── RestApi.php # Main API registration
│ │ ├── RateLimiter.php # Transient-based rate limiting
│ │ ├── ResponseFormatter.php # Standardized responses
│ │ └── Controllers/ # API endpoint controllers
│ │ ├── AbstractController.php
│ │ ├── BookingsController.php
│ │ ├── BuildingsController.php
│ │ ├── GuestsController.php
│ │ ├── PricingController.php
│ │ ├── RoomsController.php
│ │ └── ServicesController.php
│ ├── Blocks/ # Gutenberg blocks
│ │ └── BlockRegistrar.php # Block registration and rendering
│ ├── Booking/ # Booking system
│ │ ├── Availability.php # Availability checking
│ │ └── EmailNotifier.php # Email notifications
│ ├── License/
│ │ ── Manager.php # License management
│ ├── Frontend/ # Frontend components
│ │ ── Search.php # Room search and AJAX handlers
│ │ ├── Shortcodes.php # All shortcode handlers
│ │ └── Widgets/ # WordPress widgets
│ │ ├── AvailabilityCalendar.php
│ │ ├── BuildingRooms.php
│ │ └── SimilarRooms.php
│ ├── Integration/ # Third-party integrations
│ │ ├── CF7.php # Contact Form 7 integration
│ │ └── Prometheus.php # Prometheus metrics
│ ├── License/ # License management
│ │ ├── Manager.php # License validation and activation
│ │ └── Updater.php # Auto-update system
│ ├── PostTypes/ # Custom post types
│ │ ├── Booking.php # Booking post type
│ │ ├── Building.php # Building post type
│ │ ── Room.php # Room post type
│ │ ── Guest.php # Guest post type
│ │ ├── Room.php # Room post type
│ │ └── Service.php # Service post type
│ ├── Pricing/ # Pricing system
│ │ ├── Calculator.php # Price calculation
│ │ ├── PricingTier.php # Pricing tier enum
│ │ └── Season.php # Seasonal pricing
│ ├── Privacy/ # Privacy & GDPR
│ │ └── Manager.php # Data export/deletion
│ └── Taxonomies/ # Custom taxonomies
│ ├── Amenity.php # Amenity taxonomy (tags)
── RoomType.php # Room type taxonomy (categories)
├── lib/ # Git submodules
│ └── wc-licensed-product-client/ # License client library
├── vendor/ # Composer dependencies (auto-generated)
├── assets/
── RoomType.php # Room type taxonomy (categories)
│ └── ServiceCategory.php # Service categories
├── assets/ # CSS, JS, images
│ ├── css/
│ │ ├── admin.css # Admin styles
│ │ ├── blocks-editor.css # Gutenberg editor styles
│ │ ├── cf7-integration.css # CF7 form styles
│ │ └── frontend.css # Frontend styles
│ ├── grafana/
│ │ └── wp-bnb-dashboard.json # Pre-configured Grafana dashboard
│ └── js/
│ ├── admin.js # Admin scripts
│ ├── blocks-editor.js # Gutenberg editor scripts
│ ├── cf7-integration.js # CF7 form scripts
│ └── frontend.js # Frontend scripts
├── templates/ # Twig templates (future)
├── languages/ # Translation files (future)
└── releases/ # Release packages (git-ignored)
├── languages/ # Translation files (.pot/.po/.mo)
├── lib/ # Git submodules
│ └── wc-licensed-product-client/ # License client library
├── releases/ # Release packages (git-ignored)
├── templates/ # Twig templates (reserved for future)
└── vendor/ # Composer dependencies (auto-generated)
```
### Implementation Details
@@ -298,7 +365,7 @@ Admin features always work; frontend requires valid license.
- Implemented license settings page with validation/activation buttons
- Created admin CSS and JavaScript for license management
- Created Gitea CI/CD pipeline at `.gitea/workflows/release.yml`
- Created `PLAN.md` with full implementation roadmap (8 phases)
- Created `PLAN.md` with full implementation roadmap (10 phases)
- Created `README.md` with user documentation
- Created `CHANGELOG.md` following Keep a Changelog format
- Updated `CLAUDE.md` with architecture details
@@ -468,3 +535,702 @@ Admin features always work; frontend requires valid license.
- 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
- **Release workflow** must always include: commit to dev → merge to main → create tag → push all to origin
- Git fast-forward merge works well when dev is ahead of main with no conflicts
**Released:**
- Committed: `0c601df` on dev branch
- Merged to main (fast-forward)
- Tagged: `v0.3.0`
- Pushed to origin: dev, main, v0.3.0
### 2026-01-31 - Version 0.5.0 (Additional Services)
**Completed:**
- Created Custom Taxonomy: Service Categories (`bnb_service_category`)
- Non-hierarchical (tag-like) structure
- Dashicon selection for visual display
- Sort order meta field for custom ordering
- Default categories: Food & Dining, Transportation, Wellness & Spa, Activities, Housekeeping
- Created Custom Post Type: Services (`bnb_service`)
- Three pricing types: Included (free), Per Booking, Per Night
- Price configuration per service
- Service status (active/inactive)
- Sort order for display ordering
- Maximum quantity setting per service
- Custom admin columns: pricing type, price, status
- Filters by status and pricing type
- Helper methods: `get_service_data()`, `calculate_service_price()`, `get_services_for_booking()`, `format_service_price()`
- Updated Booking post type with services integration
- Added `SERVICES_META_KEY` constant for services storage
- New meta box: Additional Services with checkbox selection
- Quantity input for services with max_quantity > 1
- Real-time per-service line total calculation
- Services total display
- Price breakdown now shows services cost
- Grand total (room + services) in pricing meta box
- Admin list price column shows total including services
- Helper methods: `calculate_booking_services_total()`, `get_booking_services()`
- Updated `src/Plugin.php`
- Registered ServiceCategory taxonomy
- Registered Service post type
- Added Service post type to asset enqueuing
- Added i18n strings for service pricing descriptions
- Updated `assets/css/admin.css`
- Service status badges
- Service pricing meta box styles
- Booking services selector styles
- Service item with selected state
- Quantity inputs and line totals
- Services total summary
- Grand total display
- Updated `assets/js/admin.js`
- `initServicePricing()`: Toggle price row based on pricing type
- `initBookingServices()`: Service selection with real-time price calculation
- Quantity change handlers with min/max enforcement
- Automatic recalculation when booking dates change
- Updated version to 0.5.0
- Updated CHANGELOG.md with Phase 5 changes
- Updated PLAN.md to mark Phase 5 complete
**Learnings:**
- Service pricing calculation depends on pricing_type: included=0, per_booking=price*qty, per_night=price*qty*nights
- Services are stored as JSON array in booking meta with service_id, quantity, price, pricing_type
- Same namespace classes can reference each other directly without use statements
- Services meta box renders before pricing meta box so services total is available
- Grand total calculation happens both on save (server-side) and on change (client-side JS)
### 2026-02-02 - Version 0.6.0 (Frontend Features)
**Completed:**
- Created `src/Frontend/Search.php` class
- Room search with multiple filters: availability, capacity, room type, amenities, price range, building
- AJAX endpoints: `wp_bnb_search_rooms`, `wp_bnb_get_availability`, `wp_bnb_get_calendar`, `wp_bnb_calculate_price`
- Pagination support with configurable per_page
- Room data formatting for JSON responses with thumbnails, pricing, amenities
- Price range filtering using Calculator integration
- Availability filtering using Availability class
- Created `src/Frontend/Shortcodes.php` class
- `[bnb_buildings]` - Buildings list/grid with layout, columns, limit, orderby options
- `[bnb_rooms]` - Rooms list/grid with building, room_type, amenities filters
- `[bnb_room_search]` - Interactive search form with results container
- `[bnb_building id="X"]` - Single building display with rooms
- `[bnb_room id="X"]` - Single room display with availability form
- Grid system with 1-4 column support
- Sorting options: title, date, price, capacity
- Created `src/Frontend/Widgets/` directory with three widgets
- `SimilarRooms.php` - Shows rooms from same building/room type
- `BuildingRooms.php` - Lists all rooms in a building
- `AvailabilityCalendar.php` - Mini calendar with booking status
- All widgets extend `WP_Widget` with form/update/widget methods
- Auto-detection of current building/room from page context
- Created `src/Blocks/BlockRegistrar.php` class
- Five Gutenberg blocks: Building, Room, Room Search, Buildings List, Rooms List
- Server-side rendering using shortcode system
- Block editor assets (CSS/JS) enqueuing
- Block data localization with buildings, rooms, room types, amenities
- `render_callback` functions for each block type
- Created `assets/js/blocks-editor.js`
- Block registration using `wp.blocks.registerBlockType`
- InspectorControls for sidebar settings panels
- ServerSideRender for live preview in editor
- Attribute definitions matching shortcode parameters
- Created `assets/css/blocks-editor.css`
- Minimal editor styling for block placeholders
- Preview container styling
- Updated `assets/css/frontend.css` (~1250 lines)
- CSS custom properties for theming (colors, spacing, border-radius)
- Building and room card components
- Search form with field groups
- Results grid with responsive columns
- Calendar widget with availability states (available, booked, past, today)
- Legend styling
- Responsive breakpoints: 480px, 768px, 1024px
- Updated `assets/js/frontend.js` (~825 lines)
- `WpBnb` namespace with utility methods (ajax, formatDate, parseDate, debounce)
- `SearchForm` class: form submission, date validation, results rendering, load more
- `CalendarWidget` class: month navigation, AJAX calendar loading
- `AvailabilityForm` class: availability checking on single room pages
- `PriceCalculator` class: real-time price calculation with breakdown
- XSS-safe DOM construction using textContent instead of innerHTML
- Updated `src/Plugin.php`
- Added use statements for new frontend classes
- `init_frontend()` initializes Search, Shortcodes, BlockRegistrar
- `register_widgets()` method for widget registration
- `wp_localize_script()` adds AJAX URL, nonce, i18n strings to frontend
- Updated version to 0.6.0 in both plugin header and constant
- Updated CHANGELOG.md with comprehensive v0.6.0 release notes
- Updated PLAN.md to mark Phase 6 complete
**Learnings:**
- Server-side rendered Gutenberg blocks avoid complex build processes and ensure PHP/JS output consistency
- Shortcode system works well as render backend for blocks via `render_callback`
- Widget auto-detection from page context (`is_singular()`, `get_the_ID()`) reduces configuration
- CSS custom properties enable easy theming without modifying core styles
- AJAX nonce verification requires `wp_ajax_nopriv_` for non-logged-in users in frontend search
- Calendar data from `Availability::get_calendar_data()` provides consistent format for PHP and JS rendering
- XSS prevention in JS: use `textContent` for user data, `createElement` for structure
- Frontend components require license check (`LicenseManager::is_license_valid()`) before initialization
- Block editor requires separate script handle from frontend to avoid conflicts
**Released:**
- Committed: `864b8b2` on dev branch
- Merged to main (fast-forward)
- Tagged: `v0.6.0`
- Pushed to origin: dev, main, v0.6.0
### 2026-02-03 - Bug Fixes and Enhancements
**Completed:**
- Fixed gap between settings page tabs and tab content
- Changed `.nav-tab-wrapper` margin-bottom from 20px to 0
- Added explicit border-bottom to create seamless connection with tab content
- Added license bypass for localhost development environments
- Created `LicenseManager::is_localhost()` method
- Detects: localhost, 127.0.0.1, ::1, .local/.test/.localhost/.dev/.ddev.site domains, private IP ranges
- `is_license_valid()` now returns true for localhost environments
- Added "Development Mode" notice on license settings page and dashboard when localhost detected
- Expanded General Settings with business owner fields
- Added Address section: street, city, postal code, country
- Added Contact section: email, phone, website
- Added Social Media section: Facebook, Instagram, X (Twitter), LinkedIn, TripAdvisor
- Updated `save_general_settings()` with proper sanitization for all new fields
- Created subtabs on Pricing settings tab
- Three subtabs: Pricing Tiers, Weekend Days, Seasons
- Each subtab has its own save button and focused content
- Added CSS for subtab navigation styling
- Seasons subtab now shows priority column and direct link to Seasons Manager
- Implemented auto-updates system
- Created `src/License/Updater.php` class
- Integrates with WordPress plugin update system via `pre_set_site_transient_update_plugins`
- Provides plugin info for "View details" modal via `plugins_api` filter
- Uses license client's `checkForUpdates()` method
- Configurable check frequency (1-168 hours)
- Options for notifications enabled and auto-install enabled
- Automatic cache clearing when license settings change or after updates
- Added Updates tab to settings page
- Enable/disable update notifications
- Enable/disable automatic updates
- Configurable update check frequency
- Manual "Check for Updates" button with AJAX
- Display of last check timestamp and current version
- Reordered admin submenu for better organization
- Dashboard at top, Settings at bottom
- Logical grouping: Buildings, Rooms, Bookings, Guests, Services, Calendar, Seasons
- Fixed Booking admin issues
- Fixed auto-draft status causing type errors (check for WP_Post object)
- Fixed guest dropdown to always load existing guests
- Booking title now auto-generates with guest name and dates (room removed per user request)
- Fixed booking history display on Guest edit page
- Implemented guest auto-creation from booking form
- When new guest data is entered in booking, guest record is automatically created
- Links booking to the new guest via guest_id meta
- Added encryption for sensitive guest data
- ID/passport numbers encrypted using AES-256-CBC
- Uses WordPress AUTH_KEY for encryption key derivation
- `encrypt()` and `decrypt()` methods in Guest class
- Backward compatible with legacy unencrypted data
- Security notice displayed in Identification meta box
- Disabled Gutenberg block editor for form-based post types
- Service, Guest, and Booking post types now use classic editor
- Added `disable_block_editor()` filter to each post type class
- Meta boxes now appear properly instead of being hidden at bottom
- Form-based interfaces are more appropriate than block editor for data entry
**Files Changed:**
- `assets/css/admin.css` - Fixed tab gap, added subtab styles, booking form styles
- `assets/js/admin.js` - AJAX update check, booking form improvements, guest auto-creation
- `src/License/Manager.php` - Added `is_localhost()` method, updated `is_license_valid()`
- `src/License/Updater.php` - New file for auto-updates with configurable settings
- `src/Plugin.php` - Business owner settings, pricing subtabs, updates tab, menu reordering
- `src/PostTypes/Booking.php` - Auto-draft fixes, title generation, guest creation, disable Gutenberg
- `src/PostTypes/Guest.php` - AES-256-CBC encryption for ID numbers, disable Gutenberg
- `src/PostTypes/Service.php` - Disable Gutenberg for classic editor UI
**Learnings:**
- WordPress nav-tab styling expects tabs and content to be flush (no margin/gap)
- Localhost detection should cover common development TLDs (.local, .test, .dev, .ddev.site)
- Private IP ranges can be detected using `FILTER_FLAG_NO_PRIV_RANGE`
- WordPress plugin updates require hooking into `pre_set_site_transient_update_plugins` and `plugins_api`
- Subtabs can be implemented with query parameters and conditional rendering within a single settings callback
- URL fields should use `esc_url_raw()` for sanitization, email fields use `sanitize_email()`
- Always check if post object is valid (`$post instanceof \WP_Post`) before accessing properties - auto-draft causes issues
- AES-256-CBC encryption with random IV provides secure storage for sensitive data
- Store IV concatenated with encrypted data (IV is not secret, just needs to be unique)
- `use_block_editor_for_post_type` filter disables Gutenberg per post type
- Post types with `show_in_rest => true` get Gutenberg by default, which hides traditional meta boxes
- Form-based admin interfaces (data entry) should use classic editor, not block editor
### 2026-02-03 - Version 0.7.0 (Contact Form 7 Integration)
**Completed:**
- Created `src/Integration/CF7.php` (~750 lines)
- 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 validation in `wpcf7_before_send_mail` hook
- Automatic booking creation on form submission via `wpcf7_mail_sent`
- Guest record creation/linking using `find_or_create_guest()` pattern
- Custom mail tags: `[_bnb_room_name]`, `[_bnb_building_name]`, `[_bnb_calculated_price]`, `[_bnb_nights]`, `[_bnb_booking_reference]`
- Form type detection via CSS class `wp-bnb-booking-form`
- Created `assets/js/cf7-integration.js` (~230 lines)
- Building-based room filtering (rooms dropdown updates when building selected)
- Date validation (check-out after check-in, no past dates)
- Guest capacity validation against room limits
- AJAX availability checking with status display
- AJAX price calculation with formatted display
- Debounced updates to prevent excessive requests
- Created `assets/css/cf7-integration.css` (~200 lines)
- Two-column responsive form layout
- Availability status indicators (checking spinner, available checkmark, unavailable X)
- Price display formatting
- Capacity warning styling
- Dark mode support via `prefers-color-scheme`
- Print styles (hide interactive elements)
- Updated `src/Plugin.php`
- Added `use Magdev\WpBnb\Integration\CF7` import
- CF7 initialization in `init_frontend()` when WPCF7 class exists
- CF7 assets enqueuing with localized i18n strings
- Updated `README.md` with comprehensive CF7 documentation
- Custom form tags reference with options
- Example booking form template
- Example inquiry form template
- Custom mail tags documentation
**Files Created:**
- `src/Integration/CF7.php` - Main CF7 integration class
- `assets/js/cf7-integration.js` - Frontend JavaScript
- `assets/css/cf7-integration.css` - Form styling
**Learnings:**
- CF7 custom tags registered via `wpcf7_add_form_tag()` with callback functions
- Validation filters follow pattern `wpcf7_validate_{tag_name}`
- `wpcf7_before_send_mail` can abort submission by setting `$abort` to true and adding validation error
- `wpcf7_mail_sent` fires after successful email, ideal for booking creation
- Custom mail tags via `wpcf7_special_mail_tags` filter receive submission data
- Form type detection by CSS class more reliable than checking for specific tags
- Room dropdown with `data-building` attributes enables client-side filtering
- AJAX endpoints reuse existing `wp_bnb_get_availability` and `wp_bnb_calculate_price` actions
- CF7 assets should depend on `contact-form-7` script/style handles
- Guest linking uses email as unique identifier for find-or-create pattern
**Released:**
- Committed: `28350aa` on dev branch
- Merged to main (fast-forward)
- Tagged: `v0.7.0`
- Pushed to origin: dev, main, v0.7.0
### 2026-02-03 - Version 0.7.1 (CF7 Tag Generators)
**Completed:**
- Added CF7 tag generator buttons for admin form editor
- Hook into `wpcf7_admin_init` to register tag generators
- `register_tag_generators()` method using `WPCF7_TagGenerator::add()`
- BnB Building select generator with `first_as_label` option
- BnB Room select generator with `building_field` and `include_price` options
- BnB Check-in date generator with `min_advance` and `max_advance` options
- BnB Check-out date generator with `checkin_field`, `min_nights`, `max_nights` options
- BnB Guests count generator with `room_field`, `min`, `max`, `default` options
- All generators support `id` and `class` attribute configuration
- CF7 v2 tag generator format with `version => '2'` option
- Removed bug from Known Bugs section in CLAUDE.md
**Files Changed:**
- `src/Integration/CF7.php` - Added ~560 lines for tag generator registration and modal callbacks
- `CLAUDE.md` - Removed bug from Known Bugs section
- `wp-bnb.php` - Version bump to 0.7.1
- `CHANGELOG.md` - Added v0.7.1 release notes
**Learnings:**
- CF7 tag generators use `WPCF7_TagGenerator::get_instance()->add()` for registration
- Tag generator callbacks receive `$contact_form` and `$options` parameters
- CF7 v2 tag generator format requires `'version' => '2'` in options array
- Modal HTML structure: `<header class="description-box">`, `<div class="control-box">`, `<footer class="insert-box">`
- Form inputs use classes like `tg-name`, `oneline`, `option`, `idvalue`, `classvalue` for CF7's JavaScript handling
- The `tag-generator-insert-button` class triggers CF7's tag insertion JavaScript
- Mail tag tip shows users which tag to use in the Mail tab
- Tag generators are registered at priority 60 in `wpcf7_admin_init` to appear after core tags
**Released:**
- Committed: `a784d92` on dev branch
- Merged to main (fast-forward)
- Tagged: `v0.7.1`
- Pushed to origin: dev, main, v0.7.1
### 2026-02-03 - Version 0.8.0 (Dashboard & Reports)
**Completed:**
- Created `src/Admin/Dashboard.php` class (~700 lines)
- `render()` method for full dashboard page
- Occupancy stat card with current rate, room count, comparison to last month
- Revenue stat card with this month, YTD, comparison
- Bookings stat card with pending/confirmed counts
- Guests stat card with total, new this month, repeat guests
- Today's Activity widget showing check-ins and check-outs
- Upcoming Bookings widget with next 7 days' bookings
- Quick Actions widget (New Booking, New Guest, Calendar, Reports)
- Occupancy trend chart (30-day line chart)
- Revenue trend chart (6-month bar chart)
- Data methods: `get_occupancy_stats()`, `get_revenue_stats()`, `get_booking_stats()`, `get_guest_stats()`
- Chart data methods: `get_occupancy_trend_data()`, `get_revenue_trend_data()`
- Transient caching for expensive calculations (1-hour expiry)
- Created `src/Admin/Reports.php` class (~1100 lines)
- Tabbed interface: Occupancy, Revenue, Guests
- Date range filters with presets (this month, last month, this year, custom)
- Occupancy Report: by room, by building with progress bars and status labels
- Revenue Report: by room, by pricing tier, with averages
- Guest Statistics: top guests by revenue, nationality breakdown
- CSV export using native PHP `fputcsv()`
- PDF export using mPDF with professional HTML styling
- Summary cards with key metrics
- Progress bar visualizations for occupancy rates
- Added mPDF dependency to `composer.json` (`mpdf/mpdf ^8.2`)
- Updated `src/Plugin.php`
- Added Dashboard and Reports class imports
- `render_dashboard_page()` delegates to `Dashboard::render()`
- Added `render_reports_page()` method
- Reports submenu registration
- Updated menu ordering to include Reports
- Chart.js CDN enqueuing on dashboard page
- Chart data passed via `wp_localize_script()`
- Dashboard CSS styles (~350 lines in admin.css)
- Responsive grid layout (4-col stats, 2-col charts, 3-col activity)
- Stat cards with icons and gradients
- Widget components with headers
- Activity list styling
- Upcoming bookings table
- Quick action buttons grid
- Reports CSS styles (~200 lines in admin.css)
- Filter form layout
- Summary cards with primary variant
- Progress bars for occupancy
- Status labels (high/medium/low)
- Export buttons styling
- JavaScript additions in admin.js
- `initDashboardCharts()` for Chart.js initialization
- Occupancy line chart with tooltips and styling
- Revenue bar chart with currency formatting
- `initReportsPage()` for custom date toggle
- Updated version to 0.8.0
**Files Created:**
- `src/Admin/Dashboard.php` - Dashboard page with widgets and charts
- `src/Admin/Reports.php` - Reports page with tabs and export
**Files Changed:**
- `composer.json` - Added mpdf/mpdf dependency
- `composer.lock` - Updated with mPDF and dependencies
- `src/Plugin.php` - Dashboard/Reports integration, Chart.js enqueuing
- `assets/css/admin.css` - Dashboard and Reports styles (~550 lines added)
- `assets/js/admin.js` - Chart initialization, reports page handlers
- `wp-bnb.php` - Version bump to 0.8.0
- `CHANGELOG.md` - Added v0.8.0 release notes
- `PLAN.md` - Marked Phase 8 as complete
**Learnings:**
- Chart.js CDN loading requires conditional enqueuing to avoid loading on all admin pages
- Dashboard data methods should use transient caching for expensive queries
- PDF export with mPDF requires HTML string generation with inline CSS
- Reports use `get_posts()` with meta queries for date range filtering
- Progress bar visualization done with CSS positioning and `min(100, value)` clamping
- Chart.js 4.x uses `new Chart()` constructor with configuration object
- PDF generation needs `try/catch` for mPDF exceptions
- CSV export with BOM (`\xEF\xBB\xBF`) ensures Excel compatibility
- Guest data aggregation from bookings uses unique key pattern for anonymous guests
- Occupancy calculation: (booked nights / total room nights) * 100
### 2026-02-03 - Version 0.9.0 (Prometheus Metrics)
**Completed:**
- Created `src/Integration/Prometheus.php` class (~700 lines)
- Integration with wp-prometheus via `wp_prometheus_collect_metrics` hook
- Dashboard registration via `wp_prometheus_register_dashboards` hook
- Option to enable/disable metrics collection
- Inventory metrics: buildings total, rooms by status, services by status
- Booking metrics: by status, check-ins/outs today, upcoming 7 days, avg duration
- Guest metrics: total, by status, repeat guests, new this month
- Occupancy metrics: current rate, monthly rate, occupied rooms, bed capacity
- Revenue metrics: this month, YTD, avg booking value, services revenue
- Optimized SQL queries using `$wpdb->prepare()` throughout
- Created `assets/grafana/wp-bnb-dashboard.json` Grafana dashboard
- 24 panels with responsive grid layout
- Occupancy gauges with color-coded thresholds (red < 30%, orange < 50%, yellow < 70%, green ≥ 70%)
- Pie charts for bookings, rooms, and guests by status
- Revenue stat panels (this month, YTD, avg value, services)
- Guest stat panels (total, new, repeat, active services)
- Today's activity panels (check-ins, check-outs, upcoming)
- Prometheus datasource variable for flexibility
- Auto-refresh every 5 minutes
- Updated `src/Plugin.php`
- Added Prometheus class import
- Initialized Prometheus integration in `init_components()`
- Added "Metrics" tab to settings page (6 tabs total)
- Added `render_metrics_settings()` method with WP Prometheus detection
- Added `save_metrics_settings()` method
- Metrics reference table showing all available metrics
- Updated version to 0.9.0
**Files Created:**
- `src/Integration/Prometheus.php` - Prometheus metrics integration class
- `assets/grafana/wp-bnb-dashboard.json` - Pre-configured Grafana dashboard
**Files Changed:**
- `src/Plugin.php` - Prometheus initialization, metrics settings tab
- `wp-bnb.php` - Version bump to 0.9.0 (header and constant)
- `CHANGELOG.md` - Added v0.9.0 release notes
- `PLAN.md` - Marked Phase 9 as complete
- `README.md` - Added Prometheus metrics documentation
**Learnings:**
- wp-prometheus uses `wp_prometheus_collect_metrics` action with collector object
- Collector provides `register_gauge()` for fluctuating values
- Labels are passed as array to `register_gauge()`, values to `set()`
- Grafana dashboard JSON requires proper panel IDs and grid positions
- Occupancy queries need careful date range handling for month boundaries
- Revenue queries use `DECIMAL(10,2)` casting for accurate sums
- Metrics should be cached or computed efficiently as they're scraped frequently
- Dashboard registration requires file path, title, description, icon, and plugin name
- Settings tab detection uses `$prometheus_active` to show WP Prometheus status
### 2026-02-03 - Version 0.10.0/0.10.1 (REST API Endpoints)
**Completed:**
- Created `src/Api/RestApi.php` main registration class
- Namespace constant: `wp-bnb/v1`
- Controller initialization and route registration
- Integration with Plugin class via `rest_api_init` hook
- Created `src/Api/RateLimiter.php`
- Transient-based rate limiting per client (user ID or IP)
- Tiered limits: public (60/min), availability (30/min), booking (10/min), admin (120/min)
- Configurable via WordPress options with fallback defaults
- `check()`, `get_retry_after()`, `get_rate_limit_info()` methods
- Created `src/Api/ResponseFormatter.php`
- Standardized success/error responses
- `success()`, `collection()`, `created()` methods
- Error helpers: `validation_error()`, `not_found()`, `forbidden()`, `conflict()`, `rate_limit_error()`
- Created `src/Api/Controllers/AbstractController.php`
- Base class extending `WP_REST_Controller`
- Rate limit checking and header injection
- Client IP detection (supports Cloudflare, proxies)
- Common permission callbacks: `public_permission()`, `admin_permission()`, `manage_bookings_permission()`
- Helper methods: `validate_date()`, `validate_future_date()`, `get_pagination_params()`, `get_sorting_params()`
- Image formatting: `format_featured_image()`, `format_image()`
- HATEOAS links via `add_links()`
- Created `src/Api/Controllers/BuildingsController.php`
- GET /buildings - List with pagination, search, orderby
- GET /buildings/{id} - Single building with address, contact, rooms count
- GET /buildings/{id}/rooms - Rooms in building
- Created `src/Api/Controllers/RoomsController.php`
- GET /rooms - List with filters (building, room_type, amenities, capacity, status)
- GET /rooms/{id} - Full room data with gallery, pricing, amenities
- GET /rooms/{id}/availability - Check availability using `Availability::check_availability_with_price()`
- GET /rooms/{id}/calendar - Monthly calendar using `Availability::get_calendar_data()`
- Created `src/Api/Controllers/AvailabilityController.php`
- POST /availability/search - Search available rooms with date range, capacity, filters
- Created `src/Api/Controllers/BookingsController.php`
- POST /bookings - Create booking with guest auto-creation, conflict check
- GET /bookings - Admin list with filters (status, room, date range)
- GET /bookings/{id} - Full booking with room, guest, services
- PATCH /bookings/{id} - Update booking details
- DELETE /bookings/{id} - Cancel booking (sets status to cancelled)
- POST /bookings/{id}/confirm - Status transition
- POST /bookings/{id}/check-in - Status transition
- POST /bookings/{id}/check-out - Status transition
- Created `src/Api/Controllers/GuestsController.php`
- GET /guests - Admin list with search, status filter
- GET /guests/{id} - Guest data (excludes encrypted ID numbers)
- GET /guests/search - Quick search by name/email
- GET /guests/{id}/bookings - Guest's booking history
- Created `src/Api/Controllers/ServicesController.php`
- GET /services - List active services with categories
- GET /services/{id} - Service details with pricing info
- Created `src/Api/Controllers/PricingController.php`
- POST /pricing/calculate - Full price breakdown with room, dates, services
- Updated `src/Plugin.php`
- Added API tab to settings page with subtabs (General, Rate Limits, Endpoints)
- Enable/disable API toggle
- Configurable rate limiting with per-endpoint-type limits
- Time window configuration (10-300 seconds)
- Full endpoint documentation with HTTP method badges
- Updated `README.md` with comprehensive REST API documentation
- Endpoint reference tables (public and admin)
- Authentication examples (Application Passwords)
- Rate limiting configuration and response headers
- Code examples for common operations
**Files Created:**
- `src/Api/RestApi.php` - Main API registration
- `src/Api/RateLimiter.php` - Rate limiting
- `src/Api/ResponseFormatter.php` - Response formatting
- `src/Api/Controllers/AbstractController.php` - Base controller
- `src/Api/Controllers/BuildingsController.php` - Buildings endpoints
- `src/Api/Controllers/RoomsController.php` - Rooms endpoints
- `src/Api/Controllers/AvailabilityController.php` - Availability search
- `src/Api/Controllers/BookingsController.php` - Bookings CRUD
- `src/Api/Controllers/GuestsController.php` - Guests endpoints
- `src/Api/Controllers/ServicesController.php` - Services endpoints
- `src/Api/Controllers/PricingController.php` - Pricing calculation
- `MARKETING.md` - Marketing texts for shops (gitignored)
**Files Changed:**
- `src/Plugin.php` - API settings tab with subtabs, RestApi initialization
- `wp-bnb.php` - Version bump to 0.10.0, then 0.10.1
- `CHANGELOG.md` - Added v0.10.0 and v0.10.1 release notes
- `PLAN.md` - Marked Phase 10 as complete, reorganized roadmap
- `README.md` - Added REST API documentation section
- `.gitignore` - Added MARKETING.md to exclusions
**Learnings:**
- WordPress REST API uses `WP_REST_Controller` as base class with `register_routes()` method
- Route registration via `register_rest_route()` with namespace, route pattern, and args
- Permission callbacks return bool; use `current_user_can('edit_posts')` for admin endpoints
- Rate limiting with transients: store count and start time, check against limits
- Transient key should include client identifier and endpoint type hash
- X-RateLimit headers (Limit, Remaining, Reset) provide rate limit info to clients
- Application Passwords (WordPress 5.6+) recommended for external API access
- HATEOAS links added via `_links` key in response
- Conflict detection reuses existing `Availability::check_availability()` method
- Settings subtabs use query parameters (`subtab=general`) with conditional rendering
- Configurable options should have sensible defaults via `get_option($key, $default)`
- Marketing content (MARKETING.md) should be gitignored to keep repo focused on code
**Released:**
- v0.10.0: Committed `81c97c3` - Base REST API implementation
- v0.10.1: Committed `3f5adfb` - Configurable rate limiting with settings subtabs
- Tags: `v0.10.0`, `v0.10.1`
- Pushed to origin: dev, main, both tags
### 2026-02-03 - Version 0.11.0 (WooCommerce Integration)
**Completed:**
- Created `src/Integration/WooCommerce/Manager.php` (~435 lines)
- Core integration manager with option constants
- HPOS (High-Performance Order Storage) compatibility declaration
- `is_wc_active()`, `is_enabled()` checks
- `map_wc_status_to_booking()` for status synchronization
- `get_order_for_booking()`, `get_booking_for_order()` bidirectional lookups
- `link_booking_to_order()` for creating relationships
- Created `src/Integration/WooCommerce/ProductSync.php` (~515 lines)
- Virtual WooCommerce products for rooms (SKU: `bnb-room-{id}`)
- Auto-sync on `save_post_bnb_room` hook
- Product deletion on room deletion
- `sync_all_rooms()` for bulk synchronization
- Bidirectional meta linking (room ↔ product)
- Created `src/Integration/WooCommerce/CartHandler.php` (~545 lines)
- Cart item data structure with booking details
- Availability validation on add-to-cart
- Dynamic price calculation using `Calculator` class
- Cart item display with dates, guests, nights
- Services support in cart items
- Created `src/Integration/WooCommerce/CheckoutHandler.php` (~347 lines)
- Special requests textarea field
- Arrival time dropdown field
- Guest data pre-fill from user profile
- Final availability validation before payment
- Booking summary display
- Created `src/Integration/WooCommerce/OrderHandler.php` (~584 lines)
- Booking creation on `woocommerce_payment_complete`
- Guest record creation from billing info
- Booking reference generation (BNB-YYYY-NNNNN)
- Status synchronization on order status changes
- Order-booking bidirectional linking
- Created `src/Integration/WooCommerce/InvoiceGenerator.php` (~633 lines)
- PDF generation using existing mPDF dependency
- Configurable invoice numbering (prefix + start number)
- Auto-attach to WooCommerce order emails
- Secure storage in `wp-content/uploads/wp-bnb-invoices/`
- .htaccess protection for invoice directory
- Created `src/Integration/WooCommerce/RefundHandler.php` (~394 lines)
- Full refund triggers booking cancellation
- Partial refund records amount without cancellation
- Refund meta storage on booking
- `wp_bnb_wc_should_cancel_on_refund` filter for customization
- Created `src/Integration/WooCommerce/AdminColumns.php` (~282 lines)
- "WC Order" column in bookings list
- "Booking" column in WC orders list (supports HPOS)
- Row actions for cross-navigation
- Created `assets/css/wc-integration.css` (~443 lines)
- Cart booking display styles
- Checkout summary styles
- Booking form styles
- Status badge colors
- Created `assets/js/wc-integration.js` (~358 lines)
- `BookingForm` class for frontend forms
- AJAX sync rooms handler
- AJAX generate invoice handler
- Availability checking with debounce
- Updated `src/Plugin.php`
- WooCommerce initialization when WC is active
- WooCommerce settings tab with 4 subtabs (General, Products, Orders, Invoices)
- Asset enqueuing for WooCommerce integration
**Files Created:**
- `src/Integration/WooCommerce/Manager.php`
- `src/Integration/WooCommerce/ProductSync.php`
- `src/Integration/WooCommerce/CartHandler.php`
- `src/Integration/WooCommerce/CheckoutHandler.php`
- `src/Integration/WooCommerce/OrderHandler.php`
- `src/Integration/WooCommerce/InvoiceGenerator.php`
- `src/Integration/WooCommerce/RefundHandler.php`
- `src/Integration/WooCommerce/AdminColumns.php`
- `assets/css/wc-integration.css`
- `assets/js/wc-integration.js`
**Files Changed:**
- `src/Plugin.php` - WooCommerce initialization and settings tab
- `assets/css/admin.css` - Status badge colors for booking statuses
- `wp-bnb.php` - Version bump to 0.11.0
- `CHANGELOG.md` - Added v0.11.0 release notes
- `PLAN.md` - Marked Phase 11 as complete
**Learnings:**
- WooCommerce HPOS compatibility requires `FeaturesUtil::declare_compatibility()` in `before_woocommerce_init` hook
- Use `$order->get_meta()` / `$order->update_meta_data()` instead of `get_post_meta()` for HPOS compatibility
- Virtual products (`virtual => true`, `downloadable => false`) don't require shipping
- Cart item data stored via `woocommerce_add_cart_item_data` filter persists through session
- Dynamic pricing via `woocommerce_before_calculate_totals` hook with priority 20
- `woocommerce_payment_complete` fires after successful payment, ideal for booking creation
- Invoice attachment via `woocommerce_email_attachments` filter with order and email type detection
- Refund detection: `woocommerce_refund_created` for partial, `woocommerce_order_fully_refunded` for full
- Status badge CSS must be in admin.css for settings page (wc-integration.css is frontend only)
- HPOS orders list uses `manage_woocommerce_page_wc-orders_columns` filter (different from legacy)
**Released:**
- Committed: `2865956` on dev branch
- Merged to main (fast-forward)
- Tagged: `v0.11.0`
- Pushed to origin: dev, main, v0.11.0

283
PLAN.md
View File

@@ -84,43 +84,43 @@ This document outlines the implementation plan for the WP BnB Management plugin.
- [x] Email notifications
- [x] Booking confirmation
## Phase 4: Guest Management (v0.4.0)
## Phase 4: Guest Management (v0.4.0) - Complete
### Custom Post Type: Guests
- [ ] Personal information (name, email, phone)
- [ ] Address fields
- [ ] ID/Passport information
- [ ] Booking history reference
- [ ] Notes and preferences
- [x] Personal information (name, email, phone)
- [x] Address fields
- [x] ID/Passport information
- [x] Booking history reference
- [x] Notes and preferences
### Privacy & Compliance
- [ ] GDPR compliance features
- [ ] Data export functionality
- [ ] Data deletion on request
- [ ] Consent tracking
- [x] GDPR compliance features
- [x] Data export functionality
- [x] Data deletion on request
- [x] Consent tracking
## Phase 5: Additional Services (v0.5.0)
## Phase 5: Additional Services (v0.5.0) - Complete
### Service Options
- [ ] Custom Post Type: Services
- [ ] Price per service (or included)
- [ ] Per-booking or per-night pricing
- [ ] Service categories
- [x] Custom Post Type: Services
- [x] Price per service (or included)
- [x] Per-booking or per-night pricing
- [x] Service categories
### Booking Services
- [ ] Service selection during booking
- [ ] Automatic price calculation
- [ ] Service summary display
- [x] Service selection during booking
- [x] Automatic price calculation
- [x] Service summary display
## Phase 6: Frontend Features (v0.6.0)
## Phase 6: Frontend Features (v0.6.0) - Complete
### Search & Filtering
- [ ] Room search with filters
- [x] Room search with filters
- Date range (availability)
- Capacity
- Room type
@@ -130,71 +130,95 @@ This document outlines the implementation plan for the WP BnB Management plugin.
### Display Components
- [ ] Building list/grid shortcode
- [ ] Room list/grid shortcode
- [ ] Room detail template
- [ ] Availability widget
- [x] Building list/grid shortcode
- [x] Room list/grid shortcode
- [x] Room detail template
- [x] Availability widget
### Gutenberg Blocks
- [ ] Building block
- [ ] Room block
- [ ] Room search block
- [ ] Booking form block
- [x] Building block
- [x] Room block
- [x] Room search block
- [x] Buildings list block
- [x] Rooms list block
### Widgets
- [ ] Similar rooms widget
- [ ] Building rooms widget
- [ ] Availability calendar widget
- [x] Similar rooms widget
- [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)
## Phase 8: Dashboard & Reports (v0.8.0) - Complete
### Admin Dashboard
- [ ] Occupancy overview
- [ ] Upcoming check-ins/check-outs
- [ ] Revenue summary
- [ ] Quick actions
- [x] Occupancy overview
- [x] Upcoming check-ins/check-outs
- [x] Revenue summary
- [x] Quick actions
### Reports
- [ ] Occupancy report
- [ ] Revenue report
- [ ] Guest statistics
- [ ] Export functionality (CSV, PDF)
- [x] Occupancy report
- [x] Revenue report
- [x] Guest statistics
- [x] Export functionality (CSV, PDF)
## Phase 9: Prometheus Metrics (v0.9.0) - Complete
- [x] Meaningful Metrics for this Plugin:
- Inventory: buildings, rooms by status, services by status
- Bookings: by status, check-ins/check-outs today, upcoming, avg duration
- Guests: total, by status, repeat guests, new this month
- Occupancy: current rate, monthly rate, occupied rooms, bed capacity
- Revenue: this month, YTD, average booking value, services revenue
- [x] Example Grafana Dashboard:
- Pre-configured dashboard JSON at `assets/grafana/wp-bnb-dashboard.json`
- Automatic registration with wp-prometheus
- 24 panels with gauges, pie charts, and stat displays
- [x] Update settings page to enable/disable metrics
### Phase 10: API Endpoints (v0.10.0) - Complete
- [x] REST API for rooms (list, details, availability, calendar)
- [x] REST API for availability (search available rooms)
- [x] REST API for bookings (CRUD, status transitions)
- [x] REST API for buildings, guests, services, pricing
- [x] Authentication (Application Passwords, edit_posts capability)
- [x] Transient-based rate limiting with tiered limits
- [x] API settings tab with enable/disable toggles
### Phase 11: WooCommerce Integration (v0.11.0) - Complete
- [x] Payment processing
- [x] Invoice generation
- [x] Order management
- [x] Refund handling
## Phase 12: Security Audit (v0.12.0)
- [ ] Check for Wordpress best-practices
- [ ] Review the code for OWASP Top 10, including XSS, XSRF, SQLi and other critical threads
- [ ] Test the API-Endpoints against a local live system under <http://localhost:9080/> for common vulnerabilities
## Future Considerations (v1.0.0+)
### WooCommerce Integration (Optional)
- [ ] Payment processing
- [ ] Invoice generation
- [ ] Order management
- [ ] Refund handling
### API Endpoints
- [ ] REST API for rooms
- [ ] REST API for availability
- [ ] REST API for bookings
- [ ] Authentication and rate limiting
### Multi-language Support
- [ ] Full translation support
@@ -214,53 +238,90 @@ This document outlines the implementation plan for the WP BnB Management plugin.
```text
wp-bnb/
├── wp-bnb.php # Main plugin file
├── composer.json # Dependencies
├── src/ # PHP source (PSR-4)
│ ├── Plugin.php # Main plugin class
│ ├── License/ # License management
└── Manager.php
│ ├── PostTypes/ # Custom post types
├── Building.php
├── Room.php
├── Booking.php
│ │ ├── Guest.php
│ └── Service.php
├── Taxonomies/ # Custom taxonomies
├── RoomType.php
│ └── Amenity.php
│ ├── Admin/ # Admin functionality
│ │ ├── Dashboard.php
│ │ ├── Settings.php
│ │ ── MetaBoxes.php
├── Frontend/ # Frontend functionality
│ ├── Shortcodes.php
│ │ ├── Widgets.php
│ │ ── Search.php
├── wp-bnb.php # Main plugin file (entry point)
├── composer.json # Composer configuration
├── composer.lock # Dependency lock file
├── CHANGELOG.md # Version history
├── CLAUDE.md # AI assistant documentation
├── PLAN.md # Implementation roadmap
├── README.md # User documentation
├── .editorconfig # Editor configuration
├── .gitignore # Git ignore patterns
├── .gitmodules # Git submodule configuration
├── .gitea/
│ └── workflows/
└── release.yml # CI/CD release pipeline
├── src/ # PHP source (PSR-4: Magdev\WpBnb)
├── Plugin.php # Main plugin singleton
│ ├── Admin/ # Admin pages
│ │ ├── Calendar.php # Availability calendar page
│ │ ├── Dashboard.php # Dashboard page with statistics
│ │ ── Reports.php # Reports page with exports
│ └── Seasons.php # Seasons management page
│ ├── Api/ # REST API (v0.10.0+)
│ │ ├── RestApi.php # Main API registration
│ │ ── RateLimiter.php # Transient-based rate limiting
│ │ ├── ResponseFormatter.php # Standardized responses
│ │ └── Controllers/ # API endpoint controllers
│ │ ├── AbstractController.php
│ │ ├── BookingsController.php
│ │ ├── BuildingsController.php
│ │ ├── GuestsController.php
│ │ ├── PricingController.php
│ │ ├── RoomsController.php
│ │ └── ServicesController.php
│ ├── Blocks/ # Gutenberg blocks
│ │ ── Building.php
│ │ ├── Room.php
│ │ └── Search.php
│ ├── Pricing/ # Pricing logic
│ │ ├── Calculator.php
│ │ └── PricingTier.php
│ │ ── BlockRegistrar.php # Block registration and rendering
│ ├── Booking/ # Booking logic
│ │ ├── Manager.php
│ │ ── Calendar.php
│ └── Workflow.php
└── Integration/ # Third-party integrations
── CF7.php
├── templates/ # Twig templates
├── admin/
├── frontend/
└── email/
│ │ ├── Availability.php # Availability checking
│ │ ── EmailNotifier.php # Email notifications
├── Frontend/ # Frontend components
│ ├── Search.php # Room search and AJAX handlers
── Shortcodes.php # All shortcode handlers
│ │ └── Widgets/ # WordPress widgets
│ ├── AvailabilityCalendar.php
│ ├── BuildingRooms.php
│ └── SimilarRooms.php
│ ├── Integration/ # Third-party integrations
│ │ ├── CF7.php # Contact Form 7 integration
│ │ └── Prometheus.php # Prometheus metrics
│ ├── License/ # License management
│ │ ├── Manager.php # License validation and activation
│ │ └── Updater.php # Auto-update system
│ ├── PostTypes/ # Custom post types
│ │ ├── Booking.php
│ │ ├── Building.php
│ │ ├── Guest.php
│ │ ├── Room.php
│ │ └── Service.php
│ ├── Pricing/ # Pricing logic
│ │ ├── Calculator.php # Price calculation
│ │ ├── PricingTier.php # Pricing tier enum
│ │ └── Season.php # Seasonal pricing
│ ├── Privacy/ # Privacy & GDPR
│ │ └── Manager.php # Data export/deletion
│ └── Taxonomies/ # Custom taxonomies
│ ├── Amenity.php # Amenities (tags)
│ ├── RoomType.php # Room types (categories)
│ └── ServiceCategory.php # Service categories
├── assets/ # CSS, JS, images
│ ├── css/
│ ├── js/
└── images/
├── languages/ # Translation files
│ ├── admin.css # Admin styles
│ ├── blocks-editor.css # Gutenberg editor styles
├── cf7-integration.css # CF7 form styles
│ │ └── frontend.css # Frontend styles
│ ├── grafana/
│ │ └── wp-bnb-dashboard.json # Pre-configured Grafana dashboard
│ └── js/
│ ├── admin.js # Admin scripts
│ ├── blocks-editor.js # Gutenberg editor scripts
│ ├── cf7-integration.js # CF7 form scripts
│ └── frontend.js # Frontend scripts
├── languages/ # Translation files (.pot/.po/.mo)
├── lib/ # Git submodules
│ └── wc-licensed-product-client/
├── releases/ # Release packages (git-ignored)
├── templates/ # Twig templates (reserved for future)
└── vendor/ # Composer dependencies
```
@@ -286,14 +347,18 @@ The plugin will provide extensive hooks for customization:
## Version Milestones
| Version | Focus | Target |
| ------- | --------------- | -------- |
| ------- | ----------------------- | -------- |
| 0.0.1 | Initial setup | Complete |
| 0.1.0 | Data structures | Complete |
| 0.2.0 | Pricing | Complete |
| 0.3.0 | Bookings | Complete |
| 0.4.0 | Guests | TBD |
| 0.5.0 | Services | TBD |
| 0.6.0 | Frontend | TBD |
| 0.7.0 | CF7 Integration | TBD |
| 0.8.0 | Dashboard | TBD |
| 0.4.0 | Guests | Complete |
| 0.5.0 | Services | Complete |
| 0.6.0 | Frontend | Complete |
| 0.7.0 | CF7 Integration | Complete |
| 0.8.0 | Dashboard | Complete |
| 0.9.0 | Prometheus Metrics | Complete |
| 0.10.0 | API Endpoints | Complete |
| 0.11.0 | WooCommerce Integration | Complete |
| 0.12.0 | Security Audit | TBD |
| 1.0.0 | Stable Release | TBD |

651
README.md
View File

@@ -10,17 +10,28 @@ WP BnB Management enables WordPress to act as a full management system for B&B h
- **Multi-Property Support**: Manage multiple buildings, each with multiple rooms
- **Flexible Pricing**: Configure short-term (nights), mid-term (weeks), and long-term (months) pricing
- **Seasonal Pricing**: Set price modifiers for high/low seasons
- **Booking Management**: Track reservations from inquiry to checkout
- **Guest Management**: Store guest information securely with GDPR compliance
- **Data Encryption**: Sensitive guest data (ID/passport) encrypted at rest
- **Additional Services**: Offer extras like breakfast, parking, or tours
- **Frontend Integration**: Gutenberg blocks, widgets, and shortcodes
- **Contact Form 7 Integration**: Accept booking requests through forms
- **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 and inquiries through CF7 forms
- **WooCommerce Integration**: Accept payments, auto-sync rooms as products, generate invoices
- **Dashboard**: Comprehensive admin dashboard with statistics and charts
- **Reports**: Detailed reports with CSV and PDF export
- **Prometheus Metrics**: Expose operational metrics for monitoring with Grafana
- **REST API**: Comprehensive API for external integrations
### Requirements
- WordPress 6.0 or higher
- PHP 8.3 or higher
- Valid license key
- Contact Form 7 (optional, for booking forms)
- WooCommerce 8.0+ (optional, for payments and invoicing)
## Installation
@@ -44,6 +55,23 @@ WP BnB Management enables WordPress to act as a full management system for B&B h
- **Business Name**: Your B&B business name
- **Currency**: Select your preferred currency (CHF, EUR, USD, GBP)
- **Business Address**: Street, city, postal code, country
- **Contact Information**: Email, phone, website
- **Social Media**: Facebook, Instagram, X (Twitter), LinkedIn, TripAdvisor
### Update Settings
- **Update Notifications**: Enable/disable update notifications in WordPress
- **Automatic Updates**: Enable/disable automatic plugin updates
- **Check Frequency**: How often to check for updates (1-168 hours)
### Development Mode
The plugin automatically detects local development environments and bypasses license validation. Supported environments:
- localhost, 127.0.0.1, ::1
- Domains ending in .local, .test, .localhost, .dev, .ddev.site
- Private IP ranges (10.x.x.x, 172.16-31.x.x, 192.168.x.x)
## Usage
@@ -76,31 +104,271 @@ WP BnB Management enables WordPress to act as a full management system for B&B h
2. View guest records and booking history
3. Manage guest information
### Dashboard
The dashboard (**WP BnB → Dashboard**) provides an at-a-glance overview of your B&B operations:
**Statistics Cards:**
- **Occupancy Rate** - Current percentage of rooms occupied with trend indicator
- **Monthly Revenue** - This month's revenue with comparison to previous month
- **Total Bookings** - Active bookings count with status breakdown
- **Total Guests** - Guest count with new guests this month
**Today's Activity:**
- Check-ins scheduled for today with guest names and room assignments
- Check-outs scheduled for today
- Quick links to manage each booking
**Upcoming Bookings:**
- Next 7 days of arrivals
- Guest name, room, dates, and booking status
- Direct links to booking details
**Quick Actions:**
- New Booking - Create a booking directly
- New Guest - Add a guest record
- View Calendar - Open the availability calendar
- View Reports - Access detailed reports
**Trend Charts:**
- 30-day occupancy trend line chart
- 6-month revenue bar chart
### Reports
Access detailed reports at **WP BnB → Reports**. All reports support date range filtering and export.
**Occupancy Report:**
- Overall occupancy percentage for the selected period
- Breakdown by room showing nights booked, available, and occupancy rate
- Visual progress bars for easy comparison
- Total nights booked vs. available across all rooms
**Revenue Report:**
- Total revenue for the selected period
- Revenue breakdown by room
- Revenue breakdown by pricing tier (short-term, mid-term, long-term)
- Revenue from additional services
- Average booking value
**Guest Statistics:**
- Total guests and new guests in period
- Repeat guest rate (guests with 2+ bookings)
- Top guests by total spending
- Guest nationality distribution
- Average spending per guest
**Export Options:**
- **CSV Export** - Download report data as spreadsheet-compatible CSV
- **PDF Export** - Generate formatted PDF reports for printing or archiving
**Date Filters:**
- This Month (default)
- Last Month
- This Year
- Custom date range
## Shortcodes
Display buildings and rooms on your site using shortcodes:
```txt
[wp_bnb_buildings]
[wp_bnb_rooms building="123"]
[wp_bnb_room_search]
[bnb_buildings] - List all buildings (grid/list layout)
[bnb_rooms building="123"] - List rooms, optionally filtered by building
[bnb_room_search] - Interactive room search form
[bnb_building id="123"] - Display a single building
[bnb_room id="456"] - Display a single room with availability
```
### Shortcode Attributes
**`[bnb_buildings]`** and **`[bnb_rooms]`**:
- `layout` - "grid" or "list" (default: grid)
- `columns` - 1-4 columns (default: 3)
- `limit` - Number of items (default: 12)
- `orderby` - title, date, price, capacity (default: title)
- `order` - ASC or DESC (default: ASC)
**`[bnb_rooms]`** additional attributes:
- `building` - Building ID to filter by
- `room_type` - Room type slug to filter by
- `amenities` - Comma-separated amenity slugs
## Gutenberg Blocks
The following blocks are available in the block editor:
- **Building** - Display a single building
- **Room** - Display a single room
- **Room Search** - Search and filter rooms
- **Booking Form** - Accept booking requests
- **Building** - Display a single building with details
- **Room** - Display a single room with availability form
- **Room Search** - Interactive search form with filters
- **Buildings List** - Display buildings grid/list
- **Rooms List** - Display rooms grid/list with filters
## Widgets
Available sidebar widgets:
- **Similar Rooms** - Show rooms similar to the current one
- **Similar Rooms** - Show rooms from same building or room type
- **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
<div class="wp-bnb-booking-form">
<h3>Book Your Stay</h3>
<div class="wp-bnb-form-row">
[bnb_building_select building first_as_label:"All Locations"]
</div>
<div class="wp-bnb-form-row">
[bnb_room_select* room building_field:"building" first_as_label:"Select a Room"]
</div>
<div class="wp-bnb-form-row-2col">
<div class="wp-bnb-form-field">
<label>Check-in</label>
[bnb_date_checkin* check_in]
</div>
<div class="wp-bnb-form-field">
<label>Check-out</label>
[bnb_date_checkout* check_out]
</div>
</div>
<div class="wp-bnb-availability-status"></div>
<div class="wp-bnb-form-row">
<label>Number of Guests</label>
[bnb_guests* guests min:1 max:10 default:2]
</div>
<div class="wp-bnb-price-display"></div>
<h4>Your Information</h4>
<div class="wp-bnb-form-row-2col">
<div class="wp-bnb-form-field">
<label>First Name</label>
[text* first_name]
</div>
<div class="wp-bnb-form-field">
<label>Last Name</label>
[text* last_name]
</div>
</div>
<div class="wp-bnb-form-row">
<label>Email</label>
[email* your_email]
</div>
<div class="wp-bnb-form-row">
<label>Phone</label>
[tel your_phone]
</div>
<div class="wp-bnb-form-row">
<label>Message</label>
[textarea your_message]
</div>
[submit "Request Booking"]
</div>
```
### Example Inquiry Form
For room-specific inquiries, add the `wp-bnb-inquiry-form` class:
```txt
<div class="wp-bnb-inquiry-form">
<h3>Inquire About This Room</h3>
[hidden room default:123]
<div class="wp-bnb-form-row">
<label>Your Name</label>
[text* your_name]
</div>
<div class="wp-bnb-form-row">
<label>Email</label>
[email* your_email]
</div>
<div class="wp-bnb-form-row">
<label>Your Question</label>
[textarea* your_message]
</div>
[submit "Send Inquiry"]
</div>
```
### 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
@@ -119,11 +387,368 @@ add_action( 'wp_bnb_before_booking_create', function( $booking_data ) {
} );
```
## Prometheus Metrics
The plugin integrates with [WP Prometheus](https://src.bundespruefstelle.ch/magdev/wp-prometheus) to expose operational metrics for monitoring with Prometheus and Grafana.
### Enabling Metrics
1. Install and activate the WP Prometheus plugin
2. Navigate to **WP BnB → Settings → Metrics**
3. Enable "Expose BnB metrics via Prometheus"
4. Metrics will be available at your site's `/metrics/` endpoint
### Available Metrics
**Inventory Metrics:**
- `wp_bnb_buildings_total` - Total number of buildings
- `wp_bnb_rooms_total{status}` - Rooms by status (available, occupied, maintenance, inactive)
- `wp_bnb_services_total{status}` - Services by status (active, inactive)
- `wp_bnb_total_capacity_beds` - Total bed capacity across all rooms
**Booking Metrics:**
- `wp_bnb_bookings_total{status}` - Bookings by status (pending, confirmed, checked_in, checked_out, cancelled)
- `wp_bnb_checkins_today` - Check-ins scheduled for today
- `wp_bnb_checkouts_today` - Check-outs scheduled for today
- `wp_bnb_bookings_upcoming_7days` - Bookings starting in next 7 days
- `wp_bnb_booking_avg_duration_nights` - Average booking duration
**Occupancy Metrics:**
- `wp_bnb_occupancy_rate_current` - Current room occupancy rate (percentage)
- `wp_bnb_occupancy_rate_this_month` - Monthly occupancy rate (percentage)
- `wp_bnb_rooms_currently_occupied` - Rooms currently occupied
**Revenue Metrics:**
- `wp_bnb_revenue_this_month{currency}` - Revenue for current month
- `wp_bnb_revenue_ytd{currency}` - Revenue year to date
- `wp_bnb_booking_avg_value{currency}` - Average booking value
- `wp_bnb_services_revenue_this_month{currency}` - Services revenue this month
**Guest Metrics:**
- `wp_bnb_guests_total` - Total registered guests
- `wp_bnb_guests_by_status{status}` - Guests by status (active, blocked, vip)
- `wp_bnb_guests_repeat` - Guests with more than one booking
- `wp_bnb_guests_new_this_month` - New guests this month
### Grafana Dashboard
A pre-configured Grafana dashboard is included at `assets/grafana/wp-bnb-dashboard.json`. If WP Prometheus is installed, the dashboard is automatically registered and available for export.
The dashboard includes:
- Occupancy gauges with color-coded thresholds
- Bookings, rooms, and guests pie charts by status
- Revenue and guest statistics panels
- Today's check-ins/check-outs
- Trend indicators
## WooCommerce Integration
The plugin integrates with WooCommerce to enable payment processing, automatic invoicing, and seamless order management.
### Enabling WooCommerce Integration
1. Install and activate WooCommerce 8.0 or higher
2. Navigate to **WP BnB → Settings → WooCommerce**
3. Enable "Enable WooCommerce Integration"
4. Configure product sync and invoice settings
### Features
**Product Synchronization:**
- Rooms are automatically synced as virtual WooCommerce products
- Products use SKU format `bnb-room-{id}` for tracking
- Price, description, and images are kept in sync
- Products created/updated on room save, deleted on room deletion
- Manual "Sync All Rooms" button in settings
**Cart & Checkout:**
- Add room bookings to WooCommerce cart with dates and guest count
- Real-time availability validation prevents double-booking
- Dynamic pricing calculated from room rates and services
- Special checkout fields for arrival time and special requests
- Guest information pre-filled from user profile
**Booking Creation:**
- Bookings automatically created on successful payment
- Guest records created from billing information
- Booking linked to WooCommerce order for reference
- Booking reference displayed on order confirmation
**Order-Booking Synchronization:**
- Order status changes sync to booking status:
- Order completed → Booking confirmed
- Order cancelled → Booking cancelled
- Order refunded → Booking cancelled (full refund)
- Partial refunds recorded without cancellation
- Bidirectional linking between orders and bookings
**PDF Invoices:**
- Automatic PDF invoice generation
- Configurable invoice number prefix (default: `INV-`)
- Sequential invoice numbering with configurable start number
- Auto-attach to WooCommerce order emails
- Secure storage in `wp-content/uploads/wp-bnb-invoices/`
- Manual generation from order admin
### WooCommerce Settings
**General Subtab:**
- Enable/disable WooCommerce integration
- Enable automatic product sync
- Enable auto-attach invoices to emails
**Products Subtab:**
- View sync status and product count
- Manual "Sync All Rooms Now" button
- Product category assignment
**Orders Subtab:**
- Order-booking status mapping
- View linked orders and bookings
**Invoices Subtab:**
- Invoice number prefix
- Starting invoice number
- Company details for invoice header
- PDF styling options
### HPOS Compatibility
The integration is fully compatible with WooCommerce High-Performance Order Storage (HPOS). Order meta is accessed using the modern `$order->get_meta()` and `$order->update_meta_data()` methods.
### Admin Columns
- **Bookings list**: "WC Order" column with link to order
- **WooCommerce Orders list**: "Booking" column with link to booking
## REST API
The plugin provides a comprehensive REST API for integration with external applications, mobile apps, and third-party services.
### Enabling the API
1. Navigate to **WP BnB → Settings → API**
2. In the **General** subtab, enable "Enable REST API"
3. Optionally enable rate limiting for protection against abuse
4. Configure rate limits in the **Rate Limits** subtab
5. View all available endpoints in the **Endpoints** subtab
### Base URL
All API endpoints are prefixed with:
```txt
https://your-site.com/wp-json/wp-bnb/v1/
```
### Authentication
**Public endpoints** (room listings, availability checks) require no authentication.
**Admin endpoints** (booking management, guest data) require authentication via:
- **Cookie + Nonce**: For same-domain JavaScript requests
- **Application Passwords**: For external applications (WordPress 5.6+, recommended)
To create an Application Password:
1. Go to **Users → Profile**
2. Scroll to "Application Passwords"
3. Enter a name and click "Add New Application Password"
4. Use the generated password with HTTP Basic Auth
```bash
curl -u "username:app-password" https://site.com/wp-json/wp-bnb/v1/bookings
```
### Public Endpoints
| Method | Endpoint | Description |
| ------ | -------- | ----------- |
| GET | `/buildings` | List all buildings |
| GET | `/buildings/{id}` | Get building details |
| GET | `/buildings/{id}/rooms` | Get rooms in a building |
| GET | `/rooms` | List/search rooms |
| GET | `/rooms/{id}` | Get room details |
| GET | `/rooms/{id}/availability` | Check room availability |
| GET | `/rooms/{id}/calendar` | Get monthly calendar data |
| POST | `/availability/search` | Search available rooms |
| GET | `/services` | List all services |
| GET | `/services/{id}` | Get service details |
| POST | `/pricing/calculate` | Calculate booking price |
| POST | `/bookings` | Create a new booking (pending status) |
### Admin Endpoints
| Method | Endpoint | Description |
| ------ | -------- | ----------- |
| GET | `/bookings` | List all bookings |
| GET | `/bookings/{id}` | Get booking details |
| PATCH | `/bookings/{id}` | Update a booking |
| DELETE | `/bookings/{id}` | Cancel a booking |
| POST | `/bookings/{id}/confirm` | Confirm a pending booking |
| POST | `/bookings/{id}/check-in` | Check in a guest |
| POST | `/bookings/{id}/check-out` | Check out a guest |
| GET | `/guests` | List all guests |
| GET | `/guests/{id}` | Get guest details |
| GET | `/guests/search` | Search guests |
| GET | `/guests/{id}/bookings` | Get guest's booking history |
### Rate Limiting
When enabled, rate limits are applied per client (by user ID or IP address). Configure limits in **Settings → API → Rate Limits**.
**Default Limits:**
| Type | Default | Applies To |
| ---- | ------- | ---------- |
| Public | 60/min | Room/building listings |
| Availability | 30/min | Availability and calendar endpoints |
| Booking | 10/min | Booking creation |
| Admin | 120/min | All admin endpoints |
**Configuration Options:**
- **Time Window**: 10-300 seconds (default: 60 seconds)
- **Per-endpoint limits**: Customize for each endpoint type
- **Rate limiting toggle**: Enable/disable without losing settings
Rate limit headers are included in responses:
- `X-RateLimit-Limit`: Maximum requests allowed
- `X-RateLimit-Remaining`: Requests remaining in window
- `X-RateLimit-Reset`: Unix timestamp when limit resets
### Example: Check Room Availability
```bash
curl "https://site.com/wp-json/wp-bnb/v1/rooms/42/availability?check_in=2026-03-15&check_out=2026-03-20"
```
Response:
```json
{
"available": true,
"room_id": 42,
"check_in": "2026-03-15",
"check_out": "2026-03-20",
"nights": 5,
"pricing": {
"base_price": 500.00,
"seasonal_modifier": 1.0,
"weekend_surcharge": 40.00,
"total": 540.00,
"currency": "CHF"
}
}
```
### Example: Create a Booking
```bash
curl -X POST https://site.com/wp-json/wp-bnb/v1/bookings \
-H "Content-Type: application/json" \
-d '{
"room_id": 42,
"check_in": "2026-03-15",
"check_out": "2026-03-20",
"guests": 2,
"guest_info": {
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"phone": "+41 79 123 4567"
},
"services": [
{"service_id": 5, "quantity": 1}
],
"notes": "Late arrival expected"
}'
```
Response:
```json
{
"id": 123,
"reference": "BNB-2026-00042",
"status": "pending",
"room": {
"id": 42,
"title": "Deluxe Suite"
},
"check_in": "2026-03-15",
"check_out": "2026-03-20",
"nights": 5,
"guests": 2,
"pricing": {
"room_total": 540.00,
"services_total": 50.00,
"grand_total": 590.00,
"currency": "CHF"
},
"_links": {
"self": [{"href": "https://site.com/wp-json/wp-bnb/v1/bookings/123"}]
}
}
```
### Example: Search Available Rooms
```bash
curl -X POST https://site.com/wp-json/wp-bnb/v1/availability/search \
-H "Content-Type: application/json" \
-d '{
"check_in": "2026-03-15",
"check_out": "2026-03-20",
"guests": 2,
"amenities": ["wifi", "parking"]
}'
```
### Error Responses
Errors follow WordPress REST API conventions:
```json
{
"code": "rest_not_found",
"message": "Room not found.",
"data": {
"status": 404
}
}
```
Common error codes:
- `rest_invalid_param` (400): Invalid request parameters
- `rest_forbidden` (403): Insufficient permissions
- `rest_not_found` (404): Resource not found
- `rest_conflict` (409): Booking conflict
- `rest_rate_limit_exceeded` (429): Rate limit exceeded
## Frequently Asked Questions
### Do I need a license to use this plugin?
Yes, a valid license is required to use the frontend features. The admin functionality works without a license for evaluation purposes.
Yes, a valid license is required to use the frontend features in production. The admin functionality works without a license for evaluation purposes. Local development environments (localhost, .local, .test, .dev domains) automatically bypass license validation.
### Can I manage multiple properties?
@@ -135,7 +760,11 @@ Yes, guest data can be exported and deleted on request, and consent is tracked a
### Does it integrate with WooCommerce?
WooCommerce integration for payments is planned for a future release.
Yes! WooCommerce integration is available for payment processing and invoicing. Rooms are synced as virtual products, bookings are created on successful payment, and PDF invoices are auto-generated and attached to order emails. Navigate to **WP BnB → Settings → WooCommerce** to enable and configure the integration.
### How is guest data secured?
Sensitive guest data like passport/ID numbers are encrypted using AES-256-CBC encryption before storage. The encryption key is derived from your WordPress AUTH_KEY, ensuring data is secure at rest.
## Changelog

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
/**
* WP BnB Block Editor Styles
*
* @package Magdev\WpBnb
*/
/* Block placeholder styling */
.wp-bnb-block-placeholder {
padding: 20px;
background: #f0f0f0;
border: 2px dashed #ccc;
text-align: center;
color: #666;
border-radius: 4px;
}
/* Server-side render container */
.wp-block-wp-bnb-building,
.wp-block-wp-bnb-room,
.wp-block-wp-bnb-room-search,
.wp-block-wp-bnb-buildings,
.wp-block-wp-bnb-rooms {
margin-bottom: 1em;
}
/* Placeholder in editor */
.wp-block-wp-bnb-building .components-placeholder,
.wp-block-wp-bnb-room .components-placeholder,
.wp-block-wp-bnb-room-search .components-placeholder,
.wp-block-wp-bnb-buildings .components-placeholder,
.wp-block-wp-bnb-rooms .components-placeholder {
min-height: 150px;
}
/* Loading spinner container */
.wp-block-wp-bnb-building .components-spinner,
.wp-block-wp-bnb-room .components-spinner,
.wp-block-wp-bnb-room-search .components-spinner,
.wp-block-wp-bnb-buildings .components-spinner,
.wp-block-wp-bnb-rooms .components-spinner {
margin: 0 auto;
}
/* Inspector control sections */
.wp-block-wp-bnb-building .components-panel__body,
.wp-block-wp-bnb-room .components-panel__body,
.wp-block-wp-bnb-room-search .components-panel__body,
.wp-block-wp-bnb-buildings .components-panel__body,
.wp-block-wp-bnb-rooms .components-panel__body {
padding-bottom: 16px;
}
/* Select control styling */
.wp-block-wp-bnb-building .components-select-control__input,
.wp-block-wp-bnb-room .components-select-control__input,
.wp-block-wp-bnb-room-search .components-select-control__input,
.wp-block-wp-bnb-buildings .components-select-control__input,
.wp-block-wp-bnb-rooms .components-select-control__input {
min-width: 200px;
}
/* Preview container in editor */
.wp-bnb-editor-preview {
pointer-events: none;
opacity: 0.9;
}
/* Disable interactive elements in preview */
.wp-bnb-editor-preview a,
.wp-bnb-editor-preview button,
.wp-bnb-editor-preview input,
.wp-bnb-editor-preview select {
pointer-events: none;
}
/* Add visual indicator that this is a preview */
.wp-bnb-editor-preview::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.1);
pointer-events: none;
}

View File

@@ -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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,443 @@
/**
* WooCommerce Integration Styles
*
* Styles for WP BnB - WooCommerce integration
*
* @package Magdev\WpBnb
*/
/* ==========================================================================
Cart Item - Booking Data Display
========================================================================== */
.woocommerce-cart .cart_item .bnb-booking-info {
margin-top: 8px;
padding: 10px;
background: #f9f9f9;
border-radius: 4px;
font-size: 0.9em;
}
.woocommerce-cart .cart_item .bnb-booking-info dt {
display: inline-block;
font-weight: 600;
min-width: 80px;
color: #50575e;
}
.woocommerce-cart .cart_item .bnb-booking-info dd {
display: inline-block;
margin: 0 0 4px 0;
}
/* ==========================================================================
Checkout - Booking Summary
========================================================================== */
.bnb-checkout-booking-summary {
margin: 20px 0;
padding: 15px;
background: #f8f9fa;
border: 1px solid #e1e4e8;
border-radius: 4px;
}
.bnb-checkout-booking-summary h3 {
margin: 0 0 15px 0;
padding: 0 0 10px 0;
border-bottom: 1px solid #e1e4e8;
font-size: 1.1em;
color: #2271b1;
}
.bnb-booking-item {
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px dashed #e1e4e8;
}
.bnb-booking-item:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.bnb-booking-item strong {
display: block;
font-size: 1em;
color: #1d2327;
}
.bnb-building-name {
display: block;
font-size: 0.85em;
color: #787c82;
margin-top: 2px;
}
.bnb-booking-details {
margin-top: 8px;
font-size: 0.9em;
color: #50575e;
}
.bnb-booking-details span {
display: inline-block;
margin-right: 15px;
}
.bnb-booking-details span::before {
content: "•";
margin-right: 5px;
color: #c3c4c7;
}
.bnb-booking-details span:first-child::before {
content: "";
margin-right: 0;
}
/* ==========================================================================
Thank You Page - Booking Confirmation
========================================================================== */
.woocommerce-booking-confirmation {
margin: 30px 0;
padding: 20px;
background: #f0f8f1;
border: 1px solid #d1e7d7;
border-radius: 4px;
}
.woocommerce-booking-confirmation h2 {
margin: 0 0 15px 0;
color: #00a32a;
font-size: 1.3em;
}
.woocommerce-table--booking-details {
width: 100%;
margin-bottom: 15px;
}
.woocommerce-table--booking-details th {
text-align: left;
width: 40%;
padding: 8px 12px 8px 0;
color: #50575e;
font-weight: 600;
}
.woocommerce-table--booking-details td {
padding: 8px 0;
}
.woocommerce-table--booking-details small {
display: block;
color: #787c82;
margin-top: 2px;
}
.woocommerce-booking-reference {
margin: 20px 0;
padding: 10px 15px;
background: #f6f7f7;
border-radius: 4px;
}
.woocommerce-booking-reference .bnb-status-badge {
margin-left: 10px;
}
/* ==========================================================================
Status Badges
========================================================================== */
.bnb-status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.bnb-status-pending {
background: #fff8e5;
color: #9d6a00;
}
.bnb-status-confirmed {
background: #e6f4ea;
color: #0a6e31;
}
.bnb-status-checked_in {
background: #e3f2fd;
color: #1565c0;
}
.bnb-status-checked_out {
background: #f5f5f5;
color: #616161;
}
.bnb-status-cancelled {
background: #ffeaea;
color: #d63638;
}
/* ==========================================================================
Booking Form (Frontend)
========================================================================== */
.bnb-wc-booking-form {
margin: 20px 0;
padding: 20px;
background: #fff;
border: 1px solid #e1e4e8;
border-radius: 4px;
}
.bnb-wc-booking-form h3 {
margin: 0 0 20px 0;
padding: 0 0 15px 0;
border-bottom: 1px solid #e1e4e8;
}
.bnb-wc-booking-form .form-row {
margin-bottom: 15px;
}
.bnb-wc-booking-form label {
display: block;
margin-bottom: 5px;
font-weight: 600;
}
.bnb-wc-booking-form input[type="date"],
.bnb-wc-booking-form input[type="number"],
.bnb-wc-booking-form select {
width: 100%;
padding: 10px;
border: 1px solid #c3c4c7;
border-radius: 4px;
font-size: 1em;
}
.bnb-wc-booking-form input[type="date"]:focus,
.bnb-wc-booking-form input[type="number"]:focus,
.bnb-wc-booking-form select:focus {
border-color: #2271b1;
outline: none;
box-shadow: 0 0 0 1px #2271b1;
}
.bnb-wc-booking-form .form-row-inline {
display: flex;
gap: 15px;
}
.bnb-wc-booking-form .form-row-inline > div {
flex: 1;
}
/* Availability status */
.bnb-availability-status {
margin: 15px 0;
padding: 10px 15px;
border-radius: 4px;
display: flex;
align-items: center;
gap: 8px;
}
.bnb-availability-status.checking {
background: #f6f7f7;
color: #787c82;
}
.bnb-availability-status.available {
background: #e6f4ea;
color: #0a6e31;
}
.bnb-availability-status.unavailable {
background: #ffeaea;
color: #d63638;
}
.bnb-availability-status .dashicons {
font-size: 18px;
width: 18px;
height: 18px;
}
/* Price display */
.bnb-wc-price-display {
margin: 15px 0;
padding: 15px;
background: #f8f9fa;
border-radius: 4px;
}
.bnb-wc-price-display .price-row {
display: flex;
justify-content: space-between;
padding: 5px 0;
}
.bnb-wc-price-display .price-row.total {
border-top: 1px solid #e1e4e8;
margin-top: 10px;
padding-top: 10px;
font-weight: 700;
font-size: 1.1em;
}
/* Services selection */
.bnb-wc-services {
margin: 15px 0;
}
.bnb-wc-services h4 {
margin: 0 0 10px 0;
font-size: 1em;
}
.bnb-wc-service-item {
display: flex;
align-items: center;
padding: 10px;
margin-bottom: 8px;
background: #f9f9f9;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}
.bnb-wc-service-item:hover {
background: #f0f0f0;
}
.bnb-wc-service-item.selected {
background: #e6f4ea;
border: 1px solid #d1e7d7;
}
.bnb-wc-service-item input[type="checkbox"] {
margin-right: 10px;
}
.bnb-wc-service-item .service-name {
flex: 1;
}
.bnb-wc-service-item .service-price {
color: #2271b1;
font-weight: 600;
}
.bnb-wc-service-item .service-qty {
margin-left: 10px;
width: 60px;
}
/* Add to cart button */
.bnb-wc-booking-form .add-to-cart-btn {
width: 100%;
padding: 12px 20px;
background: #2271b1;
color: #fff;
border: none;
border-radius: 4px;
font-size: 1.1em;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.bnb-wc-booking-form .add-to-cart-btn:hover {
background: #135e96;
}
.bnb-wc-booking-form .add-to-cart-btn:disabled {
background: #c3c4c7;
cursor: not-allowed;
}
/* ==========================================================================
Admin Order - Booking Info
========================================================================== */
.bnb-order-booking-info {
margin-top: 20px;
padding: 15px;
background: #f6f7f7;
border-radius: 4px;
}
.bnb-order-booking-info h3 {
margin: 0 0 12px 0;
font-size: 13px;
color: #1d2327;
}
.bnb-order-booking-info p {
margin: 0 0 8px 0;
font-size: 13px;
}
.bnb-order-booking-info a {
color: #2271b1;
}
.bnb-order-booking-info a:hover {
color: #135e96;
}
/* ==========================================================================
Admin List Table - Order Columns
========================================================================== */
.column-wc_order {
width: 10%;
}
.column-bnb_booking {
width: 15%;
}
.column-bnb_booking small {
display: block;
color: #787c82;
margin-top: 2px;
}
/* Order action buttons */
.wc-action-button-view_booking::after {
font-family: dashicons;
content: "\f513";
}
/* ==========================================================================
Responsive
========================================================================== */
@media screen and (max-width: 768px) {
.bnb-wc-booking-form .form-row-inline {
flex-direction: column;
gap: 0;
}
.bnb-booking-details span {
display: block;
margin-right: 0;
margin-bottom: 4px;
}
.bnb-booking-details span::before {
content: "";
margin-right: 0;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -91,6 +91,91 @@
}
}
/**
* Initialize update check functionality.
*/
function initUpdateCheck() {
var $checkBtn = $('#wp-bnb-check-updates');
var $spinner = $('#wp-bnb-update-spinner');
var $message = $('#wp-bnb-update-message');
var $latestVersion = $('#wp-bnb-latest-version');
var $lastCheck = $('#wp-bnb-update-last-check');
if (!$checkBtn.length) {
return;
}
$checkBtn.on('click', function(e) {
e.preventDefault();
// Disable button and show spinner.
$checkBtn.prop('disabled', true);
$spinner.addClass('is-active');
$message.hide();
$.ajax({
url: wpBnbAdmin.ajaxUrl,
type: 'POST',
data: {
action: 'wp_bnb_check_updates',
nonce: wpBnbAdmin.nonce
},
success: function(response) {
$spinner.removeClass('is-active');
$checkBtn.prop('disabled', false);
if (response.success) {
var data = response.data;
// Update last check time.
$lastCheck.text(wpBnbAdmin.i18n.justNow || 'Just now');
// Update version display.
if (data.update_available) {
$latestVersion.html(
'<span style="color: #00a32a; font-weight: 600;">' +
data.latest_version +
'</span> ' +
'<span class="dashicons dashicons-yes" style="color: #00a32a;"></span> ' +
'<em>' + (wpBnbAdmin.i18n.updateAvailable || 'Update available!') + '</em>'
);
showUpdateMessage('success', data.message);
} else {
$latestVersion.html(
data.latest_version +
' <span style="color: #646970;">' +
(wpBnbAdmin.i18n.upToDate || '(You are up to date)') +
'</span>'
);
showUpdateMessage('success', data.message);
}
} else {
showUpdateMessage('error', response.data.message || wpBnbAdmin.i18n.error);
}
},
error: function() {
$spinner.removeClass('is-active');
$checkBtn.prop('disabled', false);
showUpdateMessage('error', wpBnbAdmin.i18n.error);
}
});
});
/**
* Show an update message.
*
* @param {string} type Message type (success or error).
* @param {string} message Message text.
*/
function showUpdateMessage(type, message) {
$message
.removeClass('success error')
.addClass(type)
.text(message)
.fadeIn();
}
}
/**
* Initialize room gallery functionality.
*/
@@ -577,15 +662,558 @@
});
}
/**
* Initialize guest search functionality for booking form.
*/
function initGuestSearch() {
var $searchInput = $('#bnb_booking_guest_search');
var $searchResults = $('#bnb-guest-search-results');
var $guestIdInput = $('#bnb_booking_guest_id');
var $linkedGuestInfo = $('#bnb-linked-guest-info');
var $searchContainer = $('#bnb-guest-search-container');
var $fieldsContainer = $('#bnb-guest-fields-container');
var $unlinkBtn = $('#bnb-unlink-guest');
var $guestNameInput = $('#bnb_booking_guest_name');
var $guestEmailInput = $('#bnb_booking_guest_email');
var $guestPhoneInput = $('#bnb_booking_guest_phone');
// Exit if not on booking form.
if (!$searchInput.length) {
return;
}
var searchTimer = null;
/**
* Perform guest search via AJAX.
*/
function searchGuests() {
var query = $searchInput.val().trim();
if (query.length < 2) {
$searchResults.hide().empty();
return;
}
$searchResults.html('<div class="bnb-search-loading">' + wpBnbAdmin.i18n.searchingGuests + '</div>').show();
$.ajax({
url: wpBnbAdmin.ajaxUrl,
type: 'POST',
data: {
action: 'wp_bnb_search_guest',
nonce: wpBnbAdmin.nonce,
search: query
},
success: function(response) {
if (response.success && response.data.guests.length > 0) {
var html = '<div class="bnb-guest-search-list">';
$.each(response.data.guests, function(i, guest) {
var isBlocked = guest.status === 'blocked';
var statusClass = isBlocked ? 'bnb-guest-blocked' : '';
var statusLabel = isBlocked ? ' <span class="bnb-blocked-label">' + wpBnbAdmin.i18n.guestBlocked + '</span>' : '';
html += '<div class="bnb-guest-search-item ' + statusClass + '" data-guest=\'' + JSON.stringify(guest) + '\'>';
html += '<div class="bnb-guest-item-info">';
html += '<strong>' + escapeHtml(guest.name) + '</strong>' + statusLabel + '<br>';
html += '<small>' + escapeHtml(guest.email || '') + '</small>';
if (guest.phone) {
html += ' <small>(' + escapeHtml(guest.phone) + ')</small>';
}
html += '</div>';
if (!isBlocked) {
html += '<button type="button" class="button button-small bnb-select-guest">' + wpBnbAdmin.i18n.selectGuest + '</button>';
}
html += '</div>';
});
html += '</div>';
$searchResults.html(html);
} else {
$searchResults.html('<div class="bnb-no-guests">' + wpBnbAdmin.i18n.noGuestsFound + '</div>');
}
},
error: function() {
$searchResults.html('<div class="bnb-search-error">' + wpBnbAdmin.i18n.error + '</div>');
}
});
}
/**
* Escape HTML entities.
*
* @param {string} text Text to escape.
* @return {string} Escaped text.
*/
function escapeHtml(text) {
if (!text) return '';
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Select a guest from search results.
*
* @param {Object} guest Guest data.
*/
function selectGuest(guest) {
// Set hidden guest ID.
$guestIdInput.val(guest.id);
// Populate guest fields (for display/fallback).
$guestNameInput.val(guest.name).prop('readonly', true);
$guestEmailInput.val(guest.email).prop('readonly', true);
$guestPhoneInput.val(guest.phone).prop('readonly', true);
// Update linked guest display.
var infoHtml = '<p>';
infoHtml += '<span class="dashicons dashicons-admin-users"></span> ';
infoHtml += '<strong>' + escapeHtml(guest.name) + '</strong> ';
infoHtml += '<a href="' + wpBnbAdmin.ajaxUrl.replace('admin-ajax.php', 'post.php?post=' + guest.id + '&action=edit') + '" target="_blank" class="button button-small">View Guest Profile</a> ';
infoHtml += '<button type="button" id="bnb-unlink-guest" class="button button-small button-link-delete">Unlink</button>';
infoHtml += '</p>';
if (guest.email) {
infoHtml += '<p><small>' + escapeHtml(guest.email) + '</small></p>';
}
$linkedGuestInfo.html(infoHtml).show();
$searchContainer.hide();
$fieldsContainer.hide();
$searchResults.hide().empty();
$searchInput.val('');
// Re-bind unlink button.
bindUnlinkButton();
}
/**
* Unlink guest from booking.
*/
function unlinkGuest() {
$guestIdInput.val('');
$guestNameInput.val('').prop('readonly', false);
$guestEmailInput.val('').prop('readonly', false);
$guestPhoneInput.val('').prop('readonly', false);
$linkedGuestInfo.hide();
$searchContainer.show();
$fieldsContainer.show();
}
/**
* Bind unlink button event.
*/
function bindUnlinkButton() {
$('#bnb-unlink-guest').off('click').on('click', function(e) {
e.preventDefault();
unlinkGuest();
});
}
// Search input with debounce.
$searchInput.on('input', function() {
if (searchTimer) {
clearTimeout(searchTimer);
}
searchTimer = setTimeout(searchGuests, 300);
});
// Select guest from results.
$searchResults.on('click', '.bnb-select-guest', function(e) {
e.preventDefault();
var guest = $(this).closest('.bnb-guest-search-item').data('guest');
if (guest) {
selectGuest(guest);
}
});
// Initial unlink button binding.
bindUnlinkButton();
// Close search results when clicking outside.
$(document).on('click', function(e) {
if (!$(e.target).closest('#bnb_booking_guest_search, #bnb-guest-search-results').length) {
$searchResults.hide();
}
});
}
/**
* Initialize service pricing type toggle.
*/
function initServicePricing() {
var $pricingTypeInputs = $('input[name="bnb_service_pricing_type"]');
var $priceRow = $('#bnb-service-price-row');
var $priceSuffix = $('#bnb-service-price-suffix');
var $priceDescription = $('#bnb-service-price-description');
if (!$pricingTypeInputs.length) {
return;
}
function updatePriceRowVisibility() {
var pricingType = $('input[name="bnb_service_pricing_type"]:checked').val();
if (pricingType === 'included') {
$priceRow.hide();
} else {
$priceRow.show();
if (pricingType === 'per_night') {
$priceSuffix.text(' / ' + (wpBnbAdmin.i18n.night || 'night'));
$priceDescription.text(wpBnbAdmin.i18n.perNightDescription || 'This price will be charged per night of the stay.');
} else {
$priceSuffix.text('');
$priceDescription.text(wpBnbAdmin.i18n.perBookingDescription || 'This price will be charged once for the booking.');
}
}
}
$pricingTypeInputs.on('change', updatePriceRowVisibility);
// Set initial visibility state on page load.
updatePriceRowVisibility();
}
/**
* Initialize booking services selector.
*/
function initBookingServices() {
var $servicesSelector = $('.bnb-services-selector');
var $servicesList = $servicesSelector.find('.bnb-services-list');
var $totalDisplay = $('#bnb-services-total-amount');
if (!$servicesSelector.length) {
return;
}
/**
* Get current number of nights from booking form.
*
* @return {number} Number of nights.
*/
function getNights() {
var checkIn = $('#bnb_booking_check_in').val();
var checkOut = $('#bnb_booking_check_out').val();
if (checkIn && checkOut) {
var startDate = new Date(checkIn);
var endDate = new Date(checkOut);
var nights = Math.ceil((endDate - startDate) / (1000 * 60 * 60 * 24));
return Math.max(1, nights);
}
return parseInt($servicesSelector.data('nights'), 10) || 1;
}
/**
* Calculate service line total.
*
* @param {jQuery} $item Service item element.
* @param {number} nights Number of nights.
* @return {number} Calculated price.
*/
function calculateServiceTotal($item, nights) {
var price = parseFloat($item.data('price')) || 0;
var pricingType = $item.data('pricing-type');
var quantity = parseInt($item.find('.bnb-service-qty-input').val(), 10) || 1;
if (pricingType === 'included') {
return 0;
}
if (pricingType === 'per_night') {
return price * quantity * nights;
}
return price * quantity;
}
/**
* Update service line total display.
*
* @param {jQuery} $item Service item element.
*/
function updateServiceLineTotal($item) {
var nights = getNights();
var total = calculateServiceTotal($item, nights);
var $lineTotal = $item.find('.bnb-service-line-total');
var $totalValue = $item.find('.bnb-service-total-value');
var isSelected = $item.find('input[type="checkbox"]').is(':checked');
var pricingType = $item.data('pricing-type');
if (isSelected && pricingType !== 'included' && total > 0) {
$totalValue.text(formatPrice(total));
$lineTotal.show();
} else {
$lineTotal.hide();
}
}
/**
* Update total services amount.
*/
function updateServicesTotal() {
var nights = getNights();
var total = 0;
$servicesList.find('.bnb-service-item').each(function() {
var $item = $(this);
var isSelected = $item.find('input[type="checkbox"]').is(':checked');
if (isSelected) {
total += calculateServiceTotal($item, nights);
}
});
$totalDisplay.text(formatPrice(total));
}
/**
* Format price for display (simple formatting).
*
* @param {number} price Price value.
* @return {string} Formatted price.
*/
function formatPrice(price) {
return parseFloat(price).toFixed(2);
}
// Handle service checkbox change.
$servicesList.on('change', 'input[type="checkbox"]', function() {
var $item = $(this).closest('.bnb-service-item');
var isSelected = $(this).is(':checked');
$item.toggleClass('selected', isSelected);
// Show/hide quantity input.
var $quantity = $item.find('.bnb-service-quantity');
if ($quantity.length) {
$quantity.toggle(isSelected);
}
updateServiceLineTotal($item);
updateServicesTotal();
});
// Handle quantity change.
$servicesList.on('change input', '.bnb-service-qty-input', function() {
var $item = $(this).closest('.bnb-service-item');
var maxQty = parseInt($item.data('max-quantity'), 10) || 1;
var value = parseInt($(this).val(), 10) || 1;
// Enforce min/max.
value = Math.max(1, Math.min(value, maxQty));
$(this).val(value);
updateServiceLineTotal($item);
updateServicesTotal();
});
// Update when booking dates change.
$('#bnb_booking_check_in, #bnb_booking_check_out').on('change', function() {
$servicesList.find('.bnb-service-item.selected').each(function() {
updateServiceLineTotal($(this));
});
updateServicesTotal();
});
// Initial calculation.
updateServicesTotal();
}
/**
* Initialize dashboard charts.
*/
function initDashboardCharts() {
// Only run on dashboard page.
if (!wpBnbAdmin.isDashboard || typeof Chart === 'undefined') {
return;
}
var chartData = wpBnbAdmin.chartData;
if (!chartData) {
return;
}
// Chart.js default configuration.
Chart.defaults.font.family = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif';
Chart.defaults.font.size = 12;
Chart.defaults.color = '#50575e';
// Initialize Occupancy Chart.
var occupancyCtx = document.getElementById('wp-bnb-occupancy-chart');
if (occupancyCtx && chartData.occupancy) {
new Chart(occupancyCtx, {
type: 'line',
data: {
labels: chartData.occupancy.labels,
datasets: [{
label: wpBnbAdmin.i18n.occupancy,
data: chartData.occupancy.data,
borderColor: '#2271b1',
backgroundColor: 'rgba(34, 113, 177, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 3,
pointHoverRadius: 5,
pointBackgroundColor: '#2271b1',
pointBorderColor: '#fff',
pointBorderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: '#1d2327',
titleColor: '#fff',
bodyColor: '#fff',
padding: 12,
displayColors: false,
callbacks: {
label: function(context) {
return context.parsed.y.toFixed(1) + '%';
}
}
}
},
scales: {
y: {
beginAtZero: true,
max: 100,
ticks: {
callback: function(value) {
return value + '%';
}
},
grid: {
color: 'rgba(0, 0, 0, 0.05)'
}
},
x: {
grid: {
display: false
}
}
},
interaction: {
intersect: false,
mode: 'index'
}
}
});
}
// Initialize Revenue Chart.
var revenueCtx = document.getElementById('wp-bnb-revenue-chart');
if (revenueCtx && chartData.revenue) {
new Chart(revenueCtx, {
type: 'bar',
data: {
labels: chartData.revenue.labels,
datasets: [{
label: wpBnbAdmin.i18n.revenue,
data: chartData.revenue.data,
backgroundColor: 'rgba(0, 163, 42, 0.8)',
borderColor: '#00a32a',
borderWidth: 1,
borderRadius: 4,
barPercentage: 0.6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: '#1d2327',
titleColor: '#fff',
bodyColor: '#fff',
padding: 12,
displayColors: false,
callbacks: {
label: function(context) {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(context.parsed.y);
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF',
maximumFractionDigits: 0
}).format(value);
}
},
grid: {
color: 'rgba(0, 0, 0, 0.05)'
}
},
x: {
grid: {
display: false
}
}
}
}
});
}
}
/**
* Initialize reports page functionality.
*/
function initReportsPage() {
var $periodSelect = $('.wp-bnb-period-select');
var $customDates = $('.wp-bnb-custom-dates');
if (!$periodSelect.length) {
return;
}
// Toggle custom date fields based on period selection.
$periodSelect.on('change', function() {
if ($(this).val() === 'custom') {
$customDates.show();
} else {
$customDates.hide();
}
});
}
// Initialize on document ready.
$(document).ready(function() {
initLicenseManagement();
initUpdateCheck();
initRoomGallery();
initPricingSettings();
initSeasonForm();
initPricingMetaBox();
initBookingForm();
initCalendarPage();
initGuestSearch();
initServicePricing();
initBookingServices();
initDashboardCharts();
initReportsPage();
});
})(jQuery);

489
assets/js/blocks-editor.js Normal file
View File

@@ -0,0 +1,489 @@
/**
* WP BnB Gutenberg Blocks
*
* @package Magdev\WpBnb
*/
(function(wp) {
'use strict';
const { registerBlockType } = wp.blocks;
const { createElement, Fragment } = wp.element;
const { InspectorControls, useBlockProps } = wp.blockEditor;
const { PanelBody, SelectControl, ToggleControl, RangeControl, Placeholder, Spinner } = wp.components;
const { ServerSideRender } = wp.editor || wp.serverSideRender;
const { __ } = wp.i18n;
const el = createElement;
// Get localized data
const { buildings, rooms, roomTypes, i18n } = wpBnbBlocks;
// Building options for select
const buildingOptions = [
{ value: 0, label: i18n.selectBuilding },
...buildings
];
// Room options for select
const roomOptions = [
{ value: 0, label: i18n.selectRoom },
...rooms.map(r => ({
value: r.value,
label: r.building ? `${r.label} (${r.building})` : r.label
}))
];
// Room type options
const roomTypeOptions = [
{ value: '', label: i18n.allTypes },
...roomTypes.map(t => ({
value: t.slug,
label: t.name
}))
];
// Building filter options for rooms block
const buildingFilterOptions = [
{ value: 0, label: i18n.allBuildings },
...buildings
];
/**
* Building Block
*/
registerBlockType('wp-bnb/building', {
title: i18n.buildingBlock,
icon: 'building',
category: 'widgets',
attributes: {
buildingId: { type: 'number', default: 0 },
showImage: { type: 'boolean', default: true },
showAddress: { type: 'boolean', default: true },
showRooms: { type: 'boolean', default: true },
showContact: { type: 'boolean', default: true }
},
edit: function(props) {
const { attributes, setAttributes } = props;
const blockProps = useBlockProps();
return el(Fragment, {},
el(InspectorControls, {},
el(PanelBody, { title: i18n.displaySettings },
el(SelectControl, {
label: i18n.buildingBlock,
value: attributes.buildingId,
options: buildingOptions,
onChange: (value) => setAttributes({ buildingId: parseInt(value, 10) })
}),
el(ToggleControl, {
label: i18n.showImage,
checked: attributes.showImage,
onChange: (value) => setAttributes({ showImage: value })
}),
el(ToggleControl, {
label: i18n.showAddress,
checked: attributes.showAddress,
onChange: (value) => setAttributes({ showAddress: value })
}),
el(ToggleControl, {
label: i18n.showRooms,
checked: attributes.showRooms,
onChange: (value) => setAttributes({ showRooms: value })
}),
el(ToggleControl, {
label: i18n.showContact,
checked: attributes.showContact,
onChange: (value) => setAttributes({ showContact: value })
})
)
),
el('div', blockProps,
attributes.buildingId ?
el(ServerSideRender, {
block: 'wp-bnb/building',
attributes: attributes,
LoadingResponsePlaceholder: () => el(Placeholder, { icon: 'building', label: i18n.buildingBlock }, el(Spinner))
}) :
el(Placeholder, { icon: 'building', label: i18n.buildingBlock },
buildings.length === 0 ?
el('p', {}, i18n.noBuildings) :
el(SelectControl, {
value: attributes.buildingId,
options: buildingOptions,
onChange: (value) => setAttributes({ buildingId: parseInt(value, 10) })
})
)
)
);
},
save: function() {
return null; // Server-side rendered
}
});
/**
* Room Block
*/
registerBlockType('wp-bnb/room', {
title: i18n.roomBlock,
icon: 'admin-home',
category: 'widgets',
attributes: {
roomId: { type: 'number', default: 0 },
showImage: { type: 'boolean', default: true },
showGallery: { type: 'boolean', default: true },
showPrice: { type: 'boolean', default: true },
showAmenities: { type: 'boolean', default: true },
showAvailability: { type: 'boolean', default: true }
},
edit: function(props) {
const { attributes, setAttributes } = props;
const blockProps = useBlockProps();
return el(Fragment, {},
el(InspectorControls, {},
el(PanelBody, { title: i18n.displaySettings },
el(SelectControl, {
label: i18n.roomBlock,
value: attributes.roomId,
options: roomOptions,
onChange: (value) => setAttributes({ roomId: parseInt(value, 10) })
}),
el(ToggleControl, {
label: i18n.showImage,
checked: attributes.showImage,
onChange: (value) => setAttributes({ showImage: value })
}),
el(ToggleControl, {
label: i18n.showGallery,
checked: attributes.showGallery,
onChange: (value) => setAttributes({ showGallery: value })
}),
el(ToggleControl, {
label: i18n.showPrice,
checked: attributes.showPrice,
onChange: (value) => setAttributes({ showPrice: value })
}),
el(ToggleControl, {
label: i18n.showAmenities,
checked: attributes.showAmenities,
onChange: (value) => setAttributes({ showAmenities: value })
}),
el(ToggleControl, {
label: i18n.showAvailability,
checked: attributes.showAvailability,
onChange: (value) => setAttributes({ showAvailability: value })
})
)
),
el('div', blockProps,
attributes.roomId ?
el(ServerSideRender, {
block: 'wp-bnb/room',
attributes: attributes,
LoadingResponsePlaceholder: () => el(Placeholder, { icon: 'admin-home', label: i18n.roomBlock }, el(Spinner))
}) :
el(Placeholder, { icon: 'admin-home', label: i18n.roomBlock },
rooms.length === 0 ?
el('p', {}, i18n.noRooms) :
el(SelectControl, {
value: attributes.roomId,
options: roomOptions,
onChange: (value) => setAttributes({ roomId: parseInt(value, 10) })
})
)
)
);
},
save: function() {
return null;
}
});
/**
* Room Search Block
*/
registerBlockType('wp-bnb/room-search', {
title: i18n.roomSearchBlock,
icon: 'search',
category: 'widgets',
attributes: {
layout: { type: 'string', default: 'grid' },
columns: { type: 'number', default: 3 },
showDates: { type: 'boolean', default: true },
showGuests: { type: 'boolean', default: true },
showRoomType: { type: 'boolean', default: true },
showAmenities: { type: 'boolean', default: true },
showPriceRange: { type: 'boolean', default: true },
showBuilding: { type: 'boolean', default: true },
resultsPerPage: { type: 'number', default: 12 }
},
edit: function(props) {
const { attributes, setAttributes } = props;
const blockProps = useBlockProps();
return el(Fragment, {},
el(InspectorControls, {},
el(PanelBody, { title: i18n.displaySettings },
el(SelectControl, {
label: i18n.layout,
value: attributes.layout,
options: [
{ value: 'grid', label: i18n.grid },
{ value: 'list', label: i18n.list }
],
onChange: (value) => setAttributes({ layout: value })
}),
el(RangeControl, {
label: i18n.columns,
value: attributes.columns,
onChange: (value) => setAttributes({ columns: value }),
min: 1,
max: 4
}),
el(RangeControl, {
label: i18n.resultsPerPage,
value: attributes.resultsPerPage,
onChange: (value) => setAttributes({ resultsPerPage: value }),
min: 4,
max: 48
})
),
el(PanelBody, { title: i18n.filterSettings, initialOpen: false },
el(ToggleControl, {
label: i18n.showDates,
checked: attributes.showDates,
onChange: (value) => setAttributes({ showDates: value })
}),
el(ToggleControl, {
label: i18n.showGuests,
checked: attributes.showGuests,
onChange: (value) => setAttributes({ showGuests: value })
}),
el(ToggleControl, {
label: i18n.showRoomType,
checked: attributes.showRoomType,
onChange: (value) => setAttributes({ showRoomType: value })
}),
el(ToggleControl, {
label: i18n.showAmenities,
checked: attributes.showAmenities,
onChange: (value) => setAttributes({ showAmenities: value })
}),
el(ToggleControl, {
label: i18n.showPriceRange,
checked: attributes.showPriceRange,
onChange: (value) => setAttributes({ showPriceRange: value })
}),
el(ToggleControl, {
label: i18n.showBuilding,
checked: attributes.showBuilding,
onChange: (value) => setAttributes({ showBuilding: value })
})
)
),
el('div', blockProps,
el(ServerSideRender, {
block: 'wp-bnb/room-search',
attributes: attributes,
LoadingResponsePlaceholder: () => el(Placeholder, { icon: 'search', label: i18n.roomSearchBlock }, el(Spinner))
})
)
);
},
save: function() {
return null;
}
});
/**
* Buildings List Block
*/
registerBlockType('wp-bnb/buildings', {
title: i18n.buildingsBlock,
icon: 'building',
category: 'widgets',
attributes: {
layout: { type: 'string', default: 'grid' },
columns: { type: 'number', default: 3 },
limit: { type: 'number', default: -1 },
showImage: { type: 'boolean', default: true },
showAddress: { type: 'boolean', default: true },
showRoomsCount: { type: 'boolean', default: true }
},
edit: function(props) {
const { attributes, setAttributes } = props;
const blockProps = useBlockProps();
return el(Fragment, {},
el(InspectorControls, {},
el(PanelBody, { title: i18n.displaySettings },
el(SelectControl, {
label: i18n.layout,
value: attributes.layout,
options: [
{ value: 'grid', label: i18n.grid },
{ value: 'list', label: i18n.list }
],
onChange: (value) => setAttributes({ layout: value })
}),
el(RangeControl, {
label: i18n.columns,
value: attributes.columns,
onChange: (value) => setAttributes({ columns: value }),
min: 1,
max: 4
}),
el(RangeControl, {
label: i18n.limit,
value: attributes.limit,
onChange: (value) => setAttributes({ limit: value }),
min: -1,
max: 20
}),
el(ToggleControl, {
label: i18n.showImage,
checked: attributes.showImage,
onChange: (value) => setAttributes({ showImage: value })
}),
el(ToggleControl, {
label: i18n.showAddress,
checked: attributes.showAddress,
onChange: (value) => setAttributes({ showAddress: value })
}),
el(ToggleControl, {
label: i18n.showRoomsCount,
checked: attributes.showRoomsCount,
onChange: (value) => setAttributes({ showRoomsCount: value })
})
)
),
el('div', blockProps,
el(ServerSideRender, {
block: 'wp-bnb/buildings',
attributes: attributes,
LoadingResponsePlaceholder: () => el(Placeholder, { icon: 'building', label: i18n.buildingsBlock }, el(Spinner))
})
)
);
},
save: function() {
return null;
}
});
/**
* Rooms List Block
*/
registerBlockType('wp-bnb/rooms', {
title: i18n.roomsBlock,
icon: 'admin-home',
category: 'widgets',
attributes: {
layout: { type: 'string', default: 'grid' },
columns: { type: 'number', default: 3 },
limit: { type: 'number', default: 12 },
buildingId: { type: 'number', default: 0 },
roomType: { type: 'string', default: '' },
showImage: { type: 'boolean', default: true },
showPrice: { type: 'boolean', default: true },
showCapacity: { type: 'boolean', default: true },
showAmenities: { type: 'boolean', default: true },
showBuilding: { type: 'boolean', default: true }
},
edit: function(props) {
const { attributes, setAttributes } = props;
const blockProps = useBlockProps();
return el(Fragment, {},
el(InspectorControls, {},
el(PanelBody, { title: i18n.displaySettings },
el(SelectControl, {
label: i18n.layout,
value: attributes.layout,
options: [
{ value: 'grid', label: i18n.grid },
{ value: 'list', label: i18n.list }
],
onChange: (value) => setAttributes({ layout: value })
}),
el(RangeControl, {
label: i18n.columns,
value: attributes.columns,
onChange: (value) => setAttributes({ columns: value }),
min: 1,
max: 4
}),
el(RangeControl, {
label: i18n.limit,
value: attributes.limit,
onChange: (value) => setAttributes({ limit: value }),
min: 1,
max: 48
}),
el(ToggleControl, {
label: i18n.showImage,
checked: attributes.showImage,
onChange: (value) => setAttributes({ showImage: value })
}),
el(ToggleControl, {
label: i18n.showPrice,
checked: attributes.showPrice,
onChange: (value) => setAttributes({ showPrice: value })
}),
el(ToggleControl, {
label: i18n.showCapacity,
checked: attributes.showCapacity,
onChange: (value) => setAttributes({ showCapacity: value })
}),
el(ToggleControl, {
label: i18n.showAmenities,
checked: attributes.showAmenities,
onChange: (value) => setAttributes({ showAmenities: value })
}),
el(ToggleControl, {
label: i18n.showBuilding,
checked: attributes.showBuilding,
onChange: (value) => setAttributes({ showBuilding: value })
})
),
el(PanelBody, { title: i18n.filterSettings, initialOpen: false },
el(SelectControl, {
label: i18n.buildingBlock,
value: attributes.buildingId,
options: buildingFilterOptions,
onChange: (value) => setAttributes({ buildingId: parseInt(value, 10) })
}),
el(SelectControl, {
label: i18n.roomType,
value: attributes.roomType,
options: roomTypeOptions,
onChange: (value) => setAttributes({ roomType: value })
})
)
),
el('div', blockProps,
el(ServerSideRender, {
block: 'wp-bnb/rooms',
attributes: attributes,
LoadingResponsePlaceholder: () => el(Placeholder, { icon: 'admin-home', label: i18n.roomsBlock }, el(Spinner))
})
)
);
},
save: function() {
return null;
}
});
})(window.wp);

View File

@@ -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 = '<span class="wp-bnb-checking">' + (WpBnbCF7.config.i18n?.checking || 'Checking availability...') + '</span>';
}
// 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 = '<span class="wp-bnb-available">' + (WpBnbCF7.config.i18n?.available || 'Room is available!') + '</span>';
statusEl.innerHTML = html;
} else {
statusEl.innerHTML = '<span class="wp-bnb-unavailable">' + (WpBnbCF7.config.i18n?.unavailable || 'Room is not available for these dates') + '</span>';
}
}
// Update price display
if (priceEl && response.available && response.price_formatted) {
priceEl.innerHTML = '<span class="wp-bnb-price-label">' + (WpBnbCF7.config.i18n?.estimatedTotal || 'Estimated Total') + ':</span> ' +
'<span class="wp-bnb-price-amount">' + response.price_formatted + '</span> ' +
'<span class="wp-bnb-nights">(' + response.nights + ' ' + (WpBnbCF7.config.i18n?.nights || 'nights') + ')</span>';
} 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 = '<span class="wp-bnb-price-label">' + (self.config.i18n?.estimatedTotal || 'Estimated Total') + ':</span> ' +
'<span class="wp-bnb-price-amount">' + response.price_formatted + '</span> ' +
'<span class="wp-bnb-nights">(' + response.nights + ' ' + (self.config.i18n?.nights || 'nights') + ')</span>';
})
.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;
})();

View File

@@ -1,12 +1,825 @@
/**
* WP BnB Frontend JavaScript
*
* Handles search forms, calendar widgets, and interactive elements.
*
* @package Magdev\WpBnb
*/
(function() {
'use strict';
// Placeholder - Frontend scripts will be added as features are implemented
/**
* WP BnB Frontend namespace.
*/
const WpBnb = {
/**
* Configuration from localized script.
*/
config: window.wpBnbFrontend || {},
/**
* Initialize all frontend components.
*/
init: function() {
this.initSearchForms();
this.initCalendarWidgets();
this.initAvailabilityForms();
this.initPriceCalculators();
},
/**
* Initialize room search forms.
*/
initSearchForms: function() {
const forms = document.querySelectorAll('.wp-bnb-search-form');
forms.forEach(form => {
new SearchForm(form);
});
},
/**
* Initialize calendar widgets.
*/
initCalendarWidgets: function() {
const calendars = document.querySelectorAll('.wp-bnb-availability-calendar-widget');
calendars.forEach(calendar => {
new CalendarWidget(calendar);
});
},
/**
* Initialize availability check forms on single room pages.
*/
initAvailabilityForms: function() {
const forms = document.querySelectorAll('.wp-bnb-availability-check');
forms.forEach(form => {
new AvailabilityForm(form);
});
},
/**
* Initialize price calculator forms.
*/
initPriceCalculators: function() {
const calculators = document.querySelectorAll('.wp-bnb-price-calculator');
calculators.forEach(calculator => {
new PriceCalculator(calculator);
});
},
/**
* Make an AJAX request.
*
* @param {string} action The AJAX action.
* @param {Object} data The request data.
* @return {Promise} Promise resolving to response data.
*/
ajax: function(action, data = {}) {
const formData = new FormData();
formData.append('action', action);
formData.append('nonce', this.config.nonce || '');
Object.keys(data).forEach(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(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
if (!data.success) {
throw new Error(data.data?.message || 'Request failed');
}
return data.data;
});
},
/**
* Format a date as YYYY-MM-DD.
*
* @param {Date} date The date object.
* @return {string} Formatted date 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}`;
},
/**
* Parse a date string.
*
* @param {string} dateStr Date string in YYYY-MM-DD format.
* @return {Date|null} Date object or null if invalid.
*/
parseDate: function(dateStr) {
if (!dateStr) return null;
const parts = dateStr.split('-');
if (parts.length !== 3) return null;
return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
},
/**
* Calculate nights between two dates.
*
* @param {Date} checkIn Check-in date.
* @param {Date} checkOut Check-out date.
* @return {number} Number of nights.
*/
calculateNights: function(checkIn, checkOut) {
if (!checkIn || !checkOut) return 0;
const diffTime = checkOut.getTime() - checkIn.getTime();
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
},
/**
* Debounce a function.
*
* @param {Function} func The function to debounce.
* @param {number} wait Wait time in milliseconds.
* @return {Function} Debounced function.
*/
debounce: function(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
};
/**
* Search Form handler class.
*/
class SearchForm {
constructor(element) {
this.form = element;
this.resultsContainer = document.querySelector(
this.form.dataset.results || '.wp-bnb-search-results'
);
this.currentPage = 1;
this.isLoading = false;
this.bindEvents();
}
bindEvents() {
// Form submission.
this.form.addEventListener('submit', (e) => {
e.preventDefault();
this.currentPage = 1;
this.search();
});
// Date validation.
const checkIn = this.form.querySelector('[name="check_in"]');
const checkOut = this.form.querySelector('[name="check_out"]');
if (checkIn && checkOut) {
// Set min date to today.
const today = WpBnb.formatDate(new Date());
checkIn.setAttribute('min', today);
checkIn.addEventListener('change', () => {
if (checkIn.value) {
// Set check-out min to day after check-in.
const minCheckOut = WpBnb.parseDate(checkIn.value);
if (minCheckOut) {
minCheckOut.setDate(minCheckOut.getDate() + 1);
checkOut.setAttribute('min', WpBnb.formatDate(minCheckOut));
// Clear check-out if it's before new minimum.
if (checkOut.value && checkOut.value <= checkIn.value) {
checkOut.value = '';
}
}
}
});
checkOut.addEventListener('change', () => {
if (checkOut.value && checkIn.value && checkOut.value <= checkIn.value) {
alert(WpBnb.config.i18n?.invalidDateRange || 'Check-out must be after check-in');
checkOut.value = '';
}
});
}
// Reset button.
const resetBtn = this.form.querySelector('[type="reset"]');
if (resetBtn) {
resetBtn.addEventListener('click', () => {
setTimeout(() => {
this.clearResults();
}, 0);
});
}
// Load more button.
if (this.resultsContainer) {
this.resultsContainer.addEventListener('click', (e) => {
if (e.target.classList.contains('wp-bnb-load-more')) {
e.preventDefault();
this.loadMore();
}
});
}
}
getFormData() {
const formData = new FormData(this.form);
const data = {};
formData.forEach((value, key) => {
if (value) {
// Handle array fields (amenities[]).
if (key.endsWith('[]')) {
const cleanKey = key.slice(0, -2);
if (!data[cleanKey]) {
data[cleanKey] = [];
}
data[cleanKey].push(value);
} else {
data[key] = value;
}
}
});
// Convert arrays to comma-separated strings for AJAX.
Object.keys(data).forEach(key => {
if (Array.isArray(data[key])) {
data[key] = data[key].join(',');
}
});
return data;
}
search() {
if (this.isLoading) return;
this.isLoading = true;
this.showLoading();
const data = this.getFormData();
data.page = this.currentPage;
data.per_page = this.form.dataset.perPage || 12;
WpBnb.ajax('wp_bnb_search_rooms', data)
.then(response => {
this.renderResults(response, this.currentPage === 1);
})
.catch(error => {
this.showError(error.message);
})
.finally(() => {
this.isLoading = false;
this.hideLoading();
});
}
loadMore() {
this.currentPage++;
this.search();
}
renderResults(response, replace = true) {
if (!this.resultsContainer) return;
const { rooms, total, page, total_pages } = response;
if (replace) {
this.resultsContainer.innerHTML = '';
} else {
// Remove existing load more button.
const existingLoadMore = this.resultsContainer.querySelector('.wp-bnb-load-more-wrapper');
if (existingLoadMore) {
existingLoadMore.remove();
}
}
if (rooms.length === 0 && replace) {
this.resultsContainer.innerHTML = `
<div class="wp-bnb-no-results">
<p>${WpBnb.config.i18n?.noResults || 'No rooms found matching your criteria.'}</p>
</div>
`;
return;
}
// Create results count.
if (replace) {
const countEl = document.createElement('div');
countEl.className = 'wp-bnb-results-count';
countEl.innerHTML = `<p>${WpBnb.config.i18n?.resultsFound?.replace('%d', total) || `${total} rooms found`}</p>`;
this.resultsContainer.appendChild(countEl);
}
// Create grid container.
let grid = this.resultsContainer.querySelector('.wp-bnb-rooms-grid');
if (!grid) {
grid = document.createElement('div');
grid.className = 'wp-bnb-rooms-grid wp-bnb-grid wp-bnb-grid-3';
this.resultsContainer.appendChild(grid);
}
// Render room cards.
rooms.forEach(room => {
const card = this.createRoomCard(room);
grid.appendChild(card);
});
// Add load more button if there are more pages.
if (page < total_pages) {
const loadMoreWrapper = document.createElement('div');
loadMoreWrapper.className = 'wp-bnb-load-more-wrapper';
loadMoreWrapper.innerHTML = `
<button type="button" class="wp-bnb-load-more wp-bnb-button">
${WpBnb.config.i18n?.loadMore || 'Load More'}
</button>
`;
this.resultsContainer.appendChild(loadMoreWrapper);
}
}
createRoomCard(room) {
const card = document.createElement('article');
card.className = 'wp-bnb-room-card';
let imageHtml = '';
if (room.thumbnail) {
imageHtml = `
<div class="wp-bnb-room-card-image">
<a href="${this.escapeHtml(room.permalink)}">
<img src="${this.escapeHtml(room.thumbnail)}" alt="${this.escapeHtml(room.title)}">
</a>
</div>
`;
}
let amenitiesHtml = '';
if (room.amenities && room.amenities.length > 0) {
const amenityItems = room.amenities.slice(0, 4).map(a =>
`<span class="wp-bnb-amenity-tag">${this.escapeHtml(a.name)}</span>`
).join('');
amenitiesHtml = `<div class="wp-bnb-room-card-amenities">${amenityItems}</div>`;
}
let priceHtml = '';
if (room.price_display) {
priceHtml = `
<div class="wp-bnb-room-card-price">
<span class="wp-bnb-price">${this.escapeHtml(room.price_display)}</span>
<span class="wp-bnb-price-unit">/ ${WpBnb.config.i18n?.perNight || 'night'}</span>
</div>
`;
}
card.innerHTML = `
${imageHtml}
<div class="wp-bnb-room-card-content">
<h3 class="wp-bnb-room-card-title">
<a href="${this.escapeHtml(room.permalink)}">${this.escapeHtml(room.title)}</a>
</h3>
${room.building_name ? `<p class="wp-bnb-room-card-building">${this.escapeHtml(room.building_name)}</p>` : ''}
<div class="wp-bnb-room-card-meta">
${room.capacity ? `<span class="wp-bnb-capacity">${room.capacity} ${WpBnb.config.i18n?.guests || 'guests'}</span>` : ''}
${room.room_type ? `<span class="wp-bnb-room-type">${this.escapeHtml(room.room_type)}</span>` : ''}
</div>
${amenitiesHtml}
${priceHtml}
<a href="${this.escapeHtml(room.permalink)}" class="wp-bnb-room-card-link wp-bnb-button wp-bnb-button-small">
${WpBnb.config.i18n?.viewDetails || 'View Details'}
</a>
</div>
`;
return card;
}
escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
showLoading() {
this.form.classList.add('wp-bnb-loading');
const submitBtn = this.form.querySelector('[type="submit"]');
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.dataset.originalText = submitBtn.textContent;
submitBtn.textContent = WpBnb.config.i18n?.searching || 'Searching...';
}
}
hideLoading() {
this.form.classList.remove('wp-bnb-loading');
const submitBtn = this.form.querySelector('[type="submit"]');
if (submitBtn) {
submitBtn.disabled = false;
if (submitBtn.dataset.originalText) {
submitBtn.textContent = submitBtn.dataset.originalText;
}
}
}
showError(message) {
if (!this.resultsContainer) return;
this.resultsContainer.innerHTML = `
<div class="wp-bnb-error">
<p>${this.escapeHtml(message)}</p>
</div>
`;
}
clearResults() {
if (this.resultsContainer) {
this.resultsContainer.innerHTML = '';
}
}
}
/**
* Calendar Widget handler class.
*/
class CalendarWidget {
constructor(element) {
this.container = element;
this.roomId = element.dataset.roomId;
this.currentYear = parseInt(element.querySelector('[data-year]')?.dataset.year) || new Date().getFullYear();
this.currentMonth = parseInt(element.querySelector('[data-month]')?.dataset.month) || (new Date().getMonth() + 1);
this.bindEvents();
}
bindEvents() {
// Navigation buttons.
this.container.addEventListener('click', (e) => {
const navBtn = e.target.closest('.wp-bnb-calendar-nav');
if (navBtn) {
e.preventDefault();
const direction = navBtn.dataset.direction;
if (direction === 'prev') {
this.navigatePrev();
} else if (direction === 'next') {
this.navigateNext();
}
}
});
}
navigatePrev() {
this.currentMonth--;
if (this.currentMonth < 1) {
this.currentMonth = 12;
this.currentYear--;
}
this.loadCalendar();
}
navigateNext() {
this.currentMonth++;
if (this.currentMonth > 12) {
this.currentMonth = 1;
this.currentYear++;
}
this.loadCalendar();
}
loadCalendar() {
this.container.classList.add('wp-bnb-loading');
WpBnb.ajax('wp_bnb_get_calendar', {
room_id: this.roomId,
year: this.currentYear,
month: this.currentMonth
})
.then(response => {
this.renderCalendar(response);
})
.catch(error => {
console.error('Calendar load error:', error);
})
.finally(() => {
this.container.classList.remove('wp-bnb-loading');
});
}
renderCalendar(data) {
const monthContainer = this.container.querySelector('.wp-bnb-calendar-month');
if (!monthContainer) return;
// Update month/year attributes.
monthContainer.dataset.year = this.currentYear;
monthContainer.dataset.month = this.currentMonth;
// Update month name.
const monthNameEl = monthContainer.querySelector('.wp-bnb-calendar-month-name');
if (monthNameEl) {
monthNameEl.textContent = `${data.month_name} ${this.currentYear}`;
}
// Rebuild calendar grid.
const tbody = monthContainer.querySelector('.wp-bnb-calendar-grid tbody');
if (!tbody) return;
tbody.innerHTML = '';
let day = 1;
const totalDays = data.days_in_month;
const firstDay = data.first_day_of_week;
const weeks = Math.ceil((firstDay + totalDays) / 7);
for (let week = 0; week < weeks; week++) {
const tr = document.createElement('tr');
for (let dow = 0; dow < 7; dow++) {
const td = document.createElement('td');
const cellIndex = week * 7 + dow;
if (cellIndex < firstDay || day > totalDays) {
td.className = 'wp-bnb-calendar-empty';
} else {
const dayData = data.days[day];
const classes = ['wp-bnb-calendar-day'];
if (dayData) {
if (dayData.is_booked) {
classes.push('wp-bnb-booked');
} else {
classes.push('wp-bnb-available');
}
if (dayData.is_past) {
classes.push('wp-bnb-past');
}
if (dayData.is_today) {
classes.push('wp-bnb-today');
}
td.dataset.date = dayData.date || '';
}
td.className = classes.join(' ');
td.textContent = day;
day++;
}
tr.appendChild(td);
}
tbody.appendChild(tr);
}
}
}
/**
* Availability Form handler class.
* For checking availability on single room pages.
*/
class AvailabilityForm {
constructor(element) {
this.form = element;
this.roomId = element.dataset.roomId;
this.resultContainer = element.querySelector('.wp-bnb-availability-result');
this.bindEvents();
}
bindEvents() {
this.form.addEventListener('submit', (e) => {
e.preventDefault();
this.checkAvailability();
});
// Date validation.
const checkIn = this.form.querySelector('[name="check_in"]');
const checkOut = this.form.querySelector('[name="check_out"]');
if (checkIn && checkOut) {
const today = WpBnb.formatDate(new Date());
checkIn.setAttribute('min', today);
checkIn.addEventListener('change', () => {
if (checkIn.value) {
const minCheckOut = WpBnb.parseDate(checkIn.value);
if (minCheckOut) {
minCheckOut.setDate(minCheckOut.getDate() + 1);
checkOut.setAttribute('min', WpBnb.formatDate(minCheckOut));
}
}
this.clearResult();
});
checkOut.addEventListener('change', () => {
this.clearResult();
});
}
}
checkAvailability() {
const checkIn = this.form.querySelector('[name="check_in"]')?.value;
const checkOut = this.form.querySelector('[name="check_out"]')?.value;
if (!checkIn || !checkOut) {
this.showResult('error', WpBnb.config.i18n?.selectDates || 'Please select check-in and check-out dates.');
return;
}
if (checkOut <= checkIn) {
this.showResult('error', WpBnb.config.i18n?.invalidDateRange || 'Check-out must be after check-in.');
return;
}
this.form.classList.add('wp-bnb-loading');
WpBnb.ajax('wp_bnb_get_availability', {
room_id: this.roomId,
check_in: checkIn,
check_out: checkOut
})
.then(response => {
if (response.available) {
let message = WpBnb.config.i18n?.available || 'Room is available!';
if (response.price_display) {
message += ` ${WpBnb.config.i18n?.totalPrice || 'Total'}: ${response.price_display}`;
}
this.showResult('success', message, response);
} else {
this.showResult('error', WpBnb.config.i18n?.notAvailable || 'Sorry, the room is not available for these dates.');
}
})
.catch(error => {
this.showResult('error', error.message);
})
.finally(() => {
this.form.classList.remove('wp-bnb-loading');
});
}
showResult(type, message, data = null) {
if (!this.resultContainer) return;
let html = `<div class="wp-bnb-availability-${type}">${this.escapeHtml(message)}</div>`;
if (type === 'success' && data && data.booking_url) {
html += `
<a href="${this.escapeHtml(data.booking_url)}" class="wp-bnb-button wp-bnb-book-now">
${WpBnb.config.i18n?.bookNow || 'Book Now'}
</a>
`;
}
this.resultContainer.innerHTML = html;
this.resultContainer.style.display = 'block';
}
clearResult() {
if (this.resultContainer) {
this.resultContainer.innerHTML = '';
this.resultContainer.style.display = 'none';
}
}
escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
}
/**
* Price Calculator handler class.
*/
class PriceCalculator {
constructor(element) {
this.container = element;
this.roomId = element.dataset.roomId;
this.priceDisplay = element.querySelector('.wp-bnb-calculated-price');
this.breakdownDisplay = element.querySelector('.wp-bnb-price-breakdown');
this.bindEvents();
}
bindEvents() {
const checkIn = this.container.querySelector('[name="check_in"]');
const checkOut = this.container.querySelector('[name="check_out"]');
if (checkIn && checkOut) {
const debouncedCalculate = WpBnb.debounce(() => this.calculate(), 300);
checkIn.addEventListener('change', debouncedCalculate);
checkOut.addEventListener('change', debouncedCalculate);
}
}
calculate() {
const checkIn = this.container.querySelector('[name="check_in"]')?.value;
const checkOut = this.container.querySelector('[name="check_out"]')?.value;
if (!checkIn || !checkOut || checkOut <= checkIn) {
this.clearDisplay();
return;
}
this.container.classList.add('wp-bnb-loading');
WpBnb.ajax('wp_bnb_calculate_price', {
room_id: this.roomId,
check_in: checkIn,
check_out: checkOut
})
.then(response => {
this.displayPrice(response);
})
.catch(error => {
console.error('Price calculation error:', error);
this.clearDisplay();
})
.finally(() => {
this.container.classList.remove('wp-bnb-loading');
});
}
displayPrice(data) {
if (this.priceDisplay) {
this.priceDisplay.innerHTML = `
<span class="wp-bnb-price-label">${WpBnb.config.i18n?.total || 'Total'}:</span>
<span class="wp-bnb-price-amount">${this.escapeHtml(data.formatted_total)}</span>
`;
this.priceDisplay.style.display = 'block';
}
if (this.breakdownDisplay && data.breakdown) {
let breakdownHtml = '<ul class="wp-bnb-breakdown-list">';
if (data.breakdown.nights) {
breakdownHtml += `<li>${data.breakdown.nights} ${WpBnb.config.i18n?.nights || 'nights'}</li>`;
}
if (data.breakdown.tier) {
breakdownHtml += `<li>${this.escapeHtml(data.breakdown.tier)}</li>`;
}
if (data.breakdown.base_total) {
breakdownHtml += `<li>${WpBnb.config.i18n?.basePrice || 'Base'}: ${this.escapeHtml(data.breakdown.base_total)}</li>`;
}
if (data.breakdown.weekend_total && parseFloat(data.breakdown.weekend_total) > 0) {
breakdownHtml += `<li>${WpBnb.config.i18n?.weekendSurcharge || 'Weekend surcharge'}: ${this.escapeHtml(data.breakdown.weekend_total)}</li>`;
}
if (data.breakdown.season_name) {
breakdownHtml += `<li>${WpBnb.config.i18n?.season || 'Season'}: ${this.escapeHtml(data.breakdown.season_name)}</li>`;
}
breakdownHtml += '</ul>';
this.breakdownDisplay.innerHTML = breakdownHtml;
this.breakdownDisplay.style.display = 'block';
}
}
clearDisplay() {
if (this.priceDisplay) {
this.priceDisplay.innerHTML = '';
this.priceDisplay.style.display = 'none';
}
if (this.breakdownDisplay) {
this.breakdownDisplay.innerHTML = '';
this.breakdownDisplay.style.display = 'none';
}
}
escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
}
// Initialize on DOM ready.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => WpBnb.init());
} else {
WpBnb.init();
}
// Expose to global scope for potential external use.
window.WpBnb = WpBnb;
})();

358
assets/js/wc-integration.js Normal file
View File

@@ -0,0 +1,358 @@
/**
* WooCommerce Integration Scripts
*
* Handles booking form interactions for WooCommerce integration.
*
* @package Magdev\WpBnb
*/
(function ($) {
'use strict';
/**
* WP BnB WooCommerce Integration
*/
const WpBnbWC = {
/**
* Settings from localization
*/
settings: {},
/**
* Initialize
*/
init: function () {
this.settings = window.wpBnbWC || {};
this.bindEvents();
this.initBookingForms();
},
/**
* Bind global events
*/
bindEvents: function () {
// Admin: Sync all rooms button
$(document).on('click', '.bnb-sync-rooms-btn', this.handleSyncRooms.bind(this));
// Admin: Generate invoice button
$(document).on('click', '.bnb-generate-invoice-btn', this.handleGenerateInvoice.bind(this));
},
/**
* Initialize booking forms
*/
initBookingForms: function () {
$('.bnb-wc-booking-form').each(function () {
new BookingForm($(this));
});
},
/**
* Handle sync all rooms button
*/
handleSyncRooms: function (e) {
e.preventDefault();
const $btn = $(e.currentTarget);
const $status = $btn.siblings('.sync-status');
$btn.prop('disabled', true).addClass('updating');
$status.text(this.settings.i18n?.syncing || 'Syncing...');
$.ajax({
url: this.settings.ajaxUrl,
type: 'POST',
data: {
action: 'wp_bnb_sync_all_rooms',
nonce: this.settings.nonce
},
success: function (response) {
if (response.success) {
$status.html('<span class="success">' + response.data.message + '</span>');
} else {
$status.html('<span class="error">' + (response.data?.message || 'Error') + '</span>');
}
},
error: function () {
$status.html('<span class="error">' + (WpBnbWC.settings.i18n?.error || 'Error occurred') + '</span>');
},
complete: function () {
$btn.prop('disabled', false).removeClass('updating');
}
});
},
/**
* Handle generate invoice button
*/
handleGenerateInvoice: function (e) {
e.preventDefault();
const $btn = $(e.currentTarget);
const orderId = $btn.data('order-id');
$btn.prop('disabled', true).addClass('updating');
$.ajax({
url: this.settings.ajaxUrl,
type: 'POST',
data: {
action: 'wp_bnb_generate_invoice',
nonce: this.settings.nonce,
order_id: orderId
},
success: function (response) {
if (response.success) {
location.reload();
} else {
alert(response.data?.message || 'Error generating invoice');
}
},
error: function () {
alert(WpBnbWC.settings.i18n?.error || 'Error occurred');
},
complete: function () {
$btn.prop('disabled', false).removeClass('updating');
}
});
}
};
/**
* Booking Form Handler
*/
class BookingForm {
constructor($form) {
this.$form = $form;
this.roomId = $form.data('room-id');
this.productId = $form.data('product-id');
this.checkAvailabilityTimeout = null;
this.bindEvents();
}
bindEvents() {
this.$form.on('change', '.bnb-date-input', this.onDateChange.bind(this));
this.$form.on('change', '.bnb-guests-input', this.onGuestsChange.bind(this));
this.$form.on('change', '.bnb-service-checkbox', this.onServiceChange.bind(this));
this.$form.on('change', '.bnb-service-qty', this.onServiceQtyChange.bind(this));
this.$form.on('submit', this.onSubmit.bind(this));
}
onDateChange() {
const checkIn = this.$form.find('[name="bnb_check_in"]').val();
const checkOut = this.$form.find('[name="bnb_check_out"]').val();
// Validate dates
if (!checkIn || !checkOut) {
this.updateAvailabilityStatus('');
return;
}
const checkInDate = new Date(checkIn);
const checkOutDate = new Date(checkOut);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (checkInDate < today) {
this.updateAvailabilityStatus('unavailable', WpBnbWC.settings.i18n?.pastDate || 'Check-in cannot be in the past');
return;
}
if (checkOutDate <= checkInDate) {
this.updateAvailabilityStatus('unavailable', WpBnbWC.settings.i18n?.invalidDates || 'Check-out must be after check-in');
return;
}
// Check availability
this.checkAvailability(checkIn, checkOut);
}
onGuestsChange() {
// Re-validate if needed
const checkIn = this.$form.find('[name="bnb_check_in"]').val();
const checkOut = this.$form.find('[name="bnb_check_out"]').val();
if (checkIn && checkOut) {
this.checkAvailability(checkIn, checkOut);
}
}
onServiceChange(e) {
const $checkbox = $(e.currentTarget);
const $item = $checkbox.closest('.bnb-wc-service-item');
const $qtyInput = $item.find('.bnb-service-qty');
if ($checkbox.is(':checked')) {
$item.addClass('selected');
$qtyInput.prop('disabled', false);
} else {
$item.removeClass('selected');
$qtyInput.prop('disabled', true);
}
this.updatePriceDisplay();
}
onServiceQtyChange() {
this.updatePriceDisplay();
}
checkAvailability(checkIn, checkOut) {
// Debounce
clearTimeout(this.checkAvailabilityTimeout);
this.updateAvailabilityStatus('checking', WpBnbWC.settings.i18n?.checking || 'Checking availability...');
this.checkAvailabilityTimeout = setTimeout(() => {
const guests = this.$form.find('[name="bnb_guests"]').val() || 1;
$.ajax({
url: WpBnbWC.settings.ajaxUrl,
type: 'POST',
data: {
action: 'wp_bnb_get_availability',
nonce: WpBnbWC.settings.nonce,
room_id: this.roomId,
check_in: checkIn,
check_out: checkOut,
guests: guests
},
success: (response) => {
if (response.success) {
const data = response.data;
if (data.available) {
this.updateAvailabilityStatus('available', WpBnbWC.settings.i18n?.available || 'Available');
this.updatePriceDisplay(data.price, data.breakdown);
this.enableSubmit();
} else {
this.updateAvailabilityStatus('unavailable', data.message || WpBnbWC.settings.i18n?.unavailable || 'Not available');
this.disableSubmit();
}
} else {
this.updateAvailabilityStatus('unavailable', response.data?.message || 'Error checking availability');
this.disableSubmit();
}
},
error: () => {
this.updateAvailabilityStatus('unavailable', WpBnbWC.settings.i18n?.error || 'Error checking availability');
this.disableSubmit();
}
});
}, 500);
}
updateAvailabilityStatus(status, message) {
const $statusEl = this.$form.find('.bnb-availability-status');
if (!status) {
$statusEl.hide();
return;
}
$statusEl.removeClass('checking available unavailable').addClass(status);
let icon = '';
switch (status) {
case 'checking':
icon = '<span class="dashicons dashicons-update"></span>';
break;
case 'available':
icon = '<span class="dashicons dashicons-yes-alt"></span>';
break;
case 'unavailable':
icon = '<span class="dashicons dashicons-no-alt"></span>';
break;
}
$statusEl.html(icon + ' ' + message).show();
}
updatePriceDisplay(roomPrice, breakdown) {
const $priceDisplay = this.$form.find('.bnb-wc-price-display');
if (!roomPrice) {
$priceDisplay.hide();
return;
}
// Calculate services total
let servicesTotal = 0;
const nights = breakdown?.nights || 1;
this.$form.find('.bnb-wc-service-item').each(function () {
const $item = $(this);
const $checkbox = $item.find('.bnb-service-checkbox');
if ($checkbox.is(':checked')) {
const price = parseFloat($item.data('price')) || 0;
const pricingType = $item.data('pricing-type');
const qty = parseInt($item.find('.bnb-service-qty').val()) || 1;
if (pricingType === 'per_night') {
servicesTotal += price * qty * nights;
} else if (pricingType === 'per_booking') {
servicesTotal += price * qty;
}
}
});
const grandTotal = roomPrice + servicesTotal;
// Update display
let html = '<div class="price-row"><span>' + (WpBnbWC.settings.i18n?.roomTotal || 'Room') + '</span><span>' + WpBnbWC.formatPrice(roomPrice) + '</span></div>';
if (servicesTotal > 0) {
html += '<div class="price-row"><span>' + (WpBnbWC.settings.i18n?.services || 'Services') + '</span><span>' + WpBnbWC.formatPrice(servicesTotal) + '</span></div>';
}
html += '<div class="price-row total"><span>' + (WpBnbWC.settings.i18n?.total || 'Total') + '</span><span>' + WpBnbWC.formatPrice(grandTotal) + '</span></div>';
$priceDisplay.html(html).show();
}
enableSubmit() {
this.$form.find('.add-to-cart-btn').prop('disabled', false);
}
disableSubmit() {
this.$form.find('.add-to-cart-btn').prop('disabled', true);
}
onSubmit(e) {
// Form will submit normally - WooCommerce handles the add to cart
// Just validate one more time
const checkIn = this.$form.find('[name="bnb_check_in"]').val();
const checkOut = this.$form.find('[name="bnb_check_out"]').val();
if (!checkIn || !checkOut) {
e.preventDefault();
alert(WpBnbWC.settings.i18n?.selectDates || 'Please select check-in and check-out dates');
return false;
}
}
}
/**
* Format price
*/
WpBnbWC.formatPrice = function (price) {
const currency = this.settings.currency || 'CHF';
const symbol = this.settings.currencySymbol || currency;
const decimals = this.settings.priceDecimals || 2;
const decimalSep = this.settings.decimalSeparator || '.';
const thousandSep = this.settings.thousandSeparator || "'";
const formatted = price.toFixed(decimals).replace(/\B(?=(\d{3})+(?!\d))/g, thousandSep).replace('.', decimalSep);
return symbol + ' ' + formatted;
};
// Initialize on DOM ready
$(document).ready(function () {
WpBnbWC.init();
});
// Export for external use
window.WpBnbWC = WpBnbWC;
})(jQuery);

View File

@@ -22,7 +22,8 @@
"require": {
"php": ">=8.3.0",
"twig/twig": "^3.0",
"magdev/wc-licensed-product-client": "^0.2"
"magdev/wc-licensed-product-client": "^0.2",
"mpdf/mpdf": "^8.2"
},
"autoload": {
"psr-4": {

357
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "aed1e4dd36ea76994768a8379100314b",
"content-hash": "ae9fdb5fb51bbef492ad4f2a40406fd3",
"packages": [
{
"name": "magdev/wc-licensed-product-client",
@@ -56,6 +56,289 @@
"relative": true
}
},
{
"name": "mpdf/mpdf",
"version": "v8.2.7",
"source": {
"type": "git",
"url": "https://github.com/mpdf/mpdf.git",
"reference": "b59670a09498689c33ce639bac8f5ba26721dab3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/mpdf/mpdf/zipball/b59670a09498689c33ce639bac8f5ba26721dab3",
"reference": "b59670a09498689c33ce639bac8f5ba26721dab3",
"shasum": ""
},
"require": {
"ext-gd": "*",
"ext-mbstring": "*",
"mpdf/psr-http-message-shim": "^1.0 || ^2.0",
"mpdf/psr-log-aware-trait": "^2.0 || ^3.0",
"myclabs/deep-copy": "^1.7",
"paragonie/random_compat": "^1.4|^2.0|^9.99.99",
"php": "^5.6 || ^7.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"psr/http-message": "^1.0 || ^2.0",
"psr/log": "^1.0 || ^2.0 || ^3.0",
"setasign/fpdi": "^2.1"
},
"require-dev": {
"mockery/mockery": "^1.3.0",
"mpdf/qrcode": "^1.1.0",
"squizlabs/php_codesniffer": "^3.5.0",
"tracy/tracy": "~2.5",
"yoast/phpunit-polyfills": "^1.0"
},
"suggest": {
"ext-bcmath": "Needed for generation of some types of barcodes",
"ext-xml": "Needed mainly for SVG manipulation",
"ext-zlib": "Needed for compression of embedded resources, such as fonts"
},
"type": "library",
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Mpdf\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"GPL-2.0-only"
],
"authors": [
{
"name": "Matěj Humpál",
"role": "Developer, maintainer"
},
{
"name": "Ian Back",
"role": "Developer (retired)"
}
],
"description": "PHP library generating PDF files from UTF-8 encoded HTML",
"homepage": "https://mpdf.github.io",
"keywords": [
"pdf",
"php",
"utf-8"
],
"support": {
"docs": "https://mpdf.github.io",
"issues": "https://github.com/mpdf/mpdf/issues",
"source": "https://github.com/mpdf/mpdf"
},
"funding": [
{
"url": "https://www.paypal.me/mpdf",
"type": "custom"
}
],
"time": "2025-12-01T10:18:02+00:00"
},
{
"name": "mpdf/psr-http-message-shim",
"version": "v2.0.1",
"source": {
"type": "git",
"url": "https://github.com/mpdf/psr-http-message-shim.git",
"reference": "f25a0153d645e234f9db42e5433b16d9b113920f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/mpdf/psr-http-message-shim/zipball/f25a0153d645e234f9db42e5433b16d9b113920f",
"reference": "f25a0153d645e234f9db42e5433b16d9b113920f",
"shasum": ""
},
"require": {
"psr/http-message": "^2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Mpdf\\PsrHttpMessageShim\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Dorison",
"email": "mark@chromatichq.com"
},
{
"name": "Kristofer Widholm",
"email": "kristofer@chromatichq.com"
},
{
"name": "Nigel Cunningham",
"email": "nigel.cunningham@technocrat.com.au"
}
],
"description": "Shim to allow support of different psr/message versions.",
"support": {
"issues": "https://github.com/mpdf/psr-http-message-shim/issues",
"source": "https://github.com/mpdf/psr-http-message-shim/tree/v2.0.1"
},
"time": "2023-10-02T14:34:03+00:00"
},
{
"name": "mpdf/psr-log-aware-trait",
"version": "v3.0.0",
"source": {
"type": "git",
"url": "https://github.com/mpdf/psr-log-aware-trait.git",
"reference": "a633da6065e946cc491e1c962850344bb0bf3e78"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/mpdf/psr-log-aware-trait/zipball/a633da6065e946cc491e1c962850344bb0bf3e78",
"reference": "a633da6065e946cc491e1c962850344bb0bf3e78",
"shasum": ""
},
"require": {
"psr/log": "^3.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Mpdf\\PsrLogAwareTrait\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Dorison",
"email": "mark@chromatichq.com"
},
{
"name": "Kristofer Widholm",
"email": "kristofer@chromatichq.com"
}
],
"description": "Trait to allow support of different psr/log versions.",
"support": {
"issues": "https://github.com/mpdf/psr-log-aware-trait/issues",
"source": "https://github.com/mpdf/psr-log-aware-trait/tree/v3.0.0"
},
"time": "2023-05-03T06:19:36+00:00"
},
{
"name": "myclabs/deep-copy",
"version": "1.13.4",
"source": {
"type": "git",
"url": "https://github.com/myclabs/DeepCopy.git",
"reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
"reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"conflict": {
"doctrine/collections": "<1.6.8",
"doctrine/common": "<2.13.3 || >=3 <3.2.2"
},
"require-dev": {
"doctrine/collections": "^1.6.8",
"doctrine/common": "^2.13.3 || ^3.2.2",
"phpspec/prophecy": "^1.10",
"phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
},
"type": "library",
"autoload": {
"files": [
"src/DeepCopy/deep_copy.php"
],
"psr-4": {
"DeepCopy\\": "src/DeepCopy/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Create deep copies (clones) of your objects",
"keywords": [
"clone",
"copy",
"duplicate",
"object",
"object graph"
],
"support": {
"issues": "https://github.com/myclabs/DeepCopy/issues",
"source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
},
"funding": [
{
"url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
"type": "tidelift"
}
],
"time": "2025-08-01T08:46:24+00:00"
},
{
"name": "paragonie/random_compat",
"version": "v9.99.100",
"source": {
"type": "git",
"url": "https://github.com/paragonie/random_compat.git",
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a",
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
"shasum": ""
},
"require": {
"php": ">= 7"
},
"require-dev": {
"phpunit/phpunit": "4.*|5.*",
"vimeo/psalm": "^1"
},
"suggest": {
"ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
},
"type": "library",
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com"
}
],
"description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
"keywords": [
"csprng",
"polyfill",
"pseudorandom",
"random"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/random_compat/issues",
"source": "https://github.com/paragonie/random_compat"
},
"time": "2020-10-15T08:29:30+00:00"
},
{
"name": "psr/cache",
"version": "3.0.0",
@@ -313,6 +596,78 @@
},
"time": "2024-09-11T13:17:53+00:00"
},
{
"name": "setasign/fpdi",
"version": "v2.6.4",
"source": {
"type": "git",
"url": "https://github.com/Setasign/FPDI.git",
"reference": "4b53852fde2734ec6a07e458a085db627c60eada"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Setasign/FPDI/zipball/4b53852fde2734ec6a07e458a085db627c60eada",
"reference": "4b53852fde2734ec6a07e458a085db627c60eada",
"shasum": ""
},
"require": {
"ext-zlib": "*",
"php": "^7.1 || ^8.0"
},
"conflict": {
"setasign/tfpdf": "<1.31"
},
"require-dev": {
"phpunit/phpunit": "^7",
"setasign/fpdf": "~1.8.6",
"setasign/tfpdf": "~1.33",
"squizlabs/php_codesniffer": "^3.5",
"tecnickcom/tcpdf": "^6.8"
},
"suggest": {
"setasign/fpdf": "FPDI will extend this class but as it is also possible to use TCPDF or tFPDF as an alternative. There's no fixed dependency configured."
},
"type": "library",
"autoload": {
"psr-4": {
"setasign\\Fpdi\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jan Slabon",
"email": "jan.slabon@setasign.com",
"homepage": "https://www.setasign.com"
},
{
"name": "Maximilian Kresse",
"email": "maximilian.kresse@setasign.com",
"homepage": "https://www.setasign.com"
}
],
"description": "FPDI is a collection of PHP classes facilitating developers to read pages from existing PDF documents and use them as templates in FPDF. Because it is also possible to use FPDI with TCPDF, there are no fixed dependencies defined. Please see suggestions for packages which evaluates the dependencies automatically.",
"homepage": "https://www.setasign.com/fpdi",
"keywords": [
"fpdf",
"fpdi",
"pdf"
],
"support": {
"issues": "https://github.com/Setasign/FPDI/issues",
"source": "https://github.com/Setasign/FPDI/tree/v2.6.4"
},
"funding": [
{
"url": "https://tidelift.com/funding/github/packagist/setasign/fpdi",
"type": "tidelift"
}
],
"time": "2025-08-05T09:57:14+00:00"
},
{
"name": "symfony/deprecation-contracts",
"version": "v3.6.0",

BIN
languages/wp-bnb-de_CH.mo Normal file

Binary file not shown.

5162
languages/wp-bnb-de_CH.po Normal file

File diff suppressed because it is too large Load Diff

5163
languages/wp-bnb.pot Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -257,15 +257,19 @@ final class Calendar {
<?php foreach ( $rooms as $room ) : ?>
<?php
$room_number = get_post_meta( $room->ID, '_bnb_room_room_number', true );
$room_building = Room::get_building( $room->ID );
$booked_dates = Availability::get_booked_dates( $room->ID, $year, $month );
?>
<tr>
<td class="bnb-calendar-room">
<a href="<?php echo esc_url( get_edit_post_link( $room->ID ) ); ?>">
<?php echo esc_html( $room->post_title ); ?>
</a>
<?php if ( $room_number ) : ?>
<br><small>#<?php echo esc_html( $room_number ); ?></small>
<span class="room-number">#<?php echo esc_html( $room_number ); ?></span>
<?php endif; ?>
</a>
<?php if ( $room_building ) : ?>
<span class="building-name"><?php echo esc_html( $room_building->post_title ); ?></span>
<?php endif; ?>
</td>
<?php for ( $day = 1; $day <= $days_in_month; $day++ ) : ?>

942
src/Admin/Dashboard.php Normal file
View File

@@ -0,0 +1,942 @@
<?php
/**
* Admin Dashboard page.
*
* Displays comprehensive dashboard with statistics, charts, and quick actions.
*
* @package Magdev\WpBnb\Admin
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Admin;
use Magdev\WpBnb\Booking\Availability;
use Magdev\WpBnb\License\Manager as LicenseManager;
use Magdev\WpBnb\PostTypes\Booking;
use Magdev\WpBnb\PostTypes\Building;
use Magdev\WpBnb\PostTypes\Guest;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\Pricing\Calculator;
/**
* Dashboard class.
*/
final class Dashboard {
/**
* Cache key for dashboard stats.
*/
private const CACHE_KEY = 'wp_bnb_dashboard_stats';
/**
* Cache expiry in seconds (1 hour).
*/
private const CACHE_EXPIRY = HOUR_IN_SECONDS;
/**
* Render the dashboard page.
*
* @return void
*/
public static function render(): void {
$license_valid = LicenseManager::is_license_valid();
$is_localhost = LicenseManager::is_localhost();
?>
<div class="wrap">
<h1><?php esc_html_e( 'WP BnB Dashboard', 'wp-bnb' ); ?></h1>
<?php self::render_notices( $license_valid, $is_localhost ); ?>
<div class="wp-bnb-dashboard-grid">
<!-- Row 1: Stats Cards -->
<div class="wp-bnb-dashboard-row wp-bnb-stats-row">
<?php self::render_occupancy_card(); ?>
<?php self::render_revenue_card(); ?>
<?php self::render_bookings_card(); ?>
<?php self::render_guests_card(); ?>
</div>
<!-- Row 2: Charts -->
<div class="wp-bnb-dashboard-row wp-bnb-charts-row">
<?php self::render_occupancy_chart(); ?>
<?php self::render_revenue_chart(); ?>
</div>
<!-- Row 3: Activity and Quick Actions -->
<div class="wp-bnb-dashboard-row wp-bnb-activity-row">
<?php self::render_today_activity(); ?>
<?php self::render_upcoming_bookings(); ?>
<?php self::render_quick_actions(); ?>
</div>
</div>
</div>
<?php
}
/**
* Render admin notices.
*
* @param bool $license_valid Whether license is valid.
* @param bool $is_localhost Whether running on localhost.
* @return void
*/
private static function render_notices( bool $license_valid, bool $is_localhost ): void {
if ( $is_localhost ) :
?>
<div class="notice notice-info">
<p>
<span class="dashicons dashicons-info" style="color: #72aee6;"></span>
<strong><?php esc_html_e( 'Development Mode', 'wp-bnb' ); ?></strong>
<?php esc_html_e( 'You are running on a local development environment. All features are enabled.', 'wp-bnb' ); ?>
</p>
</div>
<?php
elseif ( ! $license_valid ) :
?>
<div class="notice notice-warning">
<p>
<?php
printf(
/* translators: %s: Link to settings page */
esc_html__( 'Your license is not active. Please %s to unlock all features.', 'wp-bnb' ),
'<a href="' . esc_url( admin_url( 'admin.php?page=wp-bnb-settings&tab=license' ) ) . '">' . esc_html__( 'activate your license', 'wp-bnb' ) . '</a>'
);
?>
</p>
</div>
<?php
endif;
}
/**
* Render occupancy stat card.
*
* @return void
*/
private static function render_occupancy_card(): void {
$stats = self::get_occupancy_stats();
$rate = $stats['rate'];
$occupied = $stats['occupied'];
$total = $stats['total'];
$previous_rate = $stats['previous_rate'];
$change = $rate - $previous_rate;
$change_class = $change >= 0 ? 'positive' : 'negative';
$change_icon = $change >= 0 ? 'arrow-up-alt' : 'arrow-down-alt';
?>
<div class="wp-bnb-stat-card">
<div class="wp-bnb-stat-icon">
<span class="dashicons dashicons-admin-home"></span>
</div>
<div class="wp-bnb-stat-content">
<div class="wp-bnb-stat-label"><?php esc_html_e( 'Current Occupancy', 'wp-bnb' ); ?></div>
<div class="wp-bnb-stat-value"><?php echo esc_html( number_format( $rate, 1 ) ); ?>%</div>
<div class="wp-bnb-stat-meta">
<?php
printf(
/* translators: 1: Number of occupied rooms, 2: Total rooms */
esc_html__( '%1$d of %2$d rooms', 'wp-bnb' ),
$occupied,
$total
);
?>
</div>
<?php if ( $previous_rate > 0 ) : ?>
<div class="wp-bnb-stat-change <?php echo esc_attr( $change_class ); ?>">
<span class="dashicons dashicons-<?php echo esc_attr( $change_icon ); ?>"></span>
<?php
printf(
/* translators: %s: Percentage change */
esc_html__( '%s%% vs last month', 'wp-bnb' ),
number_format( abs( $change ), 1 )
);
?>
</div>
<?php endif; ?>
</div>
</div>
<?php
}
/**
* Render revenue stat card.
*
* @return void
*/
private static function render_revenue_card(): void {
$stats = self::get_revenue_stats();
$this_month = $stats['this_month'];
$last_month = $stats['last_month'];
$change = $last_month > 0 ? ( ( $this_month - $last_month ) / $last_month ) * 100 : 0;
$change_class = $change >= 0 ? 'positive' : 'negative';
$change_icon = $change >= 0 ? 'arrow-up-alt' : 'arrow-down-alt';
?>
<div class="wp-bnb-stat-card">
<div class="wp-bnb-stat-icon revenue">
<span class="dashicons dashicons-chart-area"></span>
</div>
<div class="wp-bnb-stat-content">
<div class="wp-bnb-stat-label"><?php esc_html_e( 'Revenue This Month', 'wp-bnb' ); ?></div>
<div class="wp-bnb-stat-value"><?php echo esc_html( Calculator::formatPrice( $this_month ) ); ?></div>
<div class="wp-bnb-stat-meta">
<?php
printf(
/* translators: %s: Year-to-date revenue */
esc_html__( 'YTD: %s', 'wp-bnb' ),
Calculator::formatPrice( $stats['ytd'] )
);
?>
</div>
<?php if ( $last_month > 0 ) : ?>
<div class="wp-bnb-stat-change <?php echo esc_attr( $change_class ); ?>">
<span class="dashicons dashicons-<?php echo esc_attr( $change_icon ); ?>"></span>
<?php
printf(
/* translators: %s: Percentage change */
esc_html__( '%s%% vs last month', 'wp-bnb' ),
number_format( abs( $change ), 1 )
);
?>
</div>
<?php endif; ?>
</div>
</div>
<?php
}
/**
* Render bookings stat card.
*
* @return void
*/
private static function render_bookings_card(): void {
$stats = self::get_booking_stats();
?>
<div class="wp-bnb-stat-card">
<div class="wp-bnb-stat-icon bookings">
<span class="dashicons dashicons-calendar-alt"></span>
</div>
<div class="wp-bnb-stat-content">
<div class="wp-bnb-stat-label"><?php esc_html_e( 'Bookings This Month', 'wp-bnb' ); ?></div>
<div class="wp-bnb-stat-value"><?php echo esc_html( $stats['this_month'] ); ?></div>
<div class="wp-bnb-stat-meta">
<?php
printf(
/* translators: 1: Pending count, 2: Confirmed count */
esc_html__( '%1$d pending, %2$d confirmed', 'wp-bnb' ),
$stats['pending'],
$stats['confirmed']
);
?>
</div>
</div>
</div>
<?php
}
/**
* Render guests stat card.
*
* @return void
*/
private static function render_guests_card(): void {
$stats = self::get_guest_stats();
?>
<div class="wp-bnb-stat-card">
<div class="wp-bnb-stat-icon guests">
<span class="dashicons dashicons-groups"></span>
</div>
<div class="wp-bnb-stat-content">
<div class="wp-bnb-stat-label"><?php esc_html_e( 'Total Guests', 'wp-bnb' ); ?></div>
<div class="wp-bnb-stat-value"><?php echo esc_html( $stats['total'] ); ?></div>
<div class="wp-bnb-stat-meta">
<?php
printf(
/* translators: 1: New guests this month, 2: Repeat guests count */
esc_html__( '%1$d new this month, %2$d repeat', 'wp-bnb' ),
$stats['new_this_month'],
$stats['repeat']
);
?>
</div>
</div>
</div>
<?php
}
/**
* Render occupancy trend chart.
*
* @return void
*/
private static function render_occupancy_chart(): void {
?>
<div class="wp-bnb-widget wp-bnb-chart-widget">
<div class="wp-bnb-widget-header">
<h3><?php esc_html_e( 'Occupancy Trend (Last 30 Days)', 'wp-bnb' ); ?></h3>
</div>
<div class="wp-bnb-widget-content">
<canvas id="wp-bnb-occupancy-chart" height="200"></canvas>
</div>
</div>
<?php
}
/**
* Render revenue trend chart.
*
* @return void
*/
private static function render_revenue_chart(): void {
?>
<div class="wp-bnb-widget wp-bnb-chart-widget">
<div class="wp-bnb-widget-header">
<h3><?php esc_html_e( 'Revenue Trend (Last 6 Months)', 'wp-bnb' ); ?></h3>
</div>
<div class="wp-bnb-widget-content">
<canvas id="wp-bnb-revenue-chart" height="200"></canvas>
</div>
</div>
<?php
}
/**
* Render today's activity widget.
*
* @return void
*/
private static function render_today_activity(): void {
$checkins = Availability::get_todays_checkins();
$checkouts = Availability::get_todays_checkouts();
?>
<div class="wp-bnb-widget">
<div class="wp-bnb-widget-header">
<h3><?php esc_html_e( "Today's Activity", 'wp-bnb' ); ?></h3>
<span class="wp-bnb-widget-date"><?php echo esc_html( wp_date( get_option( 'date_format' ) ) ); ?></span>
</div>
<div class="wp-bnb-widget-content">
<?php if ( empty( $checkins ) && empty( $checkouts ) ) : ?>
<div class="wp-bnb-empty-state">
<span class="dashicons dashicons-calendar"></span>
<p><?php esc_html_e( 'No check-ins or check-outs scheduled for today.', 'wp-bnb' ); ?></p>
</div>
<?php else : ?>
<?php if ( ! empty( $checkins ) ) : ?>
<div class="wp-bnb-activity-section">
<h4>
<span class="dashicons dashicons-migrate"></span>
<?php esc_html_e( 'Check-ins', 'wp-bnb' ); ?>
<span class="count"><?php echo count( $checkins ); ?></span>
</h4>
<ul class="wp-bnb-activity-list">
<?php foreach ( $checkins as $booking ) : ?>
<?php
$guest_name = get_post_meta( $booking->ID, '_bnb_booking_guest_name', true );
$room_id = get_post_meta( $booking->ID, '_bnb_booking_room_id', true );
$room = get_post( $room_id );
?>
<li>
<a href="<?php echo esc_url( get_edit_post_link( $booking->ID ) ); ?>">
<strong><?php echo esc_html( $guest_name ); ?></strong>
<?php if ( $room ) : ?>
<span class="room"><?php echo esc_html( $room->post_title ); ?></span>
<?php endif; ?>
</a>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php if ( ! empty( $checkouts ) ) : ?>
<div class="wp-bnb-activity-section">
<h4>
<span class="dashicons dashicons-external"></span>
<?php esc_html_e( 'Check-outs', 'wp-bnb' ); ?>
<span class="count"><?php echo count( $checkouts ); ?></span>
</h4>
<ul class="wp-bnb-activity-list">
<?php foreach ( $checkouts as $booking ) : ?>
<?php
$guest_name = get_post_meta( $booking->ID, '_bnb_booking_guest_name', true );
$room_id = get_post_meta( $booking->ID, '_bnb_booking_room_id', true );
$room = get_post( $room_id );
?>
<li>
<a href="<?php echo esc_url( get_edit_post_link( $booking->ID ) ); ?>">
<strong><?php echo esc_html( $guest_name ); ?></strong>
<?php if ( $room ) : ?>
<span class="room"><?php echo esc_html( $room->post_title ); ?></span>
<?php endif; ?>
</a>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
<?php
}
/**
* Render upcoming bookings widget.
*
* @return void
*/
private static function render_upcoming_bookings(): void {
$bookings = self::get_upcoming_bookings( 7 );
?>
<div class="wp-bnb-widget">
<div class="wp-bnb-widget-header">
<h3><?php esc_html_e( 'Upcoming Bookings', 'wp-bnb' ); ?></h3>
<a href="<?php echo esc_url( admin_url( 'edit.php?post_type=' . Booking::POST_TYPE ) ); ?>" class="wp-bnb-view-all">
<?php esc_html_e( 'View All', 'wp-bnb' ); ?>
</a>
</div>
<div class="wp-bnb-widget-content">
<?php if ( empty( $bookings ) ) : ?>
<div class="wp-bnb-empty-state">
<span class="dashicons dashicons-calendar-alt"></span>
<p><?php esc_html_e( 'No upcoming bookings in the next 7 days.', 'wp-bnb' ); ?></p>
</div>
<?php else : ?>
<table class="wp-bnb-upcoming-table">
<thead>
<tr>
<th><?php esc_html_e( 'Guest', 'wp-bnb' ); ?></th>
<th><?php esc_html_e( 'Room', 'wp-bnb' ); ?></th>
<th><?php esc_html_e( 'Check-in', 'wp-bnb' ); ?></th>
<th><?php esc_html_e( 'Status', 'wp-bnb' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $bookings as $booking ) : ?>
<?php
$guest_name = get_post_meta( $booking->ID, '_bnb_booking_guest_name', true );
$room_id = get_post_meta( $booking->ID, '_bnb_booking_room_id', true );
$check_in = get_post_meta( $booking->ID, '_bnb_booking_check_in', true );
$status = get_post_meta( $booking->ID, '_bnb_booking_status', true );
$room = get_post( $room_id );
$statuses = Booking::get_booking_statuses();
$colors = Booking::get_status_colors();
?>
<tr>
<td>
<a href="<?php echo esc_url( get_edit_post_link( $booking->ID ) ); ?>">
<?php echo esc_html( $guest_name ); ?>
</a>
</td>
<td><?php echo $room ? esc_html( $room->post_title ) : '—'; ?></td>
<td><?php echo esc_html( wp_date( get_option( 'date_format' ), strtotime( $check_in ) ) ); ?></td>
<td>
<span class="wp-bnb-status-badge" style="background-color: <?php echo esc_attr( $colors[ $status ] ?? '#666' ); ?>">
<?php echo esc_html( $statuses[ $status ] ?? $status ); ?>
</span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
<?php
}
/**
* Render quick actions widget.
*
* @return void
*/
private static function render_quick_actions(): void {
?>
<div class="wp-bnb-widget wp-bnb-quick-actions">
<div class="wp-bnb-widget-header">
<h3><?php esc_html_e( 'Quick Actions', 'wp-bnb' ); ?></h3>
</div>
<div class="wp-bnb-widget-content">
<div class="wp-bnb-actions-grid">
<a href="<?php echo esc_url( admin_url( 'post-new.php?post_type=' . Booking::POST_TYPE ) ); ?>" class="wp-bnb-action-btn">
<span class="dashicons dashicons-plus-alt"></span>
<span><?php esc_html_e( 'New Booking', 'wp-bnb' ); ?></span>
</a>
<a href="<?php echo esc_url( admin_url( 'post-new.php?post_type=' . Guest::POST_TYPE ) ); ?>" class="wp-bnb-action-btn">
<span class="dashicons dashicons-admin-users"></span>
<span><?php esc_html_e( 'New Guest', 'wp-bnb' ); ?></span>
</a>
<a href="<?php echo esc_url( admin_url( 'admin.php?page=wp-bnb-calendar' ) ); ?>" class="wp-bnb-action-btn">
<span class="dashicons dashicons-calendar-alt"></span>
<span><?php esc_html_e( 'View Calendar', 'wp-bnb' ); ?></span>
</a>
<a href="<?php echo esc_url( admin_url( 'admin.php?page=wp-bnb-reports' ) ); ?>" class="wp-bnb-action-btn">
<span class="dashicons dashicons-analytics"></span>
<span><?php esc_html_e( 'View Reports', 'wp-bnb' ); ?></span>
</a>
</div>
</div>
</div>
<?php
}
/**
* Get occupancy statistics.
*
* @return array{rate: float, occupied: int, total: int, previous_rate: float}
*/
public static function get_occupancy_stats(): array {
// Get total rooms.
$rooms = get_posts(
array(
'post_type' => Room::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
)
);
$total_rooms = count( $rooms );
// Get currently occupied rooms.
$current_bookings = Availability::get_current_bookings();
$occupied_rooms = count( $current_bookings );
$rate = $total_rooms > 0 ? ( $occupied_rooms / $total_rooms ) * 100 : 0;
// Calculate last month's average occupancy.
$previous_rate = self::get_average_occupancy_for_month(
(int) gmdate( 'Y', strtotime( '-1 month' ) ),
(int) gmdate( 'n', strtotime( '-1 month' ) )
);
return array(
'rate' => $rate,
'occupied' => $occupied_rooms,
'total' => $total_rooms,
'previous_rate' => $previous_rate,
);
}
/**
* Get average occupancy rate for a specific month.
*
* @param int $year Year.
* @param int $month Month.
* @return float Average occupancy percentage.
*/
private static function get_average_occupancy_for_month( int $year, int $month ): float {
$cache_key = self::CACHE_KEY . "_occupancy_{$year}_{$month}";
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
return (float) $cached;
}
$rooms = get_posts(
array(
'post_type' => Room::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
)
);
$total_rooms = count( $rooms );
if ( $total_rooms === 0 ) {
return 0.0;
}
$days_in_month = (int) gmdate( 't', mktime( 0, 0, 0, $month, 1, $year ) );
$total_room_nights = $total_rooms * $days_in_month;
$booked_nights = 0;
$month_start = sprintf( '%04d-%02d-01', $year, $month );
$month_end = sprintf( '%04d-%02d-%02d', $year, $month, $days_in_month );
$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_status',
'value' => array( 'confirmed', 'checked_in', 'checked_out' ),
'compare' => 'IN',
),
array(
'key' => '_bnb_booking_check_in',
'value' => $month_end,
'compare' => '<=',
'type' => 'DATE',
),
array(
'key' => '_bnb_booking_check_out',
'value' => $month_start,
'compare' => '>=',
'type' => 'DATE',
),
),
)
);
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 );
// Clamp to month boundaries.
$start = max( $check_in, $month_start );
$end = min( $check_out, gmdate( 'Y-m-d', strtotime( $month_end . ' +1 day' ) ) );
$nights = Booking::calculate_nights( $start, $end );
$booked_nights += $nights;
}
$rate = ( $booked_nights / $total_room_nights ) * 100;
set_transient( $cache_key, $rate, self::CACHE_EXPIRY );
return $rate;
}
/**
* Get revenue statistics.
*
* @return array{this_month: float, last_month: float, ytd: float}
*/
public static function get_revenue_stats(): array {
$this_month = self::get_revenue_for_period(
gmdate( 'Y-m-01' ),
gmdate( 'Y-m-t' )
);
$last_month = self::get_revenue_for_period(
gmdate( 'Y-m-01', strtotime( '-1 month' ) ),
gmdate( 'Y-m-t', strtotime( '-1 month' ) )
);
$ytd = self::get_revenue_for_period(
gmdate( 'Y-01-01' ),
gmdate( 'Y-m-d' )
);
return array(
'this_month' => $this_month,
'last_month' => $last_month,
'ytd' => $ytd,
);
}
/**
* Get revenue for a specific period.
*
* @param string $start_date Start date (Y-m-d).
* @param string $end_date End date (Y-m-d).
* @return float Total revenue.
*/
public static function get_revenue_for_period( string $start_date, string $end_date ): float {
$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_status',
'value' => array( 'confirmed', 'checked_in', 'checked_out' ),
'compare' => 'IN',
),
array(
'key' => '_bnb_booking_check_in',
'value' => $start_date,
'compare' => '>=',
'type' => 'DATE',
),
array(
'key' => '_bnb_booking_check_in',
'value' => $end_date,
'compare' => '<=',
'type' => 'DATE',
),
),
)
);
$total = 0.0;
foreach ( $bookings as $booking ) {
$price = get_post_meta( $booking->ID, '_bnb_booking_calculated_price', true );
$total += (float) $price;
// Add services total.
$services_total = Booking::calculate_booking_services_total( $booking->ID );
$total += $services_total;
}
return $total;
}
/**
* Get booking statistics.
*
* @return array{this_month: int, pending: int, confirmed: int}
*/
public static function get_booking_stats(): array {
$month_start = gmdate( 'Y-m-01' );
$month_end = gmdate( 'Y-m-t' );
// Bookings created this month.
$this_month = get_posts(
array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
'date_query' => array(
array(
'after' => $month_start,
'before' => $month_end,
'inclusive' => true,
),
),
)
);
// Pending bookings.
$pending = get_posts(
array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
'meta_query' => array(
array(
'key' => '_bnb_booking_status',
'value' => 'pending',
),
),
)
);
// Confirmed bookings.
$confirmed = get_posts(
array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
'meta_query' => array(
array(
'key' => '_bnb_booking_status',
'value' => 'confirmed',
),
),
)
);
return array(
'this_month' => count( $this_month ),
'pending' => count( $pending ),
'confirmed' => count( $confirmed ),
);
}
/**
* Get guest statistics.
*
* @return array{total: int, new_this_month: int, repeat: int}
*/
public static function get_guest_stats(): array {
$month_start = gmdate( 'Y-m-01' );
$month_end = gmdate( 'Y-m-t' );
// Total guests.
$total = get_posts(
array(
'post_type' => Guest::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
)
);
// New guests this month.
$new_this_month = get_posts(
array(
'post_type' => Guest::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
'date_query' => array(
array(
'after' => $month_start,
'before' => $month_end,
'inclusive' => true,
),
),
)
);
// Repeat guests (2+ bookings).
$all_guests = get_posts(
array(
'post_type' => Guest::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
)
);
$repeat = 0;
foreach ( $all_guests as $guest ) {
$booking_count = Guest::get_booking_count( $guest->ID );
if ( $booking_count >= 2 ) {
++$repeat;
}
}
return array(
'total' => count( $total ),
'new_this_month' => count( $new_this_month ),
'repeat' => $repeat,
);
}
/**
* Get upcoming bookings.
*
* @param int $days Number of days to look ahead.
* @return array<\WP_Post> Array of booking posts.
*/
private static function get_upcoming_bookings( int $days = 7 ): array {
$today = gmdate( 'Y-m-d' );
$end_date = gmdate( 'Y-m-d', strtotime( "+{$days} days" ) );
return get_posts(
array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => 10,
'meta_query' => array(
'relation' => 'AND',
array(
'key' => '_bnb_booking_status',
'value' => array( 'pending', 'confirmed' ),
'compare' => 'IN',
),
array(
'key' => '_bnb_booking_check_in',
'value' => $today,
'compare' => '>=',
'type' => 'DATE',
),
array(
'key' => '_bnb_booking_check_in',
'value' => $end_date,
'compare' => '<=',
'type' => 'DATE',
),
),
'orderby' => 'meta_value',
'meta_key' => '_bnb_booking_check_in',
'order' => 'ASC',
)
);
}
/**
* Get occupancy trend data for charts.
*
* @param int $days Number of days to include.
* @return array{labels: array<string>, data: array<float>}
*/
public static function get_occupancy_trend_data( int $days = 30 ): array {
$labels = array();
$data = array();
$rooms = get_posts(
array(
'post_type' => Room::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
)
);
$total_rooms = count( $rooms );
if ( $total_rooms === 0 ) {
return array(
'labels' => array(),
'data' => array(),
);
}
for ( $i = $days - 1; $i >= 0; $i-- ) {
$date = gmdate( 'Y-m-d', strtotime( "-{$i} days" ) );
$labels[] = wp_date( 'M j', strtotime( $date ) );
// Count bookings active on this date.
$bookings = get_posts(
array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
'meta_query' => array(
'relation' => 'AND',
array(
'key' => '_bnb_booking_status',
'value' => array( 'confirmed', 'checked_in', 'checked_out' ),
'compare' => 'IN',
),
array(
'key' => '_bnb_booking_check_in',
'value' => $date,
'compare' => '<=',
'type' => 'DATE',
),
array(
'key' => '_bnb_booking_check_out',
'value' => $date,
'compare' => '>',
'type' => 'DATE',
),
),
)
);
$rate = ( count( $bookings ) / $total_rooms ) * 100;
$data[] = round( $rate, 1 );
}
return array(
'labels' => $labels,
'data' => $data,
);
}
/**
* Get revenue trend data for charts.
*
* @param int $months Number of months to include.
* @return array{labels: array<string>, data: array<float>}
*/
public static function get_revenue_trend_data( int $months = 6 ): array {
$labels = array();
$data = array();
for ( $i = $months - 1; $i >= 0; $i-- ) {
$month_start = gmdate( 'Y-m-01', strtotime( "-{$i} months" ) );
$month_end = gmdate( 'Y-m-t', strtotime( "-{$i} months" ) );
$month_name = gmdate( 'M Y', strtotime( $month_start ) );
$labels[] = $month_name;
$data[] = self::get_revenue_for_period( $month_start, $month_end );
}
return array(
'labels' => $labels,
'data' => $data,
);
}
}

1368
src/Admin/Reports.php Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,382 @@
<?php
/**
* Abstract REST Controller
*
* Base class for all REST API controllers with common functionality.
*
* @package Magdev\WpBnb\Api\Controllers
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api\Controllers;
use Magdev\WpBnb\Api\RestApi;
use Magdev\WpBnb\Api\RateLimiter;
use Magdev\WpBnb\Api\ResponseFormatter;
use WP_REST_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
/**
* Abstract Controller class.
*/
abstract class AbstractController extends WP_REST_Controller {
/**
* API namespace.
*
* @var string
*/
protected $namespace = RestApi::NAMESPACE;
/**
* Rate limiter instance.
*
* @var RateLimiter
*/
protected RateLimiter $rate_limiter;
/**
* Response formatter instance.
*
* @var ResponseFormatter
*/
protected ResponseFormatter $formatter;
/**
* Constructor.
*/
public function __construct() {
$this->rate_limiter = new RateLimiter();
$this->formatter = new ResponseFormatter();
}
/**
* Check rate limit before processing request.
*
* @param WP_REST_Request $request Current request.
* @return WP_Error|null Error if rate limited, null otherwise.
*/
protected function check_rate_limit( WP_REST_Request $request ): ?WP_Error {
// Skip rate limiting if disabled.
if ( 'yes' !== get_option( 'wp_bnb_api_rate_limiting', 'yes' ) ) {
return null;
}
$identifier = $this->get_client_identifier( $request );
$endpoint = $request->get_route();
if ( ! $this->rate_limiter->check( $identifier, $endpoint ) ) {
return $this->formatter->rate_limit_error(
$this->rate_limiter->get_retry_after( $identifier, $endpoint )
);
}
return null;
}
/**
* Add rate limit headers to response.
*
* @param WP_REST_Response $response Current response.
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response Response with headers.
*/
protected function add_rate_limit_headers( WP_REST_Response $response, WP_REST_Request $request ): WP_REST_Response {
if ( 'yes' !== get_option( 'wp_bnb_api_rate_limiting', 'yes' ) ) {
return $response;
}
$identifier = $this->get_client_identifier( $request );
$endpoint = $request->get_route();
$info = $this->rate_limiter->get_rate_limit_info( $identifier, $endpoint );
$response->header( 'X-RateLimit-Limit', (string) $info['limit'] );
$response->header( 'X-RateLimit-Remaining', (string) $info['remaining'] );
$response->header( 'X-RateLimit-Reset', (string) $info['reset'] );
return $response;
}
/**
* Get client identifier for rate limiting.
*
* @param WP_REST_Request $request Current request.
* @return string Client identifier.
*/
protected function get_client_identifier( WP_REST_Request $request ): string {
// Use user ID if authenticated.
$user_id = get_current_user_id();
if ( $user_id > 0 ) {
return 'user_' . $user_id;
}
return 'ip_' . $this->get_client_ip();
}
/**
* Get client IP address.
*
* Supports proxies and Cloudflare.
*
* @return string Client IP address.
*/
protected function get_client_ip(): string {
$headers = array(
'HTTP_CF_CONNECTING_IP', // Cloudflare.
'HTTP_X_FORWARDED_FOR', // Proxy.
'HTTP_X_REAL_IP', // Nginx.
'REMOTE_ADDR',
);
foreach ( $headers as $header ) {
if ( ! empty( $_SERVER[ $header ] ) ) {
$ip = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) );
// Handle comma-separated list (X-Forwarded-For).
if ( str_contains( $ip, ',' ) ) {
$ip = trim( explode( ',', $ip )[0] );
}
return $ip;
}
}
return '127.0.0.1';
}
/**
* Validate date format (Y-m-d).
*
* @param string $date Date string.
* @return bool True if valid.
*/
protected function validate_date( string $date ): bool {
$d = \DateTimeImmutable::createFromFormat( 'Y-m-d', $date );
return $d && $d->format( 'Y-m-d' ) === $date;
}
/**
* Validate date is not in the past.
*
* @param string $date Date string (Y-m-d).
* @return bool True if date is today or future.
*/
protected function validate_future_date( string $date ): bool {
if ( ! $this->validate_date( $date ) ) {
return false;
}
$date_obj = \DateTimeImmutable::createFromFormat( 'Y-m-d', $date );
$today = new \DateTimeImmutable( 'today' );
return $date_obj >= $today;
}
/**
* Permission callback for public endpoints.
*
* @return bool Always true.
*/
public function public_permission(): bool {
return true;
}
/**
* Permission callback for authenticated endpoints.
*
* @return bool True if logged in.
*/
public function authenticated_permission(): bool {
return is_user_logged_in();
}
/**
* Permission callback for admin endpoints.
*
* @return bool True if user can edit posts.
*/
public function admin_permission(): bool {
return current_user_can( 'edit_posts' );
}
/**
* Permission callback for managing bookings.
*
* @return bool True if user can edit posts.
*/
public function manage_bookings_permission(): bool {
return current_user_can( 'edit_posts' );
}
/**
* Get pagination parameters from request.
*
* @param WP_REST_Request $request Current request.
* @return array{page: int, per_page: int, offset: int}
*/
protected function get_pagination_params( WP_REST_Request $request ): array {
$page = max( 1, (int) $request->get_param( 'page' ) ?: 1 );
$per_page = min( 100, max( 1, (int) $request->get_param( 'per_page' ) ?: 10 ) );
$offset = ( $page - 1 ) * $per_page;
return array(
'page' => $page,
'per_page' => $per_page,
'offset' => $offset,
);
}
/**
* Get sorting parameters from request.
*
* @param WP_REST_Request $request Current request.
* @param array $allowed_orderby Allowed orderby values.
* @param string $default_orderby Default orderby value.
* @return array{orderby: string, order: string}
*/
protected function get_sorting_params( WP_REST_Request $request, array $allowed_orderby = array( 'title', 'date' ), string $default_orderby = 'title' ): array {
$orderby = $request->get_param( 'orderby' ) ?: $default_orderby;
$order = strtoupper( $request->get_param( 'order' ) ?: 'ASC' );
// Validate orderby.
if ( ! in_array( $orderby, $allowed_orderby, true ) ) {
$orderby = $default_orderby;
}
// Validate order.
if ( ! in_array( $order, array( 'ASC', 'DESC' ), true ) ) {
$order = 'ASC';
}
return array(
'orderby' => $orderby,
'order' => $order,
);
}
/**
* Format post for API response.
*
* @param \WP_Post $post Post object.
* @return array Basic post data.
*/
protected function format_post_base( \WP_Post $post ): array {
return array(
'id' => $post->ID,
'title' => get_the_title( $post ),
'slug' => $post->post_name,
'excerpt' => get_the_excerpt( $post ),
'content' => apply_filters( 'the_content', $post->post_content ),
);
}
/**
* Format featured image for API response.
*
* @param int $post_id Post ID.
* @return array|null Image data or null.
*/
protected function format_featured_image( int $post_id ): ?array {
$thumbnail_id = get_post_thumbnail_id( $post_id );
if ( ! $thumbnail_id ) {
return null;
}
return $this->format_image( $thumbnail_id );
}
/**
* Format image attachment for API response.
*
* @param int $attachment_id Attachment ID.
* @return array|null Image data or null.
*/
protected function format_image( int $attachment_id ): ?array {
$full = wp_get_attachment_image_src( $attachment_id, 'full' );
if ( ! $full ) {
return null;
}
$sizes = array();
foreach ( array( 'thumbnail', 'medium', 'large' ) as $size ) {
$src = wp_get_attachment_image_src( $attachment_id, $size );
if ( $src ) {
$sizes[ $size ] = array(
'url' => $src[0],
'width' => $src[1],
'height' => $src[2],
);
}
}
return array(
'id' => $attachment_id,
'url' => $full[0],
'width' => $full[1],
'height' => $full[2],
'alt' => get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ),
'sizes' => $sizes,
);
}
/**
* Add HATEOAS links to response item.
*
* @param array $item Response item.
* @param string $route Base route for self link.
* @param int $id Item ID.
* @return array Item with _links.
*/
protected function add_links( array $item, string $route, int $id ): array {
$item['_links'] = array(
'self' => array(
array(
'href' => rest_url( $this->namespace . '/' . $route . '/' . $id ),
),
),
);
return $item;
}
/**
* Get common collection parameters for schema.
*
* @return array Collection parameters.
*/
public function get_collection_params(): array {
return array(
'page' => array(
'description' => __( 'Current page of the collection.', 'wp-bnb' ),
'type' => 'integer',
'default' => 1,
'minimum' => 1,
'sanitize_callback' => 'absint',
),
'per_page' => array(
'description' => __( 'Maximum number of items to be returned per page.', 'wp-bnb' ),
'type' => 'integer',
'default' => 10,
'minimum' => 1,
'maximum' => 100,
'sanitize_callback' => 'absint',
),
'search' => array(
'description' => __( 'Limit results to those matching a string.', 'wp-bnb' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
'orderby' => array(
'description' => __( 'Sort collection by attribute.', 'wp-bnb' ),
'type' => 'string',
'default' => 'title',
'sanitize_callback' => 'sanitize_text_field',
),
'order' => array(
'description' => __( 'Order sort attribute ascending or descending.', 'wp-bnb' ),
'type' => 'string',
'default' => 'asc',
'enum' => array( 'asc', 'desc' ),
'sanitize_callback' => 'sanitize_text_field',
),
);
}
}

View File

@@ -0,0 +1,930 @@
<?php
/**
* Bookings REST Controller
*
* Handles REST API endpoints for bookings.
*
* @package Magdev\WpBnb\Api\Controllers
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api\Controllers;
use Magdev\WpBnb\PostTypes\Booking;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\PostTypes\Guest;
use Magdev\WpBnb\PostTypes\Service;
use Magdev\WpBnb\Booking\Availability;
use Magdev\WpBnb\Booking\EmailNotifier;
use Magdev\WpBnb\Pricing\Calculator;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use WP_Error;
/**
* Bookings Controller class.
*/
final class BookingsController extends AbstractController {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'bookings';
/**
* Register routes.
*
* @return void
*/
public function register_routes(): void {
// GET /bookings - List bookings (admin).
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'admin_permission' ),
'args' => $this->get_bookings_collection_params(),
),
)
);
// POST /bookings - Create booking (public).
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'create_item' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => $this->get_create_booking_params(),
),
)
);
// GET /bookings/{id} - Get single booking.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( $this, 'admin_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Booking ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
),
),
)
);
// PATCH /bookings/{id} - Update booking (admin).
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)',
array(
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'update_item' ),
'permission_callback' => array( $this, 'admin_permission' ),
'args' => $this->get_update_booking_params(),
),
)
);
// DELETE /bookings/{id} - Cancel booking (admin).
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)',
array(
array(
'methods' => WP_REST_Server::DELETABLE,
'callback' => array( $this, 'delete_item' ),
'permission_callback' => array( $this, 'admin_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Booking ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
),
),
)
);
// POST /bookings/{id}/confirm - Confirm booking.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)/confirm',
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'confirm_booking' ),
'permission_callback' => array( $this, 'admin_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Booking ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
),
),
)
);
// POST /bookings/{id}/check-in - Check in guest.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)/check-in',
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'check_in_booking' ),
'permission_callback' => array( $this, 'admin_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Booking ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
),
),
)
);
// POST /bookings/{id}/check-out - Check out guest.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)/check-out',
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'check_out_booking' ),
'permission_callback' => array( $this, 'admin_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Booking ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
),
),
)
);
}
/**
* Get collection of bookings.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_items( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$pagination = $this->get_pagination_params( $request );
$args = array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => $pagination['per_page'],
'offset' => $pagination['offset'],
'orderby' => 'date',
'order' => 'DESC',
);
$meta_query = array();
// Status filter.
$status = $request->get_param( 'status' );
if ( $status ) {
$meta_query[] = array(
'key' => '_bnb_booking_status',
'value' => $status,
);
}
// Room filter.
$room_id = $request->get_param( 'room_id' );
if ( $room_id ) {
$meta_query[] = array(
'key' => '_bnb_booking_room_id',
'value' => $room_id,
);
}
// Guest filter.
$guest_id = $request->get_param( 'guest_id' );
if ( $guest_id ) {
$meta_query[] = array(
'key' => '_bnb_booking_guest_id',
'value' => $guest_id,
);
}
// Date range filter.
$date_from = $request->get_param( 'date_from' );
if ( $date_from && $this->validate_date( $date_from ) ) {
$meta_query[] = array(
'key' => '_bnb_booking_check_in',
'value' => $date_from,
'compare' => '>=',
'type' => 'DATE',
);
}
$date_to = $request->get_param( 'date_to' );
if ( $date_to && $this->validate_date( $date_to ) ) {
$meta_query[] = array(
'key' => '_bnb_booking_check_in',
'value' => $date_to,
'compare' => '<=',
'type' => 'DATE',
);
}
if ( ! empty( $meta_query ) ) {
$meta_query['relation'] = 'AND';
$args['meta_query'] = $meta_query;
}
$query = new \WP_Query( $args );
$items = array();
foreach ( $query->posts as $post ) {
$items[] = $this->prepare_booking_response( $post );
}
$response = $this->formatter->collection(
$items,
$query->found_posts,
$pagination['page'],
$pagination['per_page']
);
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Create a booking.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function create_item( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$room_id = $request->get_param( 'room_id' );
$check_in = $request->get_param( 'check_in' );
$check_out = $request->get_param( 'check_out' );
$guest = $request->get_param( 'guest' );
// Validate room.
$room = get_post( $room_id );
if ( ! $room || Room::POST_TYPE !== $room->post_type || 'publish' !== $room->post_status ) {
return $this->formatter->not_found( __( 'Room', 'wp-bnb' ) );
}
// Validate dates.
if ( ! $this->validate_date( $check_in ) ) {
return $this->formatter->validation_error( 'check_in', __( 'Invalid check-in date format. Use Y-m-d.', 'wp-bnb' ) );
}
if ( ! $this->validate_date( $check_out ) ) {
return $this->formatter->validation_error( 'check_out', __( 'Invalid check-out date format. Use Y-m-d.', 'wp-bnb' ) );
}
if ( $check_in >= $check_out ) {
return $this->formatter->validation_error( 'check_out', __( 'Check-out must be after check-in.', 'wp-bnb' ) );
}
// Check availability.
if ( ! Availability::is_available( $room_id, $check_in, $check_out ) ) {
return $this->formatter->conflict(
__( 'Room is not available for the selected dates.', 'wp-bnb' )
);
}
// Validate guest info.
if ( empty( $guest['first_name'] ) || empty( $guest['last_name'] ) || empty( $guest['email'] ) ) {
return $this->formatter->validation_error( 'guest', __( 'Guest first name, last name, and email are required.', 'wp-bnb' ) );
}
if ( ! is_email( $guest['email'] ) ) {
return $this->formatter->validation_error( 'guest.email', __( 'Invalid email address.', 'wp-bnb' ) );
}
// Find or create guest.
$guest_id = $this->find_or_create_guest( $guest );
// Calculate price.
$price = Calculator::calculate( $room_id, $check_in, $check_out );
$services = $request->get_param( 'services' ) ?? array();
$room_price = $price['price'] ?? 0;
// Calculate services total.
$services_total = 0;
$services_data = array();
$check_in_date = new \DateTimeImmutable( $check_in );
$check_out_date = new \DateTimeImmutable( $check_out );
$nights = (int) $check_in_date->diff( $check_out_date )->days;
foreach ( $services as $service_item ) {
$service_id = $service_item['service_id'] ?? 0;
$quantity = $service_item['quantity'] ?? 1;
$service_data = Service::get_service_data( $service_id );
if ( $service_data ) {
$service_price = Service::calculate_service_price( $service_id, $quantity, $nights );
$services_total += $service_price;
$services_data[] = array(
'service_id' => $service_id,
'quantity' => $quantity,
'price' => $service_price,
'pricing_type' => $service_data['pricing_type'],
);
}
}
$total_price = $room_price + $services_total;
// Generate reference.
$reference = Booking::generate_reference();
// Create booking post.
$guest_name = trim( $guest['first_name'] . ' ' . $guest['last_name'] );
$post_id = wp_insert_post(
array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'publish',
'post_title' => $guest_name . ' (' . $check_in . ' - ' . $check_out . ')',
)
);
if ( is_wp_error( $post_id ) ) {
return $this->formatter->server_error( __( 'Failed to create booking.', 'wp-bnb' ) );
}
// Save meta.
update_post_meta( $post_id, '_bnb_booking_room_id', $room_id );
update_post_meta( $post_id, '_bnb_booking_guest_id', $guest_id );
update_post_meta( $post_id, '_bnb_booking_guest_name', $guest_name );
update_post_meta( $post_id, '_bnb_booking_guest_email', sanitize_email( $guest['email'] ) );
update_post_meta( $post_id, '_bnb_booking_guest_phone', sanitize_text_field( $guest['phone'] ?? '' ) );
update_post_meta( $post_id, '_bnb_booking_check_in', $check_in );
update_post_meta( $post_id, '_bnb_booking_check_out', $check_out );
update_post_meta( $post_id, '_bnb_booking_status', 'pending' );
update_post_meta( $post_id, '_bnb_booking_adults', absint( $request->get_param( 'guests_count' ) ?? 1 ) );
update_post_meta( $post_id, '_bnb_booking_calculated_price', $room_price );
update_post_meta( $post_id, '_bnb_booking_total_price', $total_price );
update_post_meta( $post_id, '_bnb_booking_reference', $reference );
if ( ! empty( $services_data ) ) {
update_post_meta( $post_id, '_bnb_booking_services', wp_json_encode( $services_data ) );
}
$notes = $request->get_param( 'notes' );
if ( $notes ) {
update_post_meta( $post_id, '_bnb_booking_guest_notes', sanitize_textarea_field( $notes ) );
}
// Send notification email.
if ( class_exists( EmailNotifier::class ) ) {
EmailNotifier::send_admin_notification( $post_id );
}
// Prepare response.
$booking = get_post( $post_id );
$data = $this->prepare_booking_response( $booking, true );
$location = rest_url( $this->namespace . '/bookings/' . $post_id );
$response = $this->formatter->created( $data, $location );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Get single booking.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_item( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$id = $request->get_param( 'id' );
$post = get_post( $id );
if ( ! $post || Booking::POST_TYPE !== $post->post_type ) {
return $this->formatter->not_found( __( 'Booking', 'wp-bnb' ) );
}
$data = $this->prepare_booking_response( $post, true );
$response = $this->formatter->success( $data );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Update a booking.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function update_item( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$id = $request->get_param( 'id' );
$post = get_post( $id );
if ( ! $post || Booking::POST_TYPE !== $post->post_type ) {
return $this->formatter->not_found( __( 'Booking', 'wp-bnb' ) );
}
// Update status if provided.
$status = $request->get_param( 'status' );
if ( $status ) {
$current_status = get_post_meta( $id, '_bnb_booking_status', true );
if ( ! Booking::can_transition_to( $current_status, $status ) ) {
return $this->formatter->validation_error(
'status',
sprintf(
/* translators: %1$s: current status, %2$s: target status */
__( 'Cannot transition from %1$s to %2$s.', 'wp-bnb' ),
$current_status,
$status
)
);
}
update_post_meta( $id, '_bnb_booking_status', $status );
if ( 'confirmed' === $status ) {
update_post_meta( $id, '_bnb_booking_confirmed_at', current_time( 'mysql' ) );
}
}
// Update notes if provided.
$notes = $request->get_param( 'notes' );
if ( null !== $notes ) {
update_post_meta( $id, '_bnb_booking_notes', sanitize_textarea_field( $notes ) );
}
// Update guest notes if provided.
$guest_notes = $request->get_param( 'guest_notes' );
if ( null !== $guest_notes ) {
update_post_meta( $id, '_bnb_booking_guest_notes', sanitize_textarea_field( $guest_notes ) );
}
$booking = get_post( $id );
$data = $this->prepare_booking_response( $booking, true );
$response = $this->formatter->success( $data );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Cancel a booking.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function delete_item( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$id = $request->get_param( 'id' );
$post = get_post( $id );
if ( ! $post || Booking::POST_TYPE !== $post->post_type ) {
return $this->formatter->not_found( __( 'Booking', 'wp-bnb' ) );
}
// Cancel the booking (don't delete).
update_post_meta( $id, '_bnb_booking_status', 'cancelled' );
// Send cancellation email.
if ( class_exists( EmailNotifier::class ) ) {
EmailNotifier::send_cancellation_email( $id );
}
return $this->formatter->no_content();
}
/**
* Confirm a booking.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function confirm_booking( $request ) {
return $this->transition_status( $request, 'confirmed' );
}
/**
* Check in a booking.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function check_in_booking( $request ) {
return $this->transition_status( $request, 'checked_in' );
}
/**
* Check out a booking.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function check_out_booking( $request ) {
return $this->transition_status( $request, 'checked_out' );
}
/**
* Transition booking status.
*
* @param WP_REST_Request $request Current request.
* @param string $new_status Target status.
* @return WP_REST_Response|WP_Error Response object or error.
*/
private function transition_status( WP_REST_Request $request, string $new_status ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$id = $request->get_param( 'id' );
$post = get_post( $id );
if ( ! $post || Booking::POST_TYPE !== $post->post_type ) {
return $this->formatter->not_found( __( 'Booking', 'wp-bnb' ) );
}
$current_status = get_post_meta( $id, '_bnb_booking_status', true );
if ( ! Booking::can_transition_to( $current_status, $new_status ) ) {
return $this->formatter->validation_error(
'status',
sprintf(
/* translators: %1$s: current status, %2$s: target status */
__( 'Cannot transition from %1$s to %2$s.', 'wp-bnb' ),
$current_status,
$new_status
)
);
}
update_post_meta( $id, '_bnb_booking_status', $new_status );
if ( 'confirmed' === $new_status ) {
update_post_meta( $id, '_bnb_booking_confirmed_at', current_time( 'mysql' ) );
if ( class_exists( EmailNotifier::class ) ) {
EmailNotifier::send_confirmation_email( $id );
}
}
$booking = get_post( $id );
$data = $this->prepare_booking_response( $booking, true );
$response = $this->formatter->success( $data );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Find or create a guest from booking data.
*
* @param array $guest_data Guest data.
* @return int Guest post ID.
*/
private function find_or_create_guest( array $guest_data ): int {
$email = sanitize_email( $guest_data['email'] );
// Check if guest exists.
$existing = Guest::get_by_email( $email );
if ( $existing ) {
return $existing->ID;
}
// Create new guest.
$guest_name = trim( $guest_data['first_name'] . ' ' . $guest_data['last_name'] );
$guest_id = wp_insert_post(
array(
'post_type' => Guest::POST_TYPE,
'post_status' => 'publish',
'post_title' => $guest_name,
)
);
if ( is_wp_error( $guest_id ) ) {
return 0;
}
// Save guest meta.
update_post_meta( $guest_id, '_bnb_guest_first_name', sanitize_text_field( $guest_data['first_name'] ) );
update_post_meta( $guest_id, '_bnb_guest_last_name', sanitize_text_field( $guest_data['last_name'] ) );
update_post_meta( $guest_id, '_bnb_guest_email', $email );
update_post_meta( $guest_id, '_bnb_guest_status', 'active' );
if ( ! empty( $guest_data['phone'] ) ) {
update_post_meta( $guest_id, '_bnb_guest_phone', sanitize_text_field( $guest_data['phone'] ) );
}
if ( ! empty( $guest_data['address'] ) ) {
$address = $guest_data['address'];
if ( ! empty( $address['street'] ) ) {
update_post_meta( $guest_id, '_bnb_guest_street', sanitize_text_field( $address['street'] ) );
}
if ( ! empty( $address['city'] ) ) {
update_post_meta( $guest_id, '_bnb_guest_city', sanitize_text_field( $address['city'] ) );
}
if ( ! empty( $address['postal_code'] ) ) {
update_post_meta( $guest_id, '_bnb_guest_postal_code', sanitize_text_field( $address['postal_code'] ) );
}
if ( ! empty( $address['country'] ) ) {
update_post_meta( $guest_id, '_bnb_guest_country', sanitize_text_field( $address['country'] ) );
}
}
return $guest_id;
}
/**
* Prepare booking data for response.
*
* @param \WP_Post $post Booking post object.
* @param bool $full Include full details.
* @return array Booking data.
*/
private function prepare_booking_response( \WP_Post $post, bool $full = false ): array {
$room_id = get_post_meta( $post->ID, '_bnb_booking_room_id', true );
$guest_id = get_post_meta( $post->ID, '_bnb_booking_guest_id', true );
$check_in = get_post_meta( $post->ID, '_bnb_booking_check_in', true );
$check_out = get_post_meta( $post->ID, '_bnb_booking_check_out', true );
$status = get_post_meta( $post->ID, '_bnb_booking_status', true );
// Calculate nights.
$nights = 0;
if ( $check_in && $check_out ) {
$check_in_date = new \DateTimeImmutable( $check_in );
$check_out_date = new \DateTimeImmutable( $check_out );
$nights = (int) $check_in_date->diff( $check_out_date )->days;
}
$room = $room_id ? get_post( $room_id ) : null;
$data = array(
'id' => $post->ID,
'reference' => get_post_meta( $post->ID, '_bnb_booking_reference', true ) ?: $post->post_title,
'status' => $status,
'room' => $room ? array(
'id' => $room->ID,
'title' => get_the_title( $room ),
'room_number' => get_post_meta( $room->ID, '_bnb_room_room_number', true ),
) : null,
'guest' => array(
'id' => (int) $guest_id,
'name' => get_post_meta( $post->ID, '_bnb_booking_guest_name', true ),
'email' => get_post_meta( $post->ID, '_bnb_booking_guest_email', true ),
'phone' => get_post_meta( $post->ID, '_bnb_booking_guest_phone', true ),
),
'dates' => array(
'check_in' => $check_in,
'check_out' => $check_out,
'nights' => $nights,
),
'pricing' => array(
'room_total' => (float) get_post_meta( $post->ID, '_bnb_booking_calculated_price', true ),
'services_total' => 0,
'grand_total' => (float) get_post_meta( $post->ID, '_bnb_booking_total_price', true ),
'currency' => get_option( 'wp_bnb_currency', 'CHF' ),
),
'created_at' => $post->post_date_gmt,
);
// Get building info.
if ( $room ) {
$building_id = get_post_meta( $room->ID, '_bnb_room_building_id', true );
$building = $building_id ? get_post( $building_id ) : null;
if ( $building ) {
$data['building'] = array(
'id' => $building->ID,
'title' => get_the_title( $building ),
);
}
}
// Calculate services total.
$services_json = get_post_meta( $post->ID, '_bnb_booking_services', true );
if ( $services_json ) {
$services = json_decode( $services_json, true );
if ( is_array( $services ) ) {
$services_total = 0;
$services_list = array();
foreach ( $services as $service ) {
$services_total += (float) ( $service['price'] ?? 0 );
$service_post = get_post( $service['service_id'] );
$services_list[] = array(
'id' => $service['service_id'],
'name' => $service_post ? get_the_title( $service_post ) : '',
'quantity' => $service['quantity'] ?? 1,
'price' => (float) ( $service['price'] ?? 0 ),
);
}
$data['pricing']['services_total'] = $services_total;
$data['services'] = $services_list;
}
}
if ( $full ) {
$data['notes'] = get_post_meta( $post->ID, '_bnb_booking_notes', true );
$data['guest_notes'] = get_post_meta( $post->ID, '_bnb_booking_guest_notes', true );
$data['adults'] = (int) get_post_meta( $post->ID, '_bnb_booking_adults', true );
$data['children'] = (int) get_post_meta( $post->ID, '_bnb_booking_children', true );
$data['confirmed_at'] = get_post_meta( $post->ID, '_bnb_booking_confirmed_at', true );
}
$data['_links'] = array(
'self' => array(
array( 'href' => rest_url( $this->namespace . '/bookings/' . $post->ID ) ),
),
'room' => $room ? array(
array( 'href' => rest_url( $this->namespace . '/rooms/' . $room->ID ) ),
) : array(),
);
return $data;
}
/**
* Get bookings collection parameters.
*
* @return array Collection parameters.
*/
private function get_bookings_collection_params(): array {
$params = $this->get_collection_params();
$params['status'] = array(
'description' => __( 'Filter by booking status.', 'wp-bnb' ),
'type' => 'string',
'enum' => array( 'pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled' ),
'sanitize_callback' => 'sanitize_text_field',
);
$params['room_id'] = array(
'description' => __( 'Filter by room ID.', 'wp-bnb' ),
'type' => 'integer',
'sanitize_callback' => 'absint',
);
$params['guest_id'] = array(
'description' => __( 'Filter by guest ID.', 'wp-bnb' ),
'type' => 'integer',
'sanitize_callback' => 'absint',
);
$params['date_from'] = array(
'description' => __( 'Filter bookings with check-in from this date (Y-m-d).', 'wp-bnb' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
);
$params['date_to'] = array(
'description' => __( 'Filter bookings with check-in until this date (Y-m-d).', 'wp-bnb' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
);
return $params;
}
/**
* Get create booking parameters.
*
* @return array Create parameters.
*/
private function get_create_booking_params(): array {
return array(
'room_id' => array(
'description' => __( 'Room ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
'check_in' => array(
'description' => __( 'Check-in date (Y-m-d).', 'wp-bnb' ),
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
'check_out' => array(
'description' => __( 'Check-out date (Y-m-d).', 'wp-bnb' ),
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
'guest' => array(
'description' => __( 'Guest information.', 'wp-bnb' ),
'type' => 'object',
'required' => true,
'properties' => array(
'first_name' => array( 'type' => 'string', 'required' => true ),
'last_name' => array( 'type' => 'string', 'required' => true ),
'email' => array( 'type' => 'string', 'required' => true, 'format' => 'email' ),
'phone' => array( 'type' => 'string' ),
'address' => array(
'type' => 'object',
'properties' => array(
'street' => array( 'type' => 'string' ),
'city' => array( 'type' => 'string' ),
'postal_code' => array( 'type' => 'string' ),
'country' => array( 'type' => 'string' ),
),
),
),
),
'guests_count' => array(
'description' => __( 'Number of guests.', 'wp-bnb' ),
'type' => 'integer',
'default' => 1,
'minimum' => 1,
'sanitize_callback' => 'absint',
),
'services' => array(
'description' => __( 'Additional services.', 'wp-bnb' ),
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'service_id' => array( 'type' => 'integer', 'required' => true ),
'quantity' => array( 'type' => 'integer', 'default' => 1 ),
),
),
),
'notes' => array(
'description' => __( 'Guest notes or special requests.', 'wp-bnb' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_textarea_field',
),
);
}
/**
* Get update booking parameters.
*
* @return array Update parameters.
*/
private function get_update_booking_params(): array {
return array(
'id' => array(
'description' => __( 'Booking ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
'status' => array(
'description' => __( 'Booking status.', 'wp-bnb' ),
'type' => 'string',
'enum' => array( 'pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled' ),
'sanitize_callback' => 'sanitize_text_field',
),
'notes' => array(
'description' => __( 'Internal staff notes.', 'wp-bnb' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_textarea_field',
),
'guest_notes' => array(
'description' => __( 'Guest notes or special requests.', 'wp-bnb' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_textarea_field',
),
);
}
}

View File

@@ -0,0 +1,323 @@
<?php
/**
* Buildings REST Controller
*
* Handles REST API endpoints for buildings.
*
* @package Magdev\WpBnb\Api\Controllers
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api\Controllers;
use Magdev\WpBnb\PostTypes\Building;
use Magdev\WpBnb\PostTypes\Room;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use WP_Error;
/**
* Buildings Controller class.
*/
final class BuildingsController extends AbstractController {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'buildings';
/**
* Register routes.
*
* @return void
*/
public function register_routes(): void {
// GET /buildings - List all buildings.
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => $this->get_collection_params(),
),
)
);
// GET /buildings/{id} - Get single building.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Building ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
),
),
)
);
// GET /buildings/{id}/rooms - Get rooms in building.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)/rooms',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_building_rooms' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Building ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
'status' => array(
'description' => __( 'Filter by room status.', 'wp-bnb' ),
'type' => 'string',
'enum' => array( 'available', 'occupied', 'maintenance', 'blocked' ),
'sanitize_callback' => 'sanitize_text_field',
),
),
),
)
);
}
/**
* Get collection of buildings.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_items( $request ) {
// Check rate limit.
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$pagination = $this->get_pagination_params( $request );
$sorting = $this->get_sorting_params( $request, array( 'title', 'date' ), 'title' );
$args = array(
'post_type' => Building::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => $pagination['per_page'],
'offset' => $pagination['offset'],
'orderby' => $sorting['orderby'],
'order' => $sorting['order'],
);
// Search filter.
$search = $request->get_param( 'search' );
if ( $search ) {
$args['s'] = $search;
}
$query = new \WP_Query( $args );
$items = array();
foreach ( $query->posts as $post ) {
$items[] = $this->prepare_building_response( $post );
}
$response = $this->formatter->collection(
$items,
$query->found_posts,
$pagination['page'],
$pagination['per_page']
);
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Get single building.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_item( $request ) {
// Check rate limit.
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$id = $request->get_param( 'id' );
$post = get_post( $id );
if ( ! $post || Building::POST_TYPE !== $post->post_type || 'publish' !== $post->post_status ) {
return $this->formatter->not_found( __( 'Building', 'wp-bnb' ) );
}
$data = $this->prepare_building_response( $post, true );
$response = $this->formatter->success( $data );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Get rooms in a building.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_building_rooms( $request ) {
// Check rate limit.
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$building_id = $request->get_param( 'id' );
$building = get_post( $building_id );
if ( ! $building || Building::POST_TYPE !== $building->post_type || 'publish' !== $building->post_status ) {
return $this->formatter->not_found( __( 'Building', 'wp-bnb' ) );
}
$args = array(
'post_type' => Room::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'meta_query' => array(
array(
'key' => '_bnb_room_building_id',
'value' => $building_id,
),
),
'orderby' => 'meta_value',
'meta_key' => '_bnb_room_room_number',
'order' => 'ASC',
);
// Filter by status.
$status = $request->get_param( 'status' );
if ( $status ) {
$args['meta_query'][] = array(
'key' => '_bnb_room_status',
'value' => $status,
);
}
$rooms = get_posts( $args );
$items = array();
foreach ( $rooms as $room ) {
$items[] = $this->prepare_room_summary( $room );
}
$response = $this->formatter->success( $items );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Prepare building data for response.
*
* @param \WP_Post $post Building post object.
* @param bool $full Include full details.
* @return array Building data.
*/
private function prepare_building_response( \WP_Post $post, bool $full = false ): array {
$data = $this->format_post_base( $post );
// Featured image.
$data['featured_image'] = $this->format_featured_image( $post->ID );
$data['permalink'] = get_permalink( $post->ID );
// Address.
$data['address'] = array(
'street' => get_post_meta( $post->ID, '_bnb_building_street', true ),
'street2' => get_post_meta( $post->ID, '_bnb_building_street2', true ),
'city' => get_post_meta( $post->ID, '_bnb_building_city', true ),
'state' => get_post_meta( $post->ID, '_bnb_building_state', true ),
'postal_code' => get_post_meta( $post->ID, '_bnb_building_zip', true ),
'country' => get_post_meta( $post->ID, '_bnb_building_country', true ),
);
// Contact.
$data['contact'] = array(
'phone' => get_post_meta( $post->ID, '_bnb_building_phone', true ),
'email' => get_post_meta( $post->ID, '_bnb_building_email', true ),
'website' => get_post_meta( $post->ID, '_bnb_building_website', true ),
);
// Details.
$data['details'] = array(
'rooms_count' => (int) get_post_meta( $post->ID, '_bnb_building_total_rooms', true ),
'floors' => (int) get_post_meta( $post->ID, '_bnb_building_floors', true ),
'year_built' => (int) get_post_meta( $post->ID, '_bnb_building_year_built', true ),
'check_in_time' => get_post_meta( $post->ID, '_bnb_building_check_in_time', true ) ?: '14:00',
'check_out_time' => get_post_meta( $post->ID, '_bnb_building_check_out_time', true ) ?: '11:00',
);
// Count actual rooms.
$actual_rooms = Room::get_rooms_for_building( $post->ID );
$data['details']['actual_rooms_count'] = count( $actual_rooms );
// Full address formatted.
if ( $full ) {
$data['address']['formatted'] = Building::get_formatted_address( $post->ID );
// Country name.
$countries = Building::get_countries();
$country_code = $data['address']['country'];
$data['address']['country_name'] = $countries[ $country_code ] ?? $country_code;
}
// Add HATEOAS links.
$data['_links'] = array(
'self' => array(
array( 'href' => rest_url( $this->namespace . '/buildings/' . $post->ID ) ),
),
'rooms' => array(
array( 'href' => rest_url( $this->namespace . '/buildings/' . $post->ID . '/rooms' ) ),
),
);
return $data;
}
/**
* Prepare room summary for building rooms list.
*
* @param \WP_Post $room Room post object.
* @return array Room summary data.
*/
private function prepare_room_summary( \WP_Post $room ): array {
return array(
'id' => $room->ID,
'title' => get_the_title( $room ),
'slug' => $room->post_name,
'permalink' => get_permalink( $room->ID ),
'room_number' => get_post_meta( $room->ID, '_bnb_room_room_number', true ),
'floor' => (int) get_post_meta( $room->ID, '_bnb_room_floor', true ),
'capacity' => (int) get_post_meta( $room->ID, '_bnb_room_capacity', true ),
'status' => get_post_meta( $room->ID, '_bnb_room_status', true ) ?: 'available',
'thumbnail' => get_the_post_thumbnail_url( $room->ID, 'thumbnail' ) ?: null,
'_links' => array(
'self' => array(
array( 'href' => rest_url( $this->namespace . '/rooms/' . $room->ID ) ),
),
),
);
}
}

View File

@@ -0,0 +1,452 @@
<?php
/**
* Guests REST Controller
*
* Handles REST API endpoints for guests (admin only).
*
* @package Magdev\WpBnb\Api\Controllers
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api\Controllers;
use Magdev\WpBnb\PostTypes\Guest;
use Magdev\WpBnb\PostTypes\Booking;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use WP_Error;
/**
* Guests Controller class.
*/
final class GuestsController extends AbstractController {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'guests';
/**
* Register routes.
*
* @return void
*/
public function register_routes(): void {
// GET /guests - List guests (admin).
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'admin_permission' ),
'args' => $this->get_collection_params(),
),
)
);
// GET /guests/search - Search guests (admin).
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/search',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'search_guests' ),
'permission_callback' => array( $this, 'admin_permission' ),
'args' => array(
'q' => array(
'description' => __( 'Search query (name, email, phone).', 'wp-bnb' ),
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
'limit' => array(
'description' => __( 'Maximum results.', 'wp-bnb' ),
'type' => 'integer',
'default' => 10,
'minimum' => 1,
'maximum' => 50,
'sanitize_callback' => 'absint',
),
),
),
)
);
// GET /guests/{id} - Get single guest (admin).
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( $this, 'admin_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Guest ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
),
),
)
);
// GET /guests/{id}/bookings - Get guest's bookings (admin).
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)/bookings',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_guest_bookings' ),
'permission_callback' => array( $this, 'admin_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Guest ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
),
),
)
);
}
/**
* Get collection of guests.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_items( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$pagination = $this->get_pagination_params( $request );
$sorting = $this->get_sorting_params( $request, array( 'title', 'date' ), 'title' );
$args = array(
'post_type' => Guest::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => $pagination['per_page'],
'offset' => $pagination['offset'],
'orderby' => $sorting['orderby'],
'order' => $sorting['order'],
);
// Search filter.
$search = $request->get_param( 'search' );
if ( $search ) {
$args['s'] = $search;
}
// Status filter.
$status = $request->get_param( 'status' );
if ( $status ) {
$args['meta_query'] = array(
array(
'key' => '_bnb_guest_status',
'value' => $status,
),
);
}
$query = new \WP_Query( $args );
$items = array();
foreach ( $query->posts as $post ) {
$items[] = $this->prepare_guest_response( $post );
}
$response = $this->formatter->collection(
$items,
$query->found_posts,
$pagination['page'],
$pagination['per_page']
);
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Search guests by name, email, or phone.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function search_guests( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$query = $request->get_param( 'q' );
$limit = $request->get_param( 'limit' );
// Search by title (name) first.
$args = array(
'post_type' => Guest::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => $limit,
's' => $query,
);
$results = get_posts( $args );
// Also search by email and phone if we have room for more results.
if ( count( $results ) < $limit ) {
$existing_ids = wp_list_pluck( $results, 'ID' );
$remaining = $limit - count( $results );
// Search by email.
$email_args = array(
'post_type' => Guest::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => $remaining,
'post__not_in' => $existing_ids,
'meta_query' => array(
array(
'key' => '_bnb_guest_email',
'value' => $query,
'compare' => 'LIKE',
),
),
);
$email_results = get_posts( $email_args );
$results = array_merge( $results, $email_results );
// Search by phone if still room.
if ( count( $results ) < $limit ) {
$existing_ids = wp_list_pluck( $results, 'ID' );
$remaining = $limit - count( $results );
$phone_args = array(
'post_type' => Guest::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => $remaining,
'post__not_in' => $existing_ids,
'meta_query' => array(
array(
'key' => '_bnb_guest_phone',
'value' => $query,
'compare' => 'LIKE',
),
),
);
$phone_results = get_posts( $phone_args );
$results = array_merge( $results, $phone_results );
}
}
$items = array();
foreach ( $results as $post ) {
$items[] = $this->prepare_guest_summary( $post );
}
$response = $this->formatter->success( $items );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Get single guest.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_item( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$id = $request->get_param( 'id' );
$post = get_post( $id );
if ( ! $post || Guest::POST_TYPE !== $post->post_type ) {
return $this->formatter->not_found( __( 'Guest', 'wp-bnb' ) );
}
$data = $this->prepare_guest_response( $post, true );
$response = $this->formatter->success( $data );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Get guest's booking history.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_guest_bookings( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$guest_id = $request->get_param( 'id' );
$guest = get_post( $guest_id );
if ( ! $guest || Guest::POST_TYPE !== $guest->post_type ) {
return $this->formatter->not_found( __( 'Guest', 'wp-bnb' ) );
}
$bookings = Guest::get_bookings( $guest_id );
$items = array();
foreach ( $bookings as $booking ) {
$room_id = get_post_meta( $booking->ID, '_bnb_booking_room_id', true );
$room = $room_id ? get_post( $room_id ) : null;
$check_in = get_post_meta( $booking->ID, '_bnb_booking_check_in', true );
$check_out = get_post_meta( $booking->ID, '_bnb_booking_check_out', true );
$items[] = array(
'id' => $booking->ID,
'reference' => get_post_meta( $booking->ID, '_bnb_booking_reference', true ) ?: $booking->post_title,
'room' => $room ? array(
'id' => $room->ID,
'title' => get_the_title( $room ),
) : null,
'check_in' => $check_in,
'check_out' => $check_out,
'status' => get_post_meta( $booking->ID, '_bnb_booking_status', true ),
'total' => (float) get_post_meta( $booking->ID, '_bnb_booking_total_price', true ),
'created_at' => $booking->post_date_gmt,
'_links' => array(
'self' => array(
array( 'href' => rest_url( $this->namespace . '/bookings/' . $booking->ID ) ),
),
),
);
}
$response = $this->formatter->success( $items );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Prepare guest data for response.
*
* @param \WP_Post $post Guest post object.
* @param bool $full Include full details.
* @return array Guest data.
*/
private function prepare_guest_response( \WP_Post $post, bool $full = false ): array {
$data = array(
'id' => $post->ID,
'first_name' => get_post_meta( $post->ID, '_bnb_guest_first_name', true ),
'last_name' => get_post_meta( $post->ID, '_bnb_guest_last_name', true ),
'email' => get_post_meta( $post->ID, '_bnb_guest_email', true ),
'phone' => get_post_meta( $post->ID, '_bnb_guest_phone', true ),
'status' => get_post_meta( $post->ID, '_bnb_guest_status', true ) ?: 'active',
'created_at' => $post->post_date_gmt,
);
// Address.
$data['address'] = array(
'street' => get_post_meta( $post->ID, '_bnb_guest_street', true ),
'city' => get_post_meta( $post->ID, '_bnb_guest_city', true ),
'postal_code' => get_post_meta( $post->ID, '_bnb_guest_postal_code', true ),
'country' => get_post_meta( $post->ID, '_bnb_guest_country', true ),
);
// Statistics.
$booking_count = Guest::get_booking_count( $post->ID );
$total_spent = Guest::get_total_spent( $post->ID );
$data['statistics'] = array(
'total_bookings' => $booking_count,
'total_spent' => $total_spent,
);
if ( $full ) {
$data['nationality'] = get_post_meta( $post->ID, '_bnb_guest_nationality', true );
$data['date_of_birth'] = get_post_meta( $post->ID, '_bnb_guest_date_of_birth', true );
$data['notes'] = get_post_meta( $post->ID, '_bnb_guest_notes', true );
$data['preferences'] = get_post_meta( $post->ID, '_bnb_guest_preferences', true );
// Get last stay date.
$bookings = Guest::get_bookings( $post->ID );
if ( ! empty( $bookings ) ) {
$last_booking = $bookings[0];
$data['statistics']['last_stay'] = get_post_meta( $last_booking->ID, '_bnb_booking_check_out', true );
}
// Formatted address.
$data['address']['formatted'] = Guest::get_formatted_address( $post->ID );
// GDPR consent info.
$data['consent'] = array(
'data_processing' => (bool) get_post_meta( $post->ID, '_bnb_guest_consent_data', true ),
'marketing' => (bool) get_post_meta( $post->ID, '_bnb_guest_consent_marketing', true ),
'date' => get_post_meta( $post->ID, '_bnb_guest_consent_date', true ),
);
}
// Note: ID/passport numbers are NOT exposed via API for security.
$data['_links'] = array(
'self' => array(
array( 'href' => rest_url( $this->namespace . '/guests/' . $post->ID ) ),
),
'bookings' => array(
array( 'href' => rest_url( $this->namespace . '/guests/' . $post->ID . '/bookings' ) ),
),
);
return $data;
}
/**
* Prepare guest summary for search results.
*
* @param \WP_Post $post Guest post object.
* @return array Guest summary.
*/
private function prepare_guest_summary( \WP_Post $post ): array {
return array(
'id' => $post->ID,
'first_name' => get_post_meta( $post->ID, '_bnb_guest_first_name', true ),
'last_name' => get_post_meta( $post->ID, '_bnb_guest_last_name', true ),
'email' => get_post_meta( $post->ID, '_bnb_guest_email', true ),
'phone' => get_post_meta( $post->ID, '_bnb_guest_phone', true ),
'_links' => array(
'self' => array(
array( 'href' => rest_url( $this->namespace . '/guests/' . $post->ID ) ),
),
),
);
}
/**
* Get collection parameters with status filter.
*
* @return array Collection parameters.
*/
public function get_collection_params(): array {
$params = parent::get_collection_params();
$params['status'] = array(
'description' => __( 'Filter by guest status.', 'wp-bnb' ),
'type' => 'string',
'enum' => array( 'active', 'inactive', 'blocked' ),
'sanitize_callback' => 'sanitize_text_field',
);
return $params;
}
}

View File

@@ -0,0 +1,278 @@
<?php
/**
* Pricing REST Controller
*
* Handles REST API endpoints for price calculations.
*
* @package Magdev\WpBnb\Api\Controllers
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api\Controllers;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\PostTypes\Service;
use Magdev\WpBnb\Pricing\Calculator;
use Magdev\WpBnb\Pricing\Season;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use WP_Error;
/**
* Pricing Controller class.
*/
final class PricingController extends AbstractController {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'pricing';
/**
* Register routes.
*
* @return void
*/
public function register_routes(): void {
// POST /pricing/calculate - Calculate full booking price.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/calculate',
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'calculate_price' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => array(
'room_id' => array(
'description' => __( 'Room ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
'check_in' => array(
'description' => __( 'Check-in date (Y-m-d).', 'wp-bnb' ),
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
'check_out' => array(
'description' => __( 'Check-out date (Y-m-d).', 'wp-bnb' ),
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
'services' => array(
'description' => __( 'Additional services.', 'wp-bnb' ),
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'service_id' => array( 'type' => 'integer', 'required' => true ),
'quantity' => array( 'type' => 'integer', 'default' => 1 ),
),
),
),
),
),
)
);
// GET /pricing/seasons - Get active seasons.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/seasons',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_seasons' ),
'permission_callback' => array( $this, 'public_permission' ),
),
)
);
}
/**
* Calculate full booking price.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function calculate_price( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$room_id = $request->get_param( 'room_id' );
$check_in = $request->get_param( 'check_in' );
$check_out = $request->get_param( 'check_out' );
$services = $request->get_param( 'services' ) ?? array();
// Validate room.
$room = get_post( $room_id );
if ( ! $room || Room::POST_TYPE !== $room->post_type || 'publish' !== $room->post_status ) {
return $this->formatter->not_found( __( 'Room', 'wp-bnb' ) );
}
// Validate dates.
if ( ! $this->validate_date( $check_in ) ) {
return $this->formatter->validation_error( 'check_in', __( 'Invalid check-in date format. Use Y-m-d.', 'wp-bnb' ) );
}
if ( ! $this->validate_date( $check_out ) ) {
return $this->formatter->validation_error( 'check_out', __( 'Invalid check-out date format. Use Y-m-d.', 'wp-bnb' ) );
}
if ( $check_in >= $check_out ) {
return $this->formatter->validation_error( 'check_out', __( 'Check-out must be after check-in.', 'wp-bnb' ) );
}
// Calculate nights.
$check_in_date = new \DateTimeImmutable( $check_in );
$check_out_date = new \DateTimeImmutable( $check_out );
$nights = (int) $check_in_date->diff( $check_out_date )->days;
// Calculate room price.
$price = Calculator::calculate( $room_id, $check_in, $check_out );
$room_total = $price['price'] ?? 0;
$breakdown = $price['breakdown'] ?? array();
$currency = get_option( 'wp_bnb_currency', 'CHF' );
// Build night-by-night breakdown.
$night_breakdown = array();
$current_date = $check_in_date;
$base_rate = $breakdown['base_price_per_night'] ?? 0;
while ( $current_date < $check_out_date ) {
$date_str = $current_date->format( 'Y-m-d' );
$day_of_week = (int) $current_date->format( 'w' );
$modifiers = array();
$rate = $base_rate;
// Check for weekend surcharge.
$weekend_days = explode( ',', get_option( 'wp_bnb_weekend_days', '5,6' ) );
if ( in_array( (string) $day_of_week, $weekend_days, true ) ) {
$weekend_surcharge = $breakdown['weekend_surcharge'] ?? 0;
if ( $weekend_surcharge > 0 ) {
$rate += $weekend_surcharge / max( 1, $breakdown['weekend_nights'] ?? 1 );
$modifiers[] = 'weekend_surcharge';
}
}
// Check for seasonal modifier.
$season = Season::get_active_season( $date_str );
if ( $season && $season['modifier'] != 1.0 ) {
$modifiers[] = 'season:' . $season['name'];
}
$night_breakdown[] = array(
'date' => $date_str,
'rate' => round( $rate, 2 ),
'modifiers' => $modifiers,
);
$current_date = $current_date->modify( '+1 day' );
}
// Calculate services.
$services_items = array();
$services_total = 0;
foreach ( $services as $service_item ) {
$service_id = $service_item['service_id'] ?? 0;
$quantity = $service_item['quantity'] ?? 1;
$service_data = Service::get_service_data( $service_id );
if ( $service_data && 'active' === $service_data['status'] ) {
$service_price = Service::calculate_service_price( $service_id, $quantity, $nights );
$services_total += $service_price;
$services_items[] = array(
'id' => $service_id,
'name' => $service_data['title'],
'quantity' => $quantity,
'nights' => 'per_night' === $service_data['pricing_type'] ? $nights : null,
'subtotal' => $service_price,
);
}
}
$grand_total = $room_total + $services_total;
// Build response.
$data = array(
'room' => array(
'id' => $room->ID,
'title' => get_the_title( $room ),
'pricing_tier' => $breakdown['tier']->value ?? 'short_term',
),
'dates' => array(
'check_in' => $check_in,
'check_out' => $check_out,
'nights' => $nights,
),
'room_pricing' => array(
'base_rate' => $breakdown['base_price_per_night'] ?? 0,
'subtotal' => $room_total,
'breakdown' => $night_breakdown,
),
'services_pricing' => array(
'items' => $services_items,
'subtotal' => $services_total,
),
'totals' => array(
'room' => $room_total,
'services' => $services_total,
'grand_total' => $grand_total,
'currency' => $currency,
'formatted' => Calculator::formatPrice( $grand_total ),
),
);
$response = $this->formatter->success( $data );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Get active seasons.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_seasons( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$seasons = Season::get_all();
$items = array();
foreach ( $seasons as $season ) {
$items[] = array(
'id' => $season['id'],
'name' => $season['name'],
'start_date' => $season['start_date'],
'end_date' => $season['end_date'],
'modifier' => (float) $season['modifier'],
'priority' => (int) $season['priority'],
);
}
// Sort by priority (highest first).
usort(
$items,
function ( $a, $b ) {
return $b['priority'] - $a['priority'];
}
);
$response = $this->formatter->success( $items );
return $this->add_rate_limit_headers( $response, $request );
}
}

View File

@@ -0,0 +1,768 @@
<?php
/**
* Rooms REST Controller
*
* Handles REST API endpoints for rooms, availability, and calendar.
*
* @package Magdev\WpBnb\Api\Controllers
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api\Controllers;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\PostTypes\Building;
use Magdev\WpBnb\PostTypes\Booking;
use Magdev\WpBnb\Booking\Availability;
use Magdev\WpBnb\Pricing\Calculator;
use Magdev\WpBnb\Pricing\PricingTier;
use Magdev\WpBnb\Taxonomies\RoomType;
use Magdev\WpBnb\Taxonomies\Amenity;
use Magdev\WpBnb\Frontend\Search;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use WP_Error;
/**
* Rooms Controller class.
*/
final class RoomsController extends AbstractController {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'rooms';
/**
* Register routes.
*
* @return void
*/
public function register_routes(): void {
// GET /rooms - List all rooms.
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => $this->get_rooms_collection_params(),
),
)
);
// GET /rooms/{id} - Get single room.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Room ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
),
),
)
);
// GET /rooms/{id}/availability - Check room availability.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)/availability',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_availability' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Room ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
'check_in' => array(
'description' => __( 'Check-in date (Y-m-d).', 'wp-bnb' ),
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
'check_out' => array(
'description' => __( 'Check-out date (Y-m-d).', 'wp-bnb' ),
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
),
),
)
);
// GET /rooms/{id}/calendar - Get room calendar.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)/calendar',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_calendar' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Room ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
'year' => array(
'description' => __( 'Year.', 'wp-bnb' ),
'type' => 'integer',
'default' => (int) gmdate( 'Y' ),
'sanitize_callback' => 'absint',
),
'month' => array(
'description' => __( 'Month (1-12).', 'wp-bnb' ),
'type' => 'integer',
'default' => (int) gmdate( 'n' ),
'minimum' => 1,
'maximum' => 12,
'sanitize_callback' => 'absint',
),
),
),
)
);
// POST /availability/search - Search available rooms.
register_rest_route(
$this->namespace,
'/availability/search',
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'search_availability' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => array(
'check_in' => array(
'description' => __( 'Check-in date (Y-m-d).', 'wp-bnb' ),
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
'check_out' => array(
'description' => __( 'Check-out date (Y-m-d).', 'wp-bnb' ),
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
'guests' => array(
'description' => __( 'Number of guests.', 'wp-bnb' ),
'type' => 'integer',
'minimum' => 1,
'sanitize_callback' => 'absint',
),
'building_id' => array(
'description' => __( 'Building ID.', 'wp-bnb' ),
'type' => 'integer',
'sanitize_callback' => 'absint',
),
'room_type' => array(
'description' => __( 'Room type term ID or slug.', 'wp-bnb' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
'amenities' => array(
'description' => __( 'Comma-separated amenity slugs.', 'wp-bnb' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
'price_min' => array(
'description' => __( 'Minimum price per night.', 'wp-bnb' ),
'type' => 'number',
),
'price_max' => array(
'description' => __( 'Maximum price per night.', 'wp-bnb' ),
'type' => 'number',
),
),
),
)
);
}
/**
* Get collection of rooms.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_items( $request ) {
// Check rate limit.
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$pagination = $this->get_pagination_params( $request );
$sorting = $this->get_sorting_params( $request, array( 'title', 'date', 'capacity', 'price' ), 'title' );
$args = array(
'post_type' => Room::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => $pagination['per_page'],
'offset' => $pagination['offset'],
);
// Handle special orderby values.
switch ( $sorting['orderby'] ) {
case 'capacity':
$args['meta_key'] = '_bnb_room_capacity';
$args['orderby'] = 'meta_value_num';
break;
case 'price':
$args['meta_key'] = '_bnb_room_price_short_term';
$args['orderby'] = 'meta_value_num';
break;
default:
$args['orderby'] = $sorting['orderby'];
}
$args['order'] = $sorting['order'];
// Search filter.
$search = $request->get_param( 'search' );
if ( $search ) {
$args['s'] = $search;
}
// Building filter.
$building = $request->get_param( 'building' );
if ( $building ) {
$args['meta_query'][] = array(
'key' => '_bnb_room_building_id',
'value' => $building,
);
}
// Status filter.
$status = $request->get_param( 'status' );
if ( $status ) {
$args['meta_query'][] = array(
'key' => '_bnb_room_status',
'value' => $status,
);
}
// Capacity filter.
$capacity_min = $request->get_param( 'capacity_min' );
if ( $capacity_min ) {
$args['meta_query'][] = array(
'key' => '_bnb_room_capacity',
'value' => $capacity_min,
'compare' => '>=',
'type' => 'NUMERIC',
);
}
// Room type filter.
$room_type = $request->get_param( 'room_type' );
if ( $room_type ) {
$args['tax_query'][] = array(
'taxonomy' => RoomType::TAXONOMY,
'field' => is_numeric( $room_type ) ? 'term_id' : 'slug',
'terms' => $room_type,
);
}
// Amenities filter.
$amenities = $request->get_param( 'amenities' );
if ( $amenities ) {
$amenity_slugs = array_map( 'trim', explode( ',', $amenities ) );
$args['tax_query'][] = array(
'taxonomy' => Amenity::TAXONOMY,
'field' => 'slug',
'terms' => $amenity_slugs,
'operator' => 'AND',
);
}
if ( isset( $args['meta_query'] ) && count( $args['meta_query'] ) > 1 ) {
$args['meta_query']['relation'] = 'AND';
}
if ( isset( $args['tax_query'] ) && count( $args['tax_query'] ) > 1 ) {
$args['tax_query']['relation'] = 'AND';
}
$query = new \WP_Query( $args );
$items = array();
foreach ( $query->posts as $post ) {
$items[] = $this->prepare_room_response( $post );
}
$response = $this->formatter->collection(
$items,
$query->found_posts,
$pagination['page'],
$pagination['per_page']
);
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Get single room.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_item( $request ) {
// Check rate limit.
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$id = $request->get_param( 'id' );
$post = get_post( $id );
if ( ! $post || Room::POST_TYPE !== $post->post_type || 'publish' !== $post->post_status ) {
return $this->formatter->not_found( __( 'Room', 'wp-bnb' ) );
}
$data = $this->prepare_room_response( $post, true );
$response = $this->formatter->success( $data );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Get room availability.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_availability( $request ) {
// Check rate limit.
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$room_id = $request->get_param( 'id' );
$check_in = $request->get_param( 'check_in' );
$check_out = $request->get_param( 'check_out' );
// Validate room exists.
$room = get_post( $room_id );
if ( ! $room || Room::POST_TYPE !== $room->post_type || 'publish' !== $room->post_status ) {
return $this->formatter->not_found( __( 'Room', 'wp-bnb' ) );
}
// Validate dates.
if ( ! $this->validate_date( $check_in ) ) {
return $this->formatter->validation_error( 'check_in', __( 'Invalid check-in date format. Use Y-m-d.', 'wp-bnb' ) );
}
if ( ! $this->validate_date( $check_out ) ) {
return $this->formatter->validation_error( 'check_out', __( 'Invalid check-out date format. Use Y-m-d.', 'wp-bnb' ) );
}
if ( $check_in >= $check_out ) {
return $this->formatter->validation_error( 'check_out', __( 'Check-out must be after check-in.', 'wp-bnb' ) );
}
// Check availability.
$is_available = Availability::is_available( $room_id, $check_in, $check_out );
// Calculate nights.
$check_in_date = new \DateTimeImmutable( $check_in );
$check_out_date = new \DateTimeImmutable( $check_out );
$nights = (int) $check_in_date->diff( $check_out_date )->days;
$data = array(
'available' => $is_available,
'room_id' => $room_id,
'check_in' => $check_in,
'check_out' => $check_out,
'nights' => $nights,
);
if ( $is_available ) {
// Calculate pricing.
$price = Calculator::calculate( $room_id, $check_in, $check_out );
$data['pricing'] = array(
'tier' => $price['breakdown']['tier']->value ?? 'short_term',
'base_rate' => $price['breakdown']['base_price_per_night'] ?? 0,
'total' => $price['price'] ?? 0,
'formatted' => $price['price_formatted'] ?? '',
'currency' => get_option( 'wp_bnb_currency', 'CHF' ),
'breakdown' => $price['breakdown'] ?? array(),
);
} else {
// Get conflicts.
$conflicts = $this->get_conflicts( $room_id, $check_in, $check_out );
$data['conflicts'] = $conflicts;
}
$response = $this->formatter->success( $data );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Get room calendar.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_calendar( $request ) {
// Check rate limit.
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$room_id = $request->get_param( 'id' );
$year = $request->get_param( 'year' );
$month = $request->get_param( 'month' );
// Validate room exists.
$room = get_post( $room_id );
if ( ! $room || Room::POST_TYPE !== $room->post_type || 'publish' !== $room->post_status ) {
return $this->formatter->not_found( __( 'Room', 'wp-bnb' ) );
}
// Validate month.
if ( $month < 1 || $month > 12 ) {
return $this->formatter->validation_error( 'month', __( 'Month must be between 1 and 12.', 'wp-bnb' ) );
}
$data = Availability::get_calendar_data( $room_id, $year, $month );
$response = $this->formatter->success( $data );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Search available rooms.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function search_availability( $request ) {
// Check rate limit.
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$check_in = $request->get_param( 'check_in' );
$check_out = $request->get_param( 'check_out' );
// Validate dates.
if ( ! $this->validate_date( $check_in ) ) {
return $this->formatter->validation_error( 'check_in', __( 'Invalid check-in date format. Use Y-m-d.', 'wp-bnb' ) );
}
if ( ! $this->validate_date( $check_out ) ) {
return $this->formatter->validation_error( 'check_out', __( 'Invalid check-out date format. Use Y-m-d.', 'wp-bnb' ) );
}
if ( $check_in >= $check_out ) {
return $this->formatter->validation_error( 'check_out', __( 'Check-out must be after check-in.', 'wp-bnb' ) );
}
// Build search args.
$search_args = array(
'check_in' => $check_in,
'check_out' => $check_out,
);
$guests = $request->get_param( 'guests' );
if ( $guests ) {
$search_args['guests'] = $guests;
}
$building_id = $request->get_param( 'building_id' );
if ( $building_id ) {
$search_args['building_id'] = $building_id;
}
$room_type = $request->get_param( 'room_type' );
if ( $room_type ) {
$search_args['room_type'] = $room_type;
}
$amenities = $request->get_param( 'amenities' );
if ( $amenities ) {
$search_args['amenities'] = array_map( 'trim', explode( ',', $amenities ) );
}
$price_min = $request->get_param( 'price_min' );
if ( $price_min ) {
$search_args['price_min'] = $price_min;
}
$price_max = $request->get_param( 'price_max' );
if ( $price_max ) {
$search_args['price_max'] = $price_max;
}
// Use existing Search class.
$results = Search::search( $search_args );
// Format response.
$items = array();
foreach ( $results['rooms'] as $room_data ) {
$items[] = array(
'room' => $room_data,
'availability' => array(
'available' => true,
'nights' => $room_data['nights'] ?? 0,
'total_price' => $room_data['stay_price'] ?? 0,
'formatted_price' => $room_data['stay_price_formatted'] ?? '',
),
);
}
$data = array(
'results' => $items,
'total' => $results['count'],
'filters_applied' => $search_args,
);
$response = $this->formatter->success( $data );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Get conflicting bookings for a date range.
*
* @param int $room_id Room ID.
* @param string $check_in Check-in date.
* @param string $check_out Check-out date.
* @return array Conflicting bookings.
*/
private function get_conflicts( int $room_id, string $check_in, string $check_out ): array {
$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' => '!=',
),
array(
'key' => '_bnb_booking_check_in',
'value' => $check_out,
'compare' => '<',
'type' => 'DATE',
),
array(
'key' => '_bnb_booking_check_out',
'value' => $check_in,
'compare' => '>',
'type' => 'DATE',
),
),
)
);
$conflicts = array();
foreach ( $bookings as $booking ) {
$conflicts[] = array(
'booking_id' => $booking->ID,
'reference' => $booking->post_title,
'check_in' => get_post_meta( $booking->ID, '_bnb_booking_check_in', true ),
'check_out' => get_post_meta( $booking->ID, '_bnb_booking_check_out', true ),
);
}
return $conflicts;
}
/**
* Prepare room data for response.
*
* @param \WP_Post $post Room post object.
* @param bool $full Include full details.
* @return array Room data.
*/
private function prepare_room_response( \WP_Post $post, bool $full = false ): array {
$data = $this->format_post_base( $post );
$data['permalink'] = get_permalink( $post->ID );
$data['featured_image'] = $this->format_featured_image( $post->ID );
// Gallery.
$gallery_ids = get_post_meta( $post->ID, '_bnb_room_gallery', true );
$gallery = array();
if ( $gallery_ids ) {
foreach ( explode( ',', $gallery_ids ) as $image_id ) {
$image = $this->format_image( (int) $image_id );
if ( $image ) {
$gallery[] = $image;
}
}
}
$data['gallery'] = $gallery;
// Building reference.
$building_id = get_post_meta( $post->ID, '_bnb_room_building_id', true );
$building = $building_id ? get_post( $building_id ) : null;
$data['building'] = $building ? array(
'id' => $building->ID,
'title' => get_the_title( $building ),
'slug' => $building->post_name,
'permalink' => get_permalink( $building->ID ),
'city' => get_post_meta( $building->ID, '_bnb_building_city', true ),
) : null;
// Room details.
$data['room_number'] = get_post_meta( $post->ID, '_bnb_room_room_number', true );
$data['floor'] = (int) get_post_meta( $post->ID, '_bnb_room_floor', true );
$data['size_sqm'] = (float) get_post_meta( $post->ID, '_bnb_room_size', true );
// Capacity.
$data['capacity'] = array(
'max_guests' => (int) get_post_meta( $post->ID, '_bnb_room_capacity', true ),
'adults' => (int) get_post_meta( $post->ID, '_bnb_room_max_adults', true ),
'children' => (int) get_post_meta( $post->ID, '_bnb_room_max_children', true ),
);
$data['beds'] = get_post_meta( $post->ID, '_bnb_room_beds', true );
$data['bathrooms'] = (float) get_post_meta( $post->ID, '_bnb_room_bathrooms', true );
$data['status'] = get_post_meta( $post->ID, '_bnb_room_status', true ) ?: 'available';
// Room types.
$room_types = wp_get_post_terms( $post->ID, RoomType::TAXONOMY );
$data['room_types'] = array_map(
function ( $term ) {
return array(
'id' => $term->term_id,
'name' => $term->name,
'slug' => $term->slug,
);
},
is_array( $room_types ) ? $room_types : array()
);
// Amenities.
$amenities = wp_get_post_terms( $post->ID, Amenity::TAXONOMY );
$data['amenities'] = array_map(
function ( $term ) {
return array(
'id' => $term->term_id,
'name' => $term->name,
'slug' => $term->slug,
'icon' => get_term_meta( $term->term_id, '_bnb_amenity_icon', true ),
);
},
is_array( $amenities ) ? $amenities : array()
);
// Pricing.
$pricing = Calculator::getRoomPricing( $post->ID );
$currency = get_option( 'wp_bnb_currency', 'CHF' );
$data['pricing'] = array(
'currency' => $currency,
);
foreach ( PricingTier::cases() as $tier ) {
$price = $pricing[ $tier->value ]['price'] ?? null;
$data['pricing'][ $tier->value ] = array(
'price' => $price,
'formatted' => $price ? Calculator::formatPrice( $price ) : null,
'unit' => $tier->unit(),
);
}
$weekend_surcharge = $pricing['weekend_surcharge']['price'] ?? null;
$data['pricing']['weekend_surcharge'] = array(
'price' => $weekend_surcharge,
'formatted' => $weekend_surcharge ? Calculator::formatPrice( $weekend_surcharge ) : null,
);
// Add HATEOAS links.
$data['_links'] = array(
'self' => array(
array( 'href' => rest_url( $this->namespace . '/rooms/' . $post->ID ) ),
),
'building' => $building ? array(
array( 'href' => rest_url( $this->namespace . '/buildings/' . $building->ID ) ),
) : array(),
'availability' => array(
array( 'href' => rest_url( $this->namespace . '/rooms/' . $post->ID . '/availability' ) ),
),
'calendar' => array(
array( 'href' => rest_url( $this->namespace . '/rooms/' . $post->ID . '/calendar' ) ),
),
);
return $data;
}
/**
* Get rooms collection parameters.
*
* @return array Collection parameters.
*/
private function get_rooms_collection_params(): array {
$params = $this->get_collection_params();
$params['building'] = array(
'description' => __( 'Filter by building ID.', 'wp-bnb' ),
'type' => 'integer',
'sanitize_callback' => 'absint',
);
$params['room_type'] = array(
'description' => __( 'Filter by room type (term ID or slug).', 'wp-bnb' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
);
$params['amenities'] = array(
'description' => __( 'Filter by amenities (comma-separated slugs).', 'wp-bnb' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
);
$params['capacity_min'] = array(
'description' => __( 'Minimum guest capacity.', 'wp-bnb' ),
'type' => 'integer',
'minimum' => 1,
'sanitize_callback' => 'absint',
);
$params['status'] = array(
'description' => __( 'Filter by room status.', 'wp-bnb' ),
'type' => 'string',
'enum' => array( 'available', 'occupied', 'maintenance', 'blocked' ),
'sanitize_callback' => 'sanitize_text_field',
);
$params['orderby']['enum'] = array( 'title', 'date', 'capacity', 'price' );
return $params;
}
}

View File

@@ -0,0 +1,375 @@
<?php
/**
* Services REST Controller
*
* Handles REST API endpoints for services.
*
* @package Magdev\WpBnb\Api\Controllers
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api\Controllers;
use Magdev\WpBnb\PostTypes\Service;
use Magdev\WpBnb\Taxonomies\ServiceCategory;
use Magdev\WpBnb\Pricing\Calculator;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use WP_Error;
/**
* Services Controller class.
*/
final class ServicesController extends AbstractController {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'services';
/**
* Register routes.
*
* @return void
*/
public function register_routes(): void {
// GET /services - List active services (public).
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => $this->get_services_collection_params(),
),
)
);
// GET /services/{id} - Get single service (public).
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Service ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
),
),
)
);
// POST /services/{id}/calculate - Calculate service price.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)/calculate',
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'calculate_price' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Service ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
'quantity' => array(
'description' => __( 'Quantity.', 'wp-bnb' ),
'type' => 'integer',
'default' => 1,
'minimum' => 1,
'sanitize_callback' => 'absint',
),
'nights' => array(
'description' => __( 'Number of nights (for per-night services).', 'wp-bnb' ),
'type' => 'integer',
'default' => 1,
'minimum' => 1,
'sanitize_callback' => 'absint',
),
),
),
)
);
}
/**
* Get collection of services.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_items( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$args = array(
'post_type' => Service::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => 100, // Services typically don't need pagination.
'orderby' => 'meta_value_num',
'meta_key' => '_bnb_service_sort_order',
'order' => 'ASC',
);
$meta_query = array();
// Status filter (default: active only).
$status = $request->get_param( 'status' ) ?: 'active';
if ( 'all' !== $status ) {
$meta_query[] = array(
'key' => '_bnb_service_status',
'value' => $status,
);
}
// Pricing type filter.
$pricing_type = $request->get_param( 'pricing_type' );
if ( $pricing_type ) {
$meta_query[] = array(
'key' => '_bnb_service_pricing_type',
'value' => $pricing_type,
);
}
if ( ! empty( $meta_query ) ) {
$meta_query['relation'] = 'AND';
$args['meta_query'] = $meta_query;
}
// Category filter.
$category = $request->get_param( 'category' );
if ( $category ) {
$args['tax_query'] = array(
array(
'taxonomy' => 'bnb_service_category',
'field' => is_numeric( $category ) ? 'term_id' : 'slug',
'terms' => $category,
),
);
}
$services = get_posts( $args );
$items = array();
foreach ( $services as $service ) {
$items[] = $this->prepare_service_response( $service );
}
$response = $this->formatter->success( $items );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Get single service.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_item( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$id = $request->get_param( 'id' );
$post = get_post( $id );
if ( ! $post || Service::POST_TYPE !== $post->post_type || 'publish' !== $post->post_status ) {
return $this->formatter->not_found( __( 'Service', 'wp-bnb' ) );
}
$data = $this->prepare_service_response( $post, true );
$response = $this->formatter->success( $data );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Calculate service price.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function calculate_price( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$service_id = $request->get_param( 'id' );
$quantity = $request->get_param( 'quantity' );
$nights = $request->get_param( 'nights' );
// Validate service.
$service = get_post( $service_id );
if ( ! $service || Service::POST_TYPE !== $service->post_type || 'publish' !== $service->post_status ) {
return $this->formatter->not_found( __( 'Service', 'wp-bnb' ) );
}
// Check if service is active.
$status = get_post_meta( $service_id, '_bnb_service_status', true );
if ( 'active' !== $status ) {
return $this->formatter->validation_error( 'id', __( 'Service is not available.', 'wp-bnb' ) );
}
// Check max quantity.
$max_quantity = (int) get_post_meta( $service_id, '_bnb_service_max_quantity', true ) ?: 1;
if ( $quantity > $max_quantity ) {
return $this->formatter->validation_error(
'quantity',
sprintf(
/* translators: %d: maximum quantity */
__( 'Maximum quantity is %d.', 'wp-bnb' ),
$max_quantity
)
);
}
// Calculate price.
$total = Service::calculate_service_price( $service_id, $quantity, $nights );
$pricing_type = get_post_meta( $service_id, '_bnb_service_pricing_type', true );
$unit_price = (float) get_post_meta( $service_id, '_bnb_service_price', true );
$currency = get_option( 'wp_bnb_currency', 'CHF' );
// Build calculation string.
$calculation = '';
switch ( $pricing_type ) {
case 'included':
$calculation = __( 'Included', 'wp-bnb' );
break;
case 'per_booking':
$calculation = sprintf(
'%s x %d',
Calculator::formatPrice( $unit_price ),
$quantity
);
break;
case 'per_night':
$calculation = sprintf(
'%s x %d x %d %s',
Calculator::formatPrice( $unit_price ),
$quantity,
$nights,
_n( 'night', 'nights', $nights, 'wp-bnb' )
);
break;
}
$data = array(
'service_id' => $service_id,
'quantity' => $quantity,
'nights' => $nights,
'unit_price' => $unit_price,
'total' => $total,
'formatted' => Calculator::formatPrice( $total ),
'currency' => $currency,
'calculation' => $calculation,
);
$response = $this->formatter->success( $data );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Prepare service data for response.
*
* @param \WP_Post $post Service post object.
* @param bool $full Include full details.
* @return array Service data.
*/
private function prepare_service_response( \WP_Post $post, bool $full = false ): array {
$pricing_type = get_post_meta( $post->ID, '_bnb_service_pricing_type', true );
$price = (float) get_post_meta( $post->ID, '_bnb_service_price', true );
$status = get_post_meta( $post->ID, '_bnb_service_status', true ) ?: 'active';
$max_quantity = (int) get_post_meta( $post->ID, '_bnb_service_max_quantity', true ) ?: 1;
$currency = get_option( 'wp_bnb_currency', 'CHF' );
$data = array(
'id' => $post->ID,
'title' => get_the_title( $post ),
'slug' => $post->post_name,
'description' => get_the_excerpt( $post ),
'pricing' => array(
'type' => $pricing_type,
'price' => $price,
'formatted' => Service::format_service_price( Service::get_service_data( $post->ID ) ),
'currency' => $currency,
),
'max_quantity' => $max_quantity,
'status' => $status,
);
// Category.
$categories = wp_get_post_terms( $post->ID, 'bnb_service_category' );
if ( ! empty( $categories ) && ! is_wp_error( $categories ) ) {
$category = $categories[0];
$data['category'] = array(
'id' => $category->term_id,
'name' => $category->name,
'slug' => $category->slug,
'icon' => get_term_meta( $category->term_id, '_bnb_service_category_icon', true ),
);
}
if ( $full ) {
$data['content'] = apply_filters( 'the_content', $post->post_content );
$data['sort_order'] = (int) get_post_meta( $post->ID, '_bnb_service_sort_order', true );
}
$data['_links'] = array(
'self' => array(
array( 'href' => rest_url( $this->namespace . '/services/' . $post->ID ) ),
),
);
return $data;
}
/**
* Get services collection parameters.
*
* @return array Collection parameters.
*/
private function get_services_collection_params(): array {
return array(
'status' => array(
'description' => __( 'Filter by status (default: active).', 'wp-bnb' ),
'type' => 'string',
'enum' => array( 'active', 'inactive', 'all' ),
'default' => 'active',
'sanitize_callback' => 'sanitize_text_field',
),
'pricing_type' => array(
'description' => __( 'Filter by pricing type.', 'wp-bnb' ),
'type' => 'string',
'enum' => array( 'included', 'per_booking', 'per_night' ),
'sanitize_callback' => 'sanitize_text_field',
),
'category' => array(
'description' => __( 'Filter by category (term ID or slug).', 'wp-bnb' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
);
}
}

232
src/Api/RateLimiter.php Normal file
View File

@@ -0,0 +1,232 @@
<?php
/**
* REST API Rate Limiter
*
* Provides transient-based rate limiting for API endpoints.
*
* @package Magdev\WpBnb\Api
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api;
/**
* Rate Limiter class.
*/
final class RateLimiter {
/**
* Transient prefix for rate limit data.
*/
private const TRANSIENT_PREFIX = 'wp_bnb_rate_';
/**
* Default rate limits per minute by endpoint type.
*
* @var array<string, int>
*/
private const DEFAULT_LIMITS = array(
'public' => 60, // Public read endpoints.
'availability' => 30, // Availability checks.
'booking' => 10, // Booking creation.
'admin' => 120, // Admin endpoints.
);
/**
* Rate limits per minute by endpoint type.
*
* @var array<string, int>
*/
private array $limits;
/**
* Time window in seconds.
*
* @var int
*/
private int $window;
/**
* Constructor.
*/
public function __construct() {
$this->load_limits_from_options();
}
/**
* Load rate limits from WordPress options.
*
* @return void
*/
private function load_limits_from_options(): void {
$this->limits = array(
'public' => (int) get_option( 'wp_bnb_rate_limit_public', self::DEFAULT_LIMITS['public'] ),
'availability' => (int) get_option( 'wp_bnb_rate_limit_availability', self::DEFAULT_LIMITS['availability'] ),
'booking' => (int) get_option( 'wp_bnb_rate_limit_booking', self::DEFAULT_LIMITS['booking'] ),
'admin' => (int) get_option( 'wp_bnb_rate_limit_admin', self::DEFAULT_LIMITS['admin'] ),
);
$this->window = (int) get_option( 'wp_bnb_rate_limit_window', 60 );
}
/**
* Get default rate limits.
*
* @return array<string, int>
*/
public static function get_default_limits(): array {
return self::DEFAULT_LIMITS;
}
/**
* Check if request is within rate limit.
*
* @param string $identifier Client identifier (user ID or IP).
* @param string $endpoint Request endpoint.
* @return bool True if within limit, false if exceeded.
*/
public function check( string $identifier, string $endpoint ): bool {
$type = $this->get_endpoint_type( $endpoint );
$limit = $this->limits[ $type ] ?? $this->limits['public'];
$key = $this->get_transient_key( $identifier, $type );
$data = get_transient( $key );
if ( false === $data ) {
// First request in window.
set_transient(
$key,
array(
'count' => 1,
'start' => time(),
),
$this->window
);
return true;
}
// Check if window expired.
if ( time() - $data['start'] >= $this->window ) {
set_transient(
$key,
array(
'count' => 1,
'start' => time(),
),
$this->window
);
return true;
}
// Check if limit exceeded.
if ( $data['count'] >= $limit ) {
return false;
}
// Increment counter.
++$data['count'];
$remaining_window = $this->window - ( time() - $data['start'] );
set_transient( $key, $data, $remaining_window );
return true;
}
/**
* Get seconds until rate limit resets.
*
* @param string $identifier Client identifier.
* @param string $endpoint Request endpoint.
* @return int Seconds until reset.
*/
public function get_retry_after( string $identifier, string $endpoint ): int {
$type = $this->get_endpoint_type( $endpoint );
$key = $this->get_transient_key( $identifier, $type );
$data = get_transient( $key );
if ( false === $data ) {
return 0;
}
return max( 0, $this->window - ( time() - $data['start'] ) );
}
/**
* Get current rate limit info for headers.
*
* @param string $identifier Client identifier.
* @param string $endpoint Request endpoint.
* @return array{limit: int, remaining: int, reset: int}
*/
public function get_rate_limit_info( string $identifier, string $endpoint ): array {
$type = $this->get_endpoint_type( $endpoint );
$limit = $this->limits[ $type ] ?? $this->limits['public'];
$key = $this->get_transient_key( $identifier, $type );
$data = get_transient( $key );
if ( false === $data ) {
return array(
'limit' => $limit,
'remaining' => $limit,
'reset' => time() + $this->window,
);
}
$remaining = max( 0, $limit - $data['count'] );
$reset = $data['start'] + $this->window;
return array(
'limit' => $limit,
'remaining' => $remaining,
'reset' => $reset,
);
}
/**
* Determine endpoint type from route.
*
* @param string $endpoint Request endpoint.
* @return string Endpoint type.
*/
private function get_endpoint_type( string $endpoint ): string {
if ( str_contains( $endpoint, '/availability' ) || str_contains( $endpoint, '/calendar' ) ) {
return 'availability';
}
if ( str_contains( $endpoint, '/bookings' ) ) {
return 'booking';
}
if ( str_contains( $endpoint, '/guests' ) ) {
return 'admin';
}
return 'public';
}
/**
* Get transient key for rate limit data.
*
* @param string $identifier Client identifier.
* @param string $type Endpoint type.
* @return string Transient key.
*/
private function get_transient_key( string $identifier, string $type ): string {
return self::TRANSIENT_PREFIX . md5( $identifier . '_' . $type );
}
/**
* Set custom rate limits.
*
* @param array<string, int> $limits Rate limits by type.
* @return void
*/
public function set_limits( array $limits ): void {
$this->limits = array_merge( $this->limits, $limits );
}
/**
* Set custom time window.
*
* @param int $window Window in seconds.
* @return void
*/
public function set_window( int $window ): void {
$this->window = $window;
}
}

View File

@@ -0,0 +1,171 @@
<?php
/**
* REST API Response Formatter
*
* Provides standardized response formatting for all API endpoints.
*
* @package Magdev\WpBnb\Api
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api;
use WP_REST_Response;
use WP_Error;
/**
* Response Formatter class.
*/
final class ResponseFormatter {
/**
* Format successful response.
*
* @param mixed $data Response data.
* @param int $status HTTP status code.
* @return WP_REST_Response
*/
public function success( mixed $data, int $status = 200 ): WP_REST_Response {
return new WP_REST_Response( $data, $status );
}
/**
* Format collection response with pagination headers.
*
* @param array $items Collection items.
* @param int $total Total number of items.
* @param int $page Current page number.
* @param int $per_page Items per page.
* @return WP_REST_Response
*/
public function collection( array $items, int $total, int $page, int $per_page ): WP_REST_Response {
$response = new WP_REST_Response( $items, 200 );
$max_pages = (int) ceil( $total / $per_page );
$response->header( 'X-WP-Total', (string) $total );
$response->header( 'X-WP-TotalPages', (string) $max_pages );
return $response;
}
/**
* Format created response (201).
*
* @param mixed $data Response data.
* @param string $location Location header URL.
* @return WP_REST_Response
*/
public function created( mixed $data, string $location = '' ): WP_REST_Response {
$response = new WP_REST_Response( $data, 201 );
if ( $location ) {
$response->header( 'Location', $location );
}
return $response;
}
/**
* Format no content response (204).
*
* @return WP_REST_Response
*/
public function no_content(): WP_REST_Response {
return new WP_REST_Response( null, 204 );
}
/**
* Create validation error.
*
* @param string $param Parameter name.
* @param string $message Error message.
* @return WP_Error
*/
public function validation_error( string $param, string $message ): WP_Error {
return new WP_Error(
'rest_invalid_param',
$message,
array(
'status' => 400,
'param' => $param,
)
);
}
/**
* Create not found error.
*
* @param string $resource Resource name.
* @return WP_Error
*/
public function not_found( string $resource = 'Resource' ): WP_Error {
return new WP_Error(
'rest_not_found',
/* translators: %s: Resource name */
sprintf( __( '%s not found.', 'wp-bnb' ), $resource ),
array( 'status' => 404 )
);
}
/**
* Create forbidden error.
*
* @param string $message Error message.
* @return WP_Error
*/
public function forbidden( string $message = '' ): WP_Error {
return new WP_Error(
'rest_forbidden',
$message ?: __( 'You do not have permission to access this resource.', 'wp-bnb' ),
array( 'status' => 403 )
);
}
/**
* Create rate limit error.
*
* @param int $retry_after Seconds until rate limit resets.
* @return WP_Error
*/
public function rate_limit_error( int $retry_after = 60 ): WP_Error {
return new WP_Error(
'rest_rate_limit_exceeded',
__( 'Rate limit exceeded. Please try again later.', 'wp-bnb' ),
array(
'status' => 429,
'retry_after' => $retry_after,
)
);
}
/**
* Create conflict error (e.g., booking conflict).
*
* @param string $message Error message.
* @param array $conflicts Conflicting resources.
* @return WP_Error
*/
public function conflict( string $message, array $conflicts = array() ): WP_Error {
return new WP_Error(
'rest_conflict',
$message,
array(
'status' => 409,
'conflicts' => $conflicts,
)
);
}
/**
* Create internal server error.
*
* @param string $message Error message.
* @return WP_Error
*/
public function server_error( string $message = '' ): WP_Error {
return new WP_Error(
'rest_server_error',
$message ?: __( 'An internal server error occurred.', 'wp-bnb' ),
array( 'status' => 500 )
);
}
}

113
src/Api/RestApi.php Normal file
View File

@@ -0,0 +1,113 @@
<?php
/**
* REST API Main Class
*
* Registers all REST API controllers and routes.
*
* @package Magdev\WpBnb\Api
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api;
use Magdev\WpBnb\Api\Controllers\BuildingsController;
use Magdev\WpBnb\Api\Controllers\RoomsController;
use Magdev\WpBnb\Api\Controllers\BookingsController;
use Magdev\WpBnb\Api\Controllers\GuestsController;
use Magdev\WpBnb\Api\Controllers\ServicesController;
use Magdev\WpBnb\Api\Controllers\PricingController;
/**
* REST API class.
*/
final class RestApi {
/**
* API namespace.
*/
public const NAMESPACE = 'wp-bnb/v1';
/**
* API version.
*/
public const VERSION = '1.0.0';
/**
* Controller instances.
*
* @var array
*/
private array $controllers = array();
/**
* Initialize the REST API.
*
* @return void
*/
public function init(): void {
// Check if API is enabled.
if ( 'yes' !== get_option( 'wp_bnb_api_enabled', 'yes' ) ) {
return;
}
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register all API routes.
*
* @return void
*/
public function register_routes(): void {
$this->controllers = array(
new BuildingsController(),
new RoomsController(),
new BookingsController(),
new GuestsController(),
new ServicesController(),
new PricingController(),
);
foreach ( $this->controllers as $controller ) {
$controller->register_routes();
}
// Register API info endpoint.
register_rest_route(
self::NAMESPACE,
'/info',
array(
'methods' => 'GET',
'callback' => array( $this, 'get_api_info' ),
'permission_callback' => '__return_true',
)
);
}
/**
* Get API information.
*
* @return \WP_REST_Response API info response.
*/
public function get_api_info(): \WP_REST_Response {
return new \WP_REST_Response(
array(
'name' => 'WP BnB REST API',
'version' => self::VERSION,
'namespace' => self::NAMESPACE,
'description' => __( 'REST API for WP BnB booking management.', 'wp-bnb' ),
'endpoints' => array(
'buildings' => rest_url( self::NAMESPACE . '/buildings' ),
'rooms' => rest_url( self::NAMESPACE . '/rooms' ),
'bookings' => rest_url( self::NAMESPACE . '/bookings' ),
'guests' => rest_url( self::NAMESPACE . '/guests' ),
'services' => rest_url( self::NAMESPACE . '/services' ),
'pricing' => rest_url( self::NAMESPACE . '/pricing' ),
'availability' => rest_url( self::NAMESPACE . '/availability' ),
),
),
200
);
}
}

View File

@@ -0,0 +1,465 @@
<?php
/**
* Block registrar.
*
* Handles registration of all Gutenberg blocks.
*
* @package Magdev\WpBnb\Blocks
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Blocks;
use Magdev\WpBnb\Frontend\Search;
use Magdev\WpBnb\Frontend\Shortcodes;
use Magdev\WpBnb\PostTypes\Building;
use Magdev\WpBnb\PostTypes\Room;
/**
* Block registrar class.
*/
final class BlockRegistrar {
/**
* Initialize block registration.
*
* @return void
*/
public static function init(): void {
add_action( 'init', array( self::class, 'register_blocks' ) );
add_action( 'enqueue_block_editor_assets', array( self::class, 'enqueue_editor_assets' ) );
}
/**
* Register all blocks.
*
* @return void
*/
public static function register_blocks(): void {
// Building block.
register_block_type(
'wp-bnb/building',
array(
'attributes' => array(
'buildingId' => array(
'type' => 'number',
'default' => 0,
),
'showImage' => array(
'type' => 'boolean',
'default' => true,
),
'showAddress' => array(
'type' => 'boolean',
'default' => true,
),
'showRooms' => array(
'type' => 'boolean',
'default' => true,
),
'showContact' => array(
'type' => 'boolean',
'default' => true,
),
),
'render_callback' => array( self::class, 'render_building_block' ),
'editor_script' => 'wp-bnb-blocks-editor',
)
);
// Room block.
register_block_type(
'wp-bnb/room',
array(
'attributes' => array(
'roomId' => array(
'type' => 'number',
'default' => 0,
),
'showImage' => array(
'type' => 'boolean',
'default' => true,
),
'showGallery' => array(
'type' => 'boolean',
'default' => true,
),
'showPrice' => array(
'type' => 'boolean',
'default' => true,
),
'showAmenities' => array(
'type' => 'boolean',
'default' => true,
),
'showAvailability' => array(
'type' => 'boolean',
'default' => true,
),
),
'render_callback' => array( self::class, 'render_room_block' ),
'editor_script' => 'wp-bnb-blocks-editor',
)
);
// Room Search block.
register_block_type(
'wp-bnb/room-search',
array(
'attributes' => array(
'layout' => array(
'type' => 'string',
'default' => 'grid',
),
'columns' => array(
'type' => 'number',
'default' => 3,
),
'showDates' => array(
'type' => 'boolean',
'default' => true,
),
'showGuests' => array(
'type' => 'boolean',
'default' => true,
),
'showRoomType' => array(
'type' => 'boolean',
'default' => true,
),
'showAmenities' => array(
'type' => 'boolean',
'default' => true,
),
'showPriceRange' => array(
'type' => 'boolean',
'default' => true,
),
'showBuilding' => array(
'type' => 'boolean',
'default' => true,
),
'resultsPerPage' => array(
'type' => 'number',
'default' => 12,
),
),
'render_callback' => array( self::class, 'render_room_search_block' ),
'editor_script' => 'wp-bnb-blocks-editor',
)
);
// Buildings List block.
register_block_type(
'wp-bnb/buildings',
array(
'attributes' => array(
'layout' => array(
'type' => 'string',
'default' => 'grid',
),
'columns' => array(
'type' => 'number',
'default' => 3,
),
'limit' => array(
'type' => 'number',
'default' => -1,
),
'showImage' => array(
'type' => 'boolean',
'default' => true,
),
'showAddress' => array(
'type' => 'boolean',
'default' => true,
),
'showRoomsCount' => array(
'type' => 'boolean',
'default' => true,
),
),
'render_callback' => array( self::class, 'render_buildings_block' ),
'editor_script' => 'wp-bnb-blocks-editor',
)
);
// Rooms List block.
register_block_type(
'wp-bnb/rooms',
array(
'attributes' => array(
'layout' => array(
'type' => 'string',
'default' => 'grid',
),
'columns' => array(
'type' => 'number',
'default' => 3,
),
'limit' => array(
'type' => 'number',
'default' => 12,
),
'buildingId' => array(
'type' => 'number',
'default' => 0,
),
'roomType' => array(
'type' => 'string',
'default' => '',
),
'showImage' => array(
'type' => 'boolean',
'default' => true,
),
'showPrice' => array(
'type' => 'boolean',
'default' => true,
),
'showCapacity' => array(
'type' => 'boolean',
'default' => true,
),
'showAmenities' => array(
'type' => 'boolean',
'default' => true,
),
'showBuilding' => array(
'type' => 'boolean',
'default' => true,
),
),
'render_callback' => array( self::class, 'render_rooms_block' ),
'editor_script' => 'wp-bnb-blocks-editor',
)
);
}
/**
* Enqueue editor assets.
*
* @return void
*/
public static function enqueue_editor_assets(): void {
// Register the editor script.
wp_register_script(
'wp-bnb-blocks-editor',
WP_BNB_URL . 'assets/js/blocks-editor.js',
array( 'wp-blocks', 'wp-element', 'wp-editor', 'wp-components', 'wp-i18n', 'wp-data' ),
WP_BNB_VERSION,
true
);
// Get buildings and rooms for selectors.
$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',
)
);
$form_data = Search::get_search_form_data();
wp_localize_script(
'wp-bnb-blocks-editor',
'wpBnbBlocks',
array(
'buildings' => array_map(
function ( $building ) {
return array(
'value' => $building->ID,
'label' => $building->post_title,
);
},
$buildings
),
'rooms' => array_map(
function ( $room ) {
$building_id = get_post_meta( $room->ID, '_bnb_room_building_id', true );
$building = $building_id ? get_post( $building_id ) : null;
return array(
'value' => $room->ID,
'label' => $room->post_title,
'building' => $building ? $building->post_title : '',
);
},
$rooms
),
'roomTypes' => $form_data['room_types'],
'amenities' => $form_data['amenities'],
'i18n' => array(
'selectBuilding' => __( 'Select a building', 'wp-bnb' ),
'selectRoom' => __( 'Select a room', 'wp-bnb' ),
'noBuildings' => __( 'No buildings found. Create a building first.', 'wp-bnb' ),
'noRooms' => __( 'No rooms found. Create a room first.', 'wp-bnb' ),
'buildingBlock' => __( 'Building', 'wp-bnb' ),
'roomBlock' => __( 'Room', 'wp-bnb' ),
'roomSearchBlock' => __( 'Room Search', 'wp-bnb' ),
'buildingsBlock' => __( 'Buildings List', 'wp-bnb' ),
'roomsBlock' => __( 'Rooms List', 'wp-bnb' ),
'displaySettings' => __( 'Display Settings', 'wp-bnb' ),
'filterSettings' => __( 'Filter Settings', 'wp-bnb' ),
'layout' => __( 'Layout', 'wp-bnb' ),
'grid' => __( 'Grid', 'wp-bnb' ),
'list' => __( 'List', 'wp-bnb' ),
'columns' => __( 'Columns', 'wp-bnb' ),
'limit' => __( 'Limit', 'wp-bnb' ),
'showImage' => __( 'Show image', 'wp-bnb' ),
'showAddress' => __( 'Show address', 'wp-bnb' ),
'showRooms' => __( 'Show rooms', 'wp-bnb' ),
'showRoomsCount' => __( 'Show rooms count', 'wp-bnb' ),
'showContact' => __( 'Show contact', 'wp-bnb' ),
'showGallery' => __( 'Show gallery', 'wp-bnb' ),
'showPrice' => __( 'Show price', 'wp-bnb' ),
'showAmenities' => __( 'Show amenities', 'wp-bnb' ),
'showAvailability' => __( 'Show availability', 'wp-bnb' ),
'showCapacity' => __( 'Show capacity', 'wp-bnb' ),
'showBuilding' => __( 'Show building', 'wp-bnb' ),
'showDates' => __( 'Show date filter', 'wp-bnb' ),
'showGuests' => __( 'Show guests filter', 'wp-bnb' ),
'showRoomType' => __( 'Show room type filter', 'wp-bnb' ),
'showPriceRange' => __( 'Show price range filter', 'wp-bnb' ),
'resultsPerPage' => __( 'Results per page', 'wp-bnb' ),
'roomType' => __( 'Room Type', 'wp-bnb' ),
'allTypes' => __( 'All Types', 'wp-bnb' ),
'allBuildings' => __( 'All Buildings', 'wp-bnb' ),
'previewPlaceholder' => __( 'Preview will appear here', 'wp-bnb' ),
),
)
);
// Editor styles.
wp_enqueue_style(
'wp-bnb-blocks-editor',
WP_BNB_URL . 'assets/css/blocks-editor.css',
array(),
WP_BNB_VERSION
);
}
/**
* Render building block.
*
* @param array $attributes Block attributes.
* @return string HTML output.
*/
public static function render_building_block( array $attributes ): string {
$building_id = $attributes['buildingId'] ?? 0;
if ( ! $building_id ) {
return '<p class="wp-bnb-block-placeholder">' . esc_html__( 'Please select a building.', 'wp-bnb' ) . '</p>';
}
return Shortcodes::render_single_building(
array(
'id' => $building_id,
'show_rooms' => ( $attributes['showRooms'] ?? true ) ? 'yes' : 'no',
'show_address' => ( $attributes['showAddress'] ?? true ) ? 'yes' : 'no',
'show_contact' => ( $attributes['showContact'] ?? true ) ? 'yes' : 'no',
)
);
}
/**
* Render room block.
*
* @param array $attributes Block attributes.
* @return string HTML output.
*/
public static function render_room_block( array $attributes ): string {
$room_id = $attributes['roomId'] ?? 0;
if ( ! $room_id ) {
return '<p class="wp-bnb-block-placeholder">' . esc_html__( 'Please select a room.', 'wp-bnb' ) . '</p>';
}
return Shortcodes::render_single_room(
array(
'id' => $room_id,
'show_gallery' => ( $attributes['showGallery'] ?? true ) ? 'yes' : 'no',
'show_pricing' => ( $attributes['showPrice'] ?? true ) ? 'yes' : 'no',
'show_amenities' => ( $attributes['showAmenities'] ?? true ) ? 'yes' : 'no',
'show_availability' => ( $attributes['showAvailability'] ?? true ) ? 'yes' : 'no',
)
);
}
/**
* Render room search block.
*
* @param array $attributes Block attributes.
* @return string HTML output.
*/
public static function render_room_search_block( array $attributes ): string {
return Shortcodes::render_room_search(
array(
'layout' => $attributes['layout'] ?? 'grid',
'columns' => $attributes['columns'] ?? 3,
'show_dates' => ( $attributes['showDates'] ?? true ) ? 'yes' : 'no',
'show_guests' => ( $attributes['showGuests'] ?? true ) ? 'yes' : 'no',
'show_room_type' => ( $attributes['showRoomType'] ?? true ) ? 'yes' : 'no',
'show_amenities' => ( $attributes['showAmenities'] ?? true ) ? 'yes' : 'no',
'show_price_range' => ( $attributes['showPriceRange'] ?? true ) ? 'yes' : 'no',
'show_building' => ( $attributes['showBuilding'] ?? true ) ? 'yes' : 'no',
'results_per_page' => $attributes['resultsPerPage'] ?? 12,
)
);
}
/**
* Render buildings list block.
*
* @param array $attributes Block attributes.
* @return string HTML output.
*/
public static function render_buildings_block( array $attributes ): string {
return Shortcodes::render_buildings(
array(
'layout' => $attributes['layout'] ?? 'grid',
'columns' => $attributes['columns'] ?? 3,
'limit' => $attributes['limit'] ?? -1,
'show_image' => ( $attributes['showImage'] ?? true ) ? 'yes' : 'no',
'show_address' => ( $attributes['showAddress'] ?? true ) ? 'yes' : 'no',
'show_rooms_count' => ( $attributes['showRoomsCount'] ?? true ) ? 'yes' : 'no',
)
);
}
/**
* Render rooms list block.
*
* @param array $attributes Block attributes.
* @return string HTML output.
*/
public static function render_rooms_block( array $attributes ): string {
return Shortcodes::render_rooms(
array(
'layout' => $attributes['layout'] ?? 'grid',
'columns' => $attributes['columns'] ?? 3,
'limit' => $attributes['limit'] ?? 12,
'building_id' => $attributes['buildingId'] ?? 0,
'room_type' => $attributes['roomType'] ?? '',
'show_image' => ( $attributes['showImage'] ?? true ) ? 'yes' : 'no',
'show_price' => ( $attributes['showPrice'] ?? true ) ? 'yes' : 'no',
'show_capacity' => ( $attributes['showCapacity'] ?? true ) ? 'yes' : 'no',
'show_amenities' => ( $attributes['showAmenities'] ?? true ) ? 'yes' : 'no',
'show_building' => ( $attributes['showBuilding'] ?? true ) ? 'yes' : 'no',
)
);
}
}

View File

@@ -12,6 +12,7 @@ declare( strict_types=1 );
namespace Magdev\WpBnb\Booking;
use Magdev\WpBnb\PostTypes\Booking;
use Magdev\WpBnb\PostTypes\Guest;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\Pricing\Calculator;
@@ -221,13 +222,19 @@ final class EmailNotifier {
$statuses = Booking::get_booking_statuses();
// Get guest data - prefer Guest CPT if linked, fallback to booking meta.
$guest_data = self::get_guest_data( $booking_id );
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 ),
'guest_name' => $guest_data['name'],
'guest_first_name' => $guest_data['first_name'],
'guest_last_name' => $guest_data['last_name'],
'guest_email' => $guest_data['email'],
'guest_phone' => $guest_data['phone'],
'guest_notes' => $guest_data['notes'],
'guest_full_address' => $guest_data['full_address'],
'adults' => $adults ?: 1,
'children' => $children ?: 0,
'room_name' => $room ? $room->post_title : '',
@@ -249,6 +256,53 @@ final class EmailNotifier {
);
}
/**
* Get guest data from Guest CPT or booking meta.
*
* @param int $booking_id Booking post ID.
* @return array Guest data with keys: name, first_name, last_name, email, phone, notes, full_address.
*/
private static function get_guest_data( int $booking_id ): array {
$guest_id = get_post_meta( $booking_id, '_bnb_booking_guest_id', true );
// Try to get data from Guest CPT.
if ( $guest_id ) {
$guest = get_post( $guest_id );
if ( $guest && Guest::POST_TYPE === $guest->post_type ) {
$first_name = get_post_meta( $guest_id, '_bnb_guest_first_name', true );
$last_name = get_post_meta( $guest_id, '_bnb_guest_last_name', true );
return array(
'name' => Guest::get_full_name( $guest_id ),
'first_name' => $first_name,
'last_name' => $last_name,
'email' => get_post_meta( $guest_id, '_bnb_guest_email', true ),
'phone' => get_post_meta( $guest_id, '_bnb_guest_phone', true ),
'notes' => get_post_meta( $guest_id, '_bnb_guest_notes', true ),
'full_address' => Guest::get_formatted_address( $guest_id ),
);
}
}
// Fallback to booking meta (legacy bookings).
$guest_name = get_post_meta( $booking_id, '_bnb_booking_guest_name', true );
// Try to split name into first/last for legacy data.
$name_parts = explode( ' ', $guest_name, 2 );
$first_name = $name_parts[0] ?? '';
$last_name = $name_parts[1] ?? '';
return array(
'name' => $guest_name,
'first_name' => $first_name,
'last_name' => $last_name,
'email' => get_post_meta( $booking_id, '_bnb_booking_guest_email', true ),
'phone' => get_post_meta( $booking_id, '_bnb_booking_guest_phone', true ),
'notes' => get_post_meta( $booking_id, '_bnb_booking_guest_notes', true ),
'full_address' => '', // Legacy bookings don't have full address.
);
}
/**
* Get email template with placeholders replaced.
*

677
src/Frontend/Search.php Normal file
View File

@@ -0,0 +1,677 @@
<?php
/**
* Frontend room search.
*
* Handles room search with availability checking and filtering.
*
* @package Magdev\WpBnb\Frontend
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Frontend;
use Magdev\WpBnb\Booking\Availability;
use Magdev\WpBnb\PostTypes\Booking;
use Magdev\WpBnb\PostTypes\Building;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\Pricing\Calculator;
use Magdev\WpBnb\Pricing\PricingTier;
use Magdev\WpBnb\Taxonomies\Amenity;
use Magdev\WpBnb\Taxonomies\RoomType;
/**
* Search class for frontend room searches.
*/
final class Search {
/**
* Initialize the search system.
*
* @return void
*/
public static function init(): void {
// Public AJAX handlers (no login required).
add_action( 'wp_ajax_wp_bnb_search_rooms', array( self::class, 'ajax_search_rooms' ) );
add_action( 'wp_ajax_nopriv_wp_bnb_search_rooms', array( self::class, 'ajax_search_rooms' ) );
add_action( 'wp_ajax_wp_bnb_get_availability', array( self::class, 'ajax_get_availability' ) );
add_action( 'wp_ajax_nopriv_wp_bnb_get_availability', array( self::class, 'ajax_get_availability' ) );
add_action( 'wp_ajax_wp_bnb_get_calendar', array( self::class, 'ajax_get_calendar' ) );
add_action( 'wp_ajax_nopriv_wp_bnb_get_calendar', array( self::class, 'ajax_get_calendar' ) );
add_action( 'wp_ajax_wp_bnb_calculate_price', array( self::class, 'ajax_calculate_price' ) );
add_action( 'wp_ajax_nopriv_wp_bnb_calculate_price', array( self::class, 'ajax_calculate_price' ) );
}
/**
* Search for rooms with filters.
*
* @param array $args Search arguments.
* @return array Array of room data.
*/
public static function search( array $args = array() ): array {
$defaults = array(
'check_in' => '',
'check_out' => '',
'guests' => 0,
'room_type' => '',
'amenities' => array(),
'price_min' => 0,
'price_max' => 0,
'building_id' => 0,
'orderby' => 'title',
'order' => 'ASC',
'limit' => -1,
'offset' => 0,
);
$args = wp_parse_args( $args, $defaults );
// Build base query.
$query_args = array(
'post_type' => Room::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => (int) $args['limit'],
'offset' => (int) $args['offset'],
'meta_query' => array(
'relation' => 'AND',
),
'tax_query' => array(
'relation' => 'AND',
),
);
// Filter by building.
if ( ! empty( $args['building_id'] ) ) {
$query_args['meta_query'][] = array(
'key' => '_bnb_room_building_id',
'value' => (int) $args['building_id'],
);
}
// Filter by capacity.
if ( ! empty( $args['guests'] ) && (int) $args['guests'] > 0 ) {
$query_args['meta_query'][] = array(
'key' => '_bnb_room_capacity',
'value' => (int) $args['guests'],
'compare' => '>=',
'type' => 'NUMERIC',
);
}
// Filter by room status (only available rooms).
$query_args['meta_query'][] = array(
'relation' => 'OR',
array(
'key' => '_bnb_room_status',
'value' => 'available',
),
array(
'key' => '_bnb_room_status',
'compare' => 'NOT EXISTS',
),
);
// Filter by room type.
if ( ! empty( $args['room_type'] ) ) {
$query_args['tax_query'][] = array(
'taxonomy' => RoomType::TAXONOMY,
'field' => is_numeric( $args['room_type'] ) ? 'term_id' : 'slug',
'terms' => $args['room_type'],
);
}
// Filter by amenities (all must match).
if ( ! empty( $args['amenities'] ) ) {
$amenities = is_array( $args['amenities'] ) ? $args['amenities'] : explode( ',', $args['amenities'] );
$amenities = array_map( 'trim', $amenities );
$amenities = array_filter( $amenities );
if ( ! empty( $amenities ) ) {
$query_args['tax_query'][] = array(
'taxonomy' => Amenity::TAXONOMY,
'field' => is_numeric( $amenities[0] ) ? 'term_id' : 'slug',
'terms' => $amenities,
'operator' => 'AND',
);
}
}
// Handle ordering.
switch ( $args['orderby'] ) {
case 'price':
$query_args['meta_key'] = '_bnb_room_price_' . PricingTier::SHORT_TERM->value;
$query_args['orderby'] = 'meta_value_num';
break;
case 'capacity':
$query_args['meta_key'] = '_bnb_room_capacity';
$query_args['orderby'] = 'meta_value_num';
break;
case 'date':
$query_args['orderby'] = 'date';
break;
case 'random':
$query_args['orderby'] = 'rand';
break;
default:
$query_args['orderby'] = 'title';
break;
}
$query_args['order'] = strtoupper( $args['order'] ) === 'DESC' ? 'DESC' : 'ASC';
// Execute query.
$rooms = get_posts( $query_args );
// Filter by availability if dates provided.
if ( ! empty( $args['check_in'] ) && ! empty( $args['check_out'] ) ) {
$rooms = self::filter_by_availability( $rooms, $args['check_in'], $args['check_out'] );
}
// Filter by price range.
if ( ( ! empty( $args['price_min'] ) || ! empty( $args['price_max'] ) ) && ! empty( $args['check_in'] ) && ! empty( $args['check_out'] ) ) {
$rooms = self::filter_by_price_range(
$rooms,
(float) $args['price_min'],
(float) $args['price_max'],
$args['check_in'],
$args['check_out']
);
}
// Build result array with room data.
$results = array();
foreach ( $rooms as $room ) {
$results[] = self::get_room_data( $room, $args['check_in'], $args['check_out'] );
}
return $results;
}
/**
* Filter rooms by availability.
*
* @param array $rooms Array of WP_Post objects.
* @param string $check_in Check-in date (Y-m-d).
* @param string $check_out Check-out date (Y-m-d).
* @return array Filtered rooms.
*/
public static function filter_by_availability( array $rooms, string $check_in, string $check_out ): array {
return array_filter(
$rooms,
function ( $room ) use ( $check_in, $check_out ) {
return Availability::is_available( $room->ID, $check_in, $check_out );
}
);
}
/**
* Filter rooms by price range.
*
* @param array $rooms Array of WP_Post objects.
* @param float $min Minimum price.
* @param float $max Maximum price.
* @param string $check_in Check-in date.
* @param string $check_out Check-out date.
* @return array Filtered rooms.
*/
public static function filter_by_price_range( array $rooms, float $min, float $max, string $check_in, string $check_out ): array {
return array_filter(
$rooms,
function ( $room ) use ( $min, $max, $check_in, $check_out ) {
try {
$calculator = new Calculator( $room->ID, $check_in, $check_out );
$price = $calculator->calculate();
if ( $min > 0 && $price < $min ) {
return false;
}
if ( $max > 0 && $price > $max ) {
return false;
}
return true;
} catch ( \Exception $e ) {
return false;
}
}
);
}
/**
* Get complete room data for display.
*
* @param \WP_Post $room Room post object.
* @param string $check_in Optional check-in date.
* @param string $check_out Optional check-out date.
* @return array Room data array.
*/
public static function get_room_data( \WP_Post $room, string $check_in = '', string $check_out = '' ): array {
$building_id = get_post_meta( $room->ID, '_bnb_room_building_id', true );
$building = $building_id ? get_post( $building_id ) : null;
// Get room types.
$room_types = wp_get_post_terms( $room->ID, RoomType::TAXONOMY, array( 'fields' => 'names' ) );
// Get amenities with icons.
$amenities = wp_get_post_terms( $room->ID, Amenity::TAXONOMY );
$amenity_list = array();
foreach ( $amenities as $amenity ) {
$amenity_list[] = array(
'id' => $amenity->term_id,
'name' => $amenity->name,
'slug' => $amenity->slug,
'icon' => get_term_meta( $amenity->term_id, 'amenity_icon', true ),
);
}
// Get gallery images.
$gallery_ids = get_post_meta( $room->ID, '_bnb_room_gallery', true );
$gallery = array();
if ( $gallery_ids ) {
$ids = explode( ',', $gallery_ids );
foreach ( $ids as $id ) {
$image = wp_get_attachment_image_src( (int) $id, 'large' );
if ( $image ) {
$gallery[] = array(
'id' => (int) $id,
'url' => $image[0],
'width' => $image[1],
'height' => $image[2],
'thumb' => wp_get_attachment_image_src( (int) $id, 'thumbnail' )[0] ?? $image[0],
);
}
}
}
// Get pricing.
$pricing = Calculator::getRoomPricing( $room->ID );
$nightly_price = $pricing[ PricingTier::SHORT_TERM->value ]['price'] ?? null;
// Calculate stay price if dates provided.
$stay_price = null;
$nights = 0;
if ( ! empty( $check_in ) && ! empty( $check_out ) ) {
try {
$calculator = new Calculator( $room->ID, $check_in, $check_out );
$stay_price = $calculator->calculate();
$nights = $calculator->getNights();
} catch ( \Exception $e ) {
$stay_price = null;
}
}
return array(
'id' => $room->ID,
'title' => $room->post_title,
'slug' => $room->post_name,
'excerpt' => get_the_excerpt( $room ),
'content' => apply_filters( 'the_content', $room->post_content ),
'permalink' => get_permalink( $room->ID ),
'featured_image' => get_the_post_thumbnail_url( $room->ID, 'large' ),
'thumbnail' => get_the_post_thumbnail_url( $room->ID, 'medium' ),
'gallery' => $gallery,
'building' => $building ? array(
'id' => $building->ID,
'title' => $building->post_title,
'permalink' => get_permalink( $building->ID ),
'city' => get_post_meta( $building->ID, '_bnb_building_city', true ),
) : null,
'room_number' => get_post_meta( $room->ID, '_bnb_room_room_number', true ),
'floor' => (int) get_post_meta( $room->ID, '_bnb_room_floor', true ),
'capacity' => (int) get_post_meta( $room->ID, '_bnb_room_capacity', true ),
'max_adults' => (int) get_post_meta( $room->ID, '_bnb_room_max_adults', true ),
'max_children' => (int) get_post_meta( $room->ID, '_bnb_room_max_children', true ),
'size' => (float) get_post_meta( $room->ID, '_bnb_room_size', true ),
'beds' => get_post_meta( $room->ID, '_bnb_room_beds', true ),
'bathrooms' => (float) get_post_meta( $room->ID, '_bnb_room_bathrooms', true ),
'room_types' => $room_types,
'amenities' => $amenity_list,
'nightly_price' => $nightly_price,
'price_formatted' => $nightly_price ? Calculator::formatPrice( $nightly_price ) : null,
'stay_price' => $stay_price,
'stay_price_formatted' => $stay_price ? Calculator::formatPrice( $stay_price ) : null,
'nights' => $nights,
);
}
/**
* Get data for search form (room types, amenities, buildings).
*
* @return array Form data.
*/
public static function get_search_form_data(): array {
// Get all room types.
$room_types = get_terms(
array(
'taxonomy' => RoomType::TAXONOMY,
'hide_empty' => true,
'orderby' => 'meta_value_num',
'meta_key' => 'room_type_sort_order',
'order' => 'ASC',
)
);
// Get all amenities.
$amenities = get_terms(
array(
'taxonomy' => Amenity::TAXONOMY,
'hide_empty' => true,
'orderby' => 'name',
'order' => 'ASC',
)
);
// Get all buildings with rooms.
$buildings = get_posts(
array(
'post_type' => Building::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'orderby' => 'title',
'order' => 'ASC',
)
);
// Filter buildings to only those with rooms.
$buildings_with_rooms = array();
foreach ( $buildings as $building ) {
$rooms = Room::get_rooms_for_building( $building->ID );
if ( ! empty( $rooms ) ) {
$buildings_with_rooms[] = array(
'id' => $building->ID,
'title' => $building->post_title,
'city' => get_post_meta( $building->ID, '_bnb_building_city', true ),
);
}
}
// Get price range from all rooms.
$price_range = self::get_price_range();
// Get capacity range.
$capacity_range = self::get_capacity_range();
return array(
'room_types' => array_map(
function ( $term ) {
return array(
'id' => $term->term_id,
'name' => $term->name,
'slug' => $term->slug,
'parent' => $term->parent,
'count' => $term->count,
'capacity' => (int) get_term_meta( $term->term_id, 'room_type_base_capacity', true ),
);
},
$room_types
),
'amenities' => array_map(
function ( $term ) {
return array(
'id' => $term->term_id,
'name' => $term->name,
'slug' => $term->slug,
'icon' => get_term_meta( $term->term_id, 'amenity_icon', true ),
'count' => $term->count,
);
},
$amenities
),
'buildings' => $buildings_with_rooms,
'price_range' => $price_range,
'capacity_range' => $capacity_range,
'currency' => get_option( 'wp_bnb_currency', 'CHF' ),
);
}
/**
* Get price range from all rooms.
*
* @return array Min and max prices.
*/
public static function get_price_range(): array {
global $wpdb;
$meta_key = '_bnb_room_price_' . PricingTier::SHORT_TERM->value;
$result = $wpdb->get_row(
$wpdb->prepare(
"SELECT MIN(CAST(meta_value AS DECIMAL(10,2))) as min_price,
MAX(CAST(meta_value AS DECIMAL(10,2))) as max_price
FROM {$wpdb->postmeta} pm
JOIN {$wpdb->posts} p ON pm.post_id = p.ID
WHERE pm.meta_key = %s
AND pm.meta_value != ''
AND pm.meta_value > 0
AND p.post_type = %s
AND p.post_status = 'publish'",
$meta_key,
Room::POST_TYPE
)
);
return array(
'min' => $result ? (float) $result->min_price : 0,
'max' => $result ? (float) $result->max_price : 500,
);
}
/**
* Get capacity range from all rooms.
*
* @return array Min and max capacity.
*/
public static function get_capacity_range(): array {
global $wpdb;
$result = $wpdb->get_row(
$wpdb->prepare(
"SELECT MIN(CAST(meta_value AS UNSIGNED)) as min_capacity,
MAX(CAST(meta_value AS UNSIGNED)) as max_capacity
FROM {$wpdb->postmeta} pm
JOIN {$wpdb->posts} p ON pm.post_id = p.ID
WHERE pm.meta_key = '_bnb_room_capacity'
AND pm.meta_value != ''
AND p.post_type = %s
AND p.post_status = 'publish'",
Room::POST_TYPE
)
);
return array(
'min' => $result && $result->min_capacity ? (int) $result->min_capacity : 1,
'max' => $result && $result->max_capacity ? (int) $result->max_capacity : 10,
);
}
/**
* AJAX handler for room search.
*
* @return void
*/
public static function ajax_search_rooms(): void {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- Public API.
$args = array(
'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'] ) ) : '',
'guests' => isset( $_POST['guests'] ) ? absint( $_POST['guests'] ) : 0,
'room_type' => isset( $_POST['room_type'] ) ? sanitize_text_field( wp_unslash( $_POST['room_type'] ) ) : '',
'amenities' => isset( $_POST['amenities'] ) ? array_map( 'sanitize_text_field', (array) $_POST['amenities'] ) : array(),
'price_min' => isset( $_POST['price_min'] ) ? (float) $_POST['price_min'] : 0,
'price_max' => isset( $_POST['price_max'] ) ? (float) $_POST['price_max'] : 0,
'building_id' => isset( $_POST['building_id'] ) ? absint( $_POST['building_id'] ) : 0,
'orderby' => isset( $_POST['orderby'] ) ? sanitize_text_field( wp_unslash( $_POST['orderby'] ) ) : 'title',
'order' => isset( $_POST['order'] ) ? sanitize_text_field( wp_unslash( $_POST['order'] ) ) : 'ASC',
'limit' => isset( $_POST['limit'] ) ? absint( $_POST['limit'] ) : 12,
'offset' => isset( $_POST['offset'] ) ? absint( $_POST['offset'] ) : 0,
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
// Validate dates if provided.
if ( ! empty( $args['check_in'] ) && ! empty( $args['check_out'] ) ) {
$check_in = strtotime( $args['check_in'] );
$check_out = strtotime( $args['check_out'] );
if ( ! $check_in || ! $check_out || $check_out <= $check_in ) {
wp_send_json_error(
array( 'message' => __( 'Invalid date range.', 'wp-bnb' ) )
);
}
if ( $check_in < strtotime( 'today' ) ) {
wp_send_json_error(
array( 'message' => __( 'Check-in date cannot be in the past.', 'wp-bnb' ) )
);
}
}
$results = self::search( $args );
wp_send_json_success(
array(
'rooms' => $results,
'count' => count( $results ),
'args' => $args,
)
);
}
/**
* AJAX handler for availability check.
*
* @return void
*/
public static function ajax_get_availability(): void {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- Public API.
$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'] ) ) : '';
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( ! $room_id || ! $check_in || ! $check_out ) {
wp_send_json_error(
array( 'message' => __( 'Missing required parameters.', 'wp-bnb' ) )
);
}
$available = Availability::is_available( $room_id, $check_in, $check_out );
$result = array(
'available' => $available,
'room_id' => $room_id,
'check_in' => $check_in,
'check_out' => $check_out,
);
if ( $available ) {
try {
$calculator = new Calculator( $room_id, $check_in, $check_out );
$price = $calculator->calculate();
$result['price'] = $price;
$result['price_formatted'] = Calculator::formatPrice( $price );
$result['nights'] = $calculator->getNights();
$result['breakdown'] = $calculator->getBreakdown();
} catch ( \Exception $e ) {
$result['price_error'] = $e->getMessage();
}
}
wp_send_json_success( $result );
}
/**
* AJAX handler for calendar data.
*
* @return void
*/
public static function ajax_get_calendar(): void {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- Public API.
$room_id = isset( $_POST['room_id'] ) ? absint( $_POST['room_id'] ) : 0;
$year = isset( $_POST['year'] ) ? absint( $_POST['year'] ) : (int) gmdate( 'Y' );
$month = isset( $_POST['month'] ) ? absint( $_POST['month'] ) : (int) gmdate( 'n' );
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( ! $room_id ) {
wp_send_json_error(
array( 'message' => __( 'Room ID is required.', 'wp-bnb' ) )
);
}
// Validate month.
$month = max( 1, min( 12, $month ) );
// Get calendar data.
$calendar = Availability::get_calendar_data( $room_id, $year, $month );
// Simplify for frontend (remove booking details, just show availability).
$days = array();
foreach ( $calendar['days'] as $day_num => $day_data ) {
$days[ $day_num ] = array(
'date' => $day_data['date'],
'day' => $day_data['day'],
'available' => ! $day_data['is_booked'],
'is_past' => $day_data['is_past'],
'is_today' => $day_data['is_today'],
);
}
wp_send_json_success(
array(
'room_id' => $room_id,
'year' => $year,
'month' => $month,
'month_name' => $calendar['month_name'],
'days_in_month' => $calendar['days_in_month'],
'first_day_of_week' => $calendar['first_day_of_week'],
'days' => $days,
'prev_month' => $calendar['prev_month'],
'next_month' => $calendar['next_month'],
)
);
}
/**
* AJAX handler for price calculation.
*
* @return void
*/
public static function ajax_calculate_price(): void {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- Public API.
$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'] ) ) : '';
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( ! $room_id || ! $check_in || ! $check_out ) {
wp_send_json_error(
array( 'message' => __( 'Missing required parameters.', 'wp-bnb' ) )
);
}
try {
$calculator = new Calculator( $room_id, $check_in, $check_out );
$price = $calculator->calculate();
$breakdown = $calculator->getBreakdown();
wp_send_json_success(
array(
'room_id' => $room_id,
'check_in' => $check_in,
'check_out' => $check_out,
'nights' => $calculator->getNights(),
'price' => $price,
'price_formatted' => Calculator::formatPrice( $price ),
'tier' => $breakdown['tier'] ?? null,
'breakdown' => $breakdown,
)
);
} catch ( \Exception $e ) {
wp_send_json_error(
array( 'message' => $e->getMessage() )
);
}
}
}

867
src/Frontend/Shortcodes.php Normal file
View File

@@ -0,0 +1,867 @@
<?php
/**
* Frontend shortcodes.
*
* Handles all shortcode registration and rendering.
*
* @package Magdev\WpBnb\Frontend
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Frontend;
use Magdev\WpBnb\Booking\Availability;
use Magdev\WpBnb\PostTypes\Building;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\Pricing\Calculator;
use Magdev\WpBnb\Pricing\PricingTier;
use Magdev\WpBnb\Taxonomies\Amenity;
use Magdev\WpBnb\Taxonomies\RoomType;
/**
* Shortcodes class.
*/
final class Shortcodes {
/**
* Initialize shortcodes.
*
* @return void
*/
public static function init(): void {
add_shortcode( 'bnb_buildings', array( self::class, 'render_buildings' ) );
add_shortcode( 'bnb_rooms', array( self::class, 'render_rooms' ) );
add_shortcode( 'bnb_room_search', array( self::class, 'render_room_search' ) );
add_shortcode( 'bnb_building', array( self::class, 'render_single_building' ) );
add_shortcode( 'bnb_room', array( self::class, 'render_single_room' ) );
}
/**
* Render buildings list/grid shortcode.
*
* @param array $atts Shortcode attributes.
* @return string HTML output.
*/
public static function render_buildings( $atts ): string {
$atts = shortcode_atts(
array(
'layout' => 'grid',
'columns' => 3,
'limit' => -1,
'orderby' => 'title',
'order' => 'ASC',
'show_image' => 'yes',
'show_address' => 'yes',
'show_rooms_count' => 'yes',
),
$atts,
'bnb_buildings'
);
// Query buildings.
$query_args = array(
'post_type' => Building::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => (int) $atts['limit'],
'orderby' => sanitize_text_field( $atts['orderby'] ),
'order' => strtoupper( $atts['order'] ) === 'DESC' ? 'DESC' : 'ASC',
);
$buildings = get_posts( $query_args );
if ( empty( $buildings ) ) {
return '<p class="wp-bnb-no-results">' . esc_html__( 'No buildings found.', 'wp-bnb' ) . '</p>';
}
$layout = sanitize_text_field( $atts['layout'] );
$columns = max( 1, min( 4, (int) $atts['columns'] ) );
$classes = array(
'wp-bnb-buildings',
'wp-bnb-buildings-' . $layout,
);
if ( 'grid' === $layout ) {
$classes[] = 'wp-bnb-columns-' . $columns;
}
ob_start();
?>
<div class="<?php echo esc_attr( implode( ' ', $classes ) ); ?>">
<?php foreach ( $buildings as $building ) : ?>
<?php echo self::render_building_card( $building, $atts ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php endforeach; ?>
</div>
<?php
return ob_get_clean();
}
/**
* Render a single building card.
*
* @param \WP_Post $building Building post.
* @param array $atts Display attributes.
* @return string HTML output.
*/
private static function render_building_card( \WP_Post $building, array $atts ): string {
$show_image = 'yes' === $atts['show_image'];
$show_address = 'yes' === $atts['show_address'];
$show_rooms_count = 'yes' === $atts['show_rooms_count'];
// Get room count.
$rooms = Room::get_rooms_for_building( $building->ID );
$room_count = count( $rooms );
ob_start();
?>
<div class="wp-bnb-building-card">
<?php if ( $show_image && has_post_thumbnail( $building->ID ) ) : ?>
<div class="wp-bnb-building-image">
<a href="<?php echo esc_url( get_permalink( $building->ID ) ); ?>">
<?php echo get_the_post_thumbnail( $building->ID, 'medium_large' ); ?>
</a>
</div>
<?php endif; ?>
<div class="wp-bnb-building-content">
<h3 class="wp-bnb-building-title">
<a href="<?php echo esc_url( get_permalink( $building->ID ) ); ?>">
<?php echo esc_html( $building->post_title ); ?>
</a>
</h3>
<?php if ( $show_address ) : ?>
<?php
$city = get_post_meta( $building->ID, '_bnb_building_city', true );
$country = get_post_meta( $building->ID, '_bnb_building_country', true );
if ( $city || $country ) :
$countries = Building::get_countries();
$country_name = $countries[ $country ] ?? $country;
?>
<p class="wp-bnb-building-address">
<span class="dashicons dashicons-location"></span>
<?php echo esc_html( implode( ', ', array_filter( array( $city, $country_name ) ) ) ); ?>
</p>
<?php endif; ?>
<?php endif; ?>
<?php if ( $show_rooms_count && $room_count > 0 ) : ?>
<p class="wp-bnb-building-rooms">
<span class="dashicons dashicons-admin-home"></span>
<?php
printf(
/* translators: %d: Number of rooms */
esc_html( _n( '%d room', '%d rooms', $room_count, 'wp-bnb' ) ),
(int) $room_count
);
?>
</p>
<?php endif; ?>
<?php if ( has_excerpt( $building->ID ) ) : ?>
<div class="wp-bnb-building-excerpt">
<?php echo wp_kses_post( get_the_excerpt( $building->ID ) ); ?>
</div>
<?php endif; ?>
<a href="<?php echo esc_url( get_permalink( $building->ID ) ); ?>" class="wp-bnb-button">
<?php esc_html_e( 'View Details', 'wp-bnb' ); ?>
</a>
</div>
</div>
<?php
return ob_get_clean();
}
/**
* Render rooms list/grid shortcode.
*
* @param array $atts Shortcode attributes.
* @return string HTML output.
*/
public static function render_rooms( $atts ): string {
$atts = shortcode_atts(
array(
'layout' => 'grid',
'columns' => 3,
'limit' => 12,
'building_id' => 0,
'room_type' => '',
'amenities' => '',
'orderby' => 'title',
'order' => 'ASC',
'show_image' => 'yes',
'show_price' => 'yes',
'show_capacity' => 'yes',
'show_amenities' => 'yes',
'show_building' => 'yes',
),
$atts,
'bnb_rooms'
);
// Use search function for filtering.
$search_args = array(
'building_id' => (int) $atts['building_id'],
'room_type' => sanitize_text_field( $atts['room_type'] ),
'amenities' => $atts['amenities'] ? explode( ',', $atts['amenities'] ) : array(),
'orderby' => sanitize_text_field( $atts['orderby'] ),
'order' => $atts['order'],
'limit' => (int) $atts['limit'],
);
$rooms = Search::search( $search_args );
if ( empty( $rooms ) ) {
return '<p class="wp-bnb-no-results">' . esc_html__( 'No rooms found.', 'wp-bnb' ) . '</p>';
}
$layout = sanitize_text_field( $atts['layout'] );
$columns = max( 1, min( 4, (int) $atts['columns'] ) );
$classes = array(
'wp-bnb-rooms',
'wp-bnb-rooms-' . $layout,
);
if ( 'grid' === $layout ) {
$classes[] = 'wp-bnb-columns-' . $columns;
}
ob_start();
?>
<div class="<?php echo esc_attr( implode( ' ', $classes ) ); ?>">
<?php foreach ( $rooms as $room_data ) : ?>
<?php echo self::render_room_card( $room_data, $atts ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php endforeach; ?>
</div>
<?php
return ob_get_clean();
}
/**
* Render a single room card.
*
* @param array $room Room data array.
* @param array $atts Display attributes.
* @return string HTML output.
*/
private static function render_room_card( array $room, array $atts ): string {
$show_image = 'yes' === $atts['show_image'];
$show_price = 'yes' === $atts['show_price'];
$show_capacity = 'yes' === $atts['show_capacity'];
$show_amenities = 'yes' === $atts['show_amenities'];
$show_building = 'yes' === $atts['show_building'];
ob_start();
?>
<div class="wp-bnb-room-card" data-room-id="<?php echo esc_attr( $room['id'] ); ?>">
<?php if ( $show_image && ! empty( $room['featured_image'] ) ) : ?>
<div class="wp-bnb-room-image">
<a href="<?php echo esc_url( $room['permalink'] ); ?>">
<img src="<?php echo esc_url( $room['featured_image'] ); ?>" alt="<?php echo esc_attr( $room['title'] ); ?>">
</a>
<?php if ( ! empty( $room['room_types'] ) ) : ?>
<span class="wp-bnb-room-type-badge"><?php echo esc_html( $room['room_types'][0] ); ?></span>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="wp-bnb-room-content">
<h3 class="wp-bnb-room-title">
<a href="<?php echo esc_url( $room['permalink'] ); ?>">
<?php echo esc_html( $room['title'] ); ?>
</a>
</h3>
<?php if ( $show_building && ! empty( $room['building'] ) ) : ?>
<p class="wp-bnb-room-building">
<span class="dashicons dashicons-building"></span>
<a href="<?php echo esc_url( $room['building']['permalink'] ); ?>">
<?php echo esc_html( $room['building']['title'] ); ?>
</a>
<?php if ( ! empty( $room['building']['city'] ) ) : ?>
<span class="wp-bnb-room-city">, <?php echo esc_html( $room['building']['city'] ); ?></span>
<?php endif; ?>
</p>
<?php endif; ?>
<div class="wp-bnb-room-meta">
<?php if ( $show_capacity && ! empty( $room['capacity'] ) ) : ?>
<span class="wp-bnb-room-capacity">
<span class="dashicons dashicons-groups"></span>
<?php
printf(
/* translators: %d: Number of guests */
esc_html( _n( '%d guest', '%d guests', $room['capacity'], 'wp-bnb' ) ),
(int) $room['capacity']
);
?>
</span>
<?php endif; ?>
<?php if ( ! empty( $room['size'] ) ) : ?>
<span class="wp-bnb-room-size">
<span class="dashicons dashicons-editor-expand"></span>
<?php echo esc_html( $room['size'] ); ?> m²
</span>
<?php endif; ?>
<?php if ( ! empty( $room['beds'] ) ) : ?>
<span class="wp-bnb-room-beds">
<span class="dashicons dashicons-admin-home"></span>
<?php echo esc_html( $room['beds'] ); ?>
</span>
<?php endif; ?>
</div>
<?php if ( $show_amenities && ! empty( $room['amenities'] ) ) : ?>
<div class="wp-bnb-room-amenities">
<?php foreach ( array_slice( $room['amenities'], 0, 4 ) as $amenity ) : ?>
<span class="wp-bnb-amenity" title="<?php echo esc_attr( $amenity['name'] ); ?>">
<?php if ( ! empty( $amenity['icon'] ) ) : ?>
<span class="dashicons dashicons-<?php echo esc_attr( $amenity['icon'] ); ?>"></span>
<?php endif; ?>
<span class="wp-bnb-amenity-name"><?php echo esc_html( $amenity['name'] ); ?></span>
</span>
<?php endforeach; ?>
<?php if ( count( $room['amenities'] ) > 4 ) : ?>
<span class="wp-bnb-amenity-more">
+<?php echo (int) ( count( $room['amenities'] ) - 4 ); ?>
</span>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="wp-bnb-room-footer">
<?php if ( $show_price && ! empty( $room['price_formatted'] ) ) : ?>
<span class="wp-bnb-room-price">
<span class="wp-bnb-price-amount"><?php echo esc_html( $room['price_formatted'] ); ?></span>
<span class="wp-bnb-price-unit"><?php esc_html_e( '/night', 'wp-bnb' ); ?></span>
</span>
<?php endif; ?>
<a href="<?php echo esc_url( $room['permalink'] ); ?>" class="wp-bnb-button">
<?php esc_html_e( 'View Details', 'wp-bnb' ); ?>
</a>
</div>
</div>
</div>
<?php
return ob_get_clean();
}
/**
* Render room search form with results.
*
* @param array $atts Shortcode attributes.
* @return string HTML output.
*/
public static function render_room_search( $atts ): string {
$atts = shortcode_atts(
array(
'layout' => 'grid',
'columns' => 3,
'show_dates' => 'yes',
'show_guests' => 'yes',
'show_room_type' => 'yes',
'show_amenities' => 'yes',
'show_price_range' => 'yes',
'show_building' => 'yes',
'results_per_page' => 12,
),
$atts,
'bnb_room_search'
);
// Get search form data.
$form_data = Search::get_search_form_data();
$layout = sanitize_text_field( $atts['layout'] );
$columns = max( 1, min( 4, (int) $atts['columns'] ) );
ob_start();
?>
<div class="wp-bnb-room-search" data-layout="<?php echo esc_attr( $layout ); ?>" data-columns="<?php echo esc_attr( $columns ); ?>" data-per-page="<?php echo esc_attr( $atts['results_per_page'] ); ?>">
<form class="wp-bnb-search-form" id="wp-bnb-search-form">
<div class="wp-bnb-search-fields">
<?php if ( 'yes' === $atts['show_dates'] ) : ?>
<div class="wp-bnb-field wp-bnb-field-dates">
<div class="wp-bnb-field-group">
<label for="wp-bnb-check-in"><?php esc_html_e( 'Check-in', 'wp-bnb' ); ?></label>
<input type="date" id="wp-bnb-check-in" name="check_in" min="<?php echo esc_attr( gmdate( 'Y-m-d' ) ); ?>">
</div>
<div class="wp-bnb-field-group">
<label for="wp-bnb-check-out"><?php esc_html_e( 'Check-out', 'wp-bnb' ); ?></label>
<input type="date" id="wp-bnb-check-out" name="check_out" min="<?php echo esc_attr( gmdate( 'Y-m-d', strtotime( '+1 day' ) ) ); ?>">
</div>
</div>
<?php endif; ?>
<?php if ( 'yes' === $atts['show_guests'] ) : ?>
<div class="wp-bnb-field wp-bnb-field-guests">
<label for="wp-bnb-guests"><?php esc_html_e( 'Guests', 'wp-bnb' ); ?></label>
<select id="wp-bnb-guests" name="guests">
<option value=""><?php esc_html_e( 'Any', 'wp-bnb' ); ?></option>
<?php for ( $i = 1; $i <= $form_data['capacity_range']['max']; $i++ ) : ?>
<option value="<?php echo esc_attr( $i ); ?>">
<?php echo esc_html( $i ); ?>
</option>
<?php endfor; ?>
</select>
</div>
<?php endif; ?>
<?php if ( 'yes' === $atts['show_room_type'] && ! empty( $form_data['room_types'] ) ) : ?>
<div class="wp-bnb-field wp-bnb-field-room-type">
<label for="wp-bnb-room-type"><?php esc_html_e( 'Room Type', 'wp-bnb' ); ?></label>
<select id="wp-bnb-room-type" name="room_type">
<option value=""><?php esc_html_e( 'All Types', 'wp-bnb' ); ?></option>
<?php foreach ( $form_data['room_types'] as $type ) : ?>
<option value="<?php echo esc_attr( $type['slug'] ); ?>">
<?php echo esc_html( $type['name'] ); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<?php if ( 'yes' === $atts['show_building'] && ! empty( $form_data['buildings'] ) ) : ?>
<div class="wp-bnb-field wp-bnb-field-building">
<label for="wp-bnb-building"><?php esc_html_e( 'Building', 'wp-bnb' ); ?></label>
<select id="wp-bnb-building" name="building_id">
<option value=""><?php esc_html_e( 'All Buildings', 'wp-bnb' ); ?></option>
<?php foreach ( $form_data['buildings'] as $building ) : ?>
<option value="<?php echo esc_attr( $building['id'] ); ?>">
<?php echo esc_html( $building['title'] ); ?>
<?php if ( ! empty( $building['city'] ) ) : ?>
(<?php echo esc_html( $building['city'] ); ?>)
<?php endif; ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<?php if ( 'yes' === $atts['show_price_range'] && $form_data['price_range']['max'] > 0 ) : ?>
<div class="wp-bnb-field wp-bnb-field-price-range">
<label><?php esc_html_e( 'Price Range', 'wp-bnb' ); ?></label>
<div class="wp-bnb-price-range-inputs">
<input type="number" id="wp-bnb-price-min" name="price_min" placeholder="<?php esc_attr_e( 'Min', 'wp-bnb' ); ?>" min="0" step="10">
<span class="wp-bnb-price-separator">-</span>
<input type="number" id="wp-bnb-price-max" name="price_max" placeholder="<?php esc_attr_e( 'Max', 'wp-bnb' ); ?>" min="0" step="10">
<span class="wp-bnb-currency"><?php echo esc_html( $form_data['currency'] ); ?></span>
</div>
</div>
<?php endif; ?>
</div>
<?php if ( 'yes' === $atts['show_amenities'] && ! empty( $form_data['amenities'] ) ) : ?>
<div class="wp-bnb-search-amenities">
<label><?php esc_html_e( 'Amenities', 'wp-bnb' ); ?></label>
<div class="wp-bnb-amenities-list">
<?php foreach ( $form_data['amenities'] as $amenity ) : ?>
<label class="wp-bnb-amenity-checkbox">
<input type="checkbox" name="amenities[]" value="<?php echo esc_attr( $amenity['slug'] ); ?>">
<?php if ( ! empty( $amenity['icon'] ) ) : ?>
<span class="dashicons dashicons-<?php echo esc_attr( $amenity['icon'] ); ?>"></span>
<?php endif; ?>
<span><?php echo esc_html( $amenity['name'] ); ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<div class="wp-bnb-search-actions">
<button type="submit" class="wp-bnb-button wp-bnb-button-primary">
<span class="dashicons dashicons-search"></span>
<?php esc_html_e( 'Search Rooms', 'wp-bnb' ); ?>
</button>
<button type="reset" class="wp-bnb-button wp-bnb-button-secondary">
<?php esc_html_e( 'Clear', 'wp-bnb' ); ?>
</button>
</div>
</form>
<div class="wp-bnb-search-results-container">
<div class="wp-bnb-search-status">
<span class="wp-bnb-results-count"></span>
<div class="wp-bnb-sort-options">
<label for="wp-bnb-sort"><?php esc_html_e( 'Sort by:', 'wp-bnb' ); ?></label>
<select id="wp-bnb-sort" name="orderby">
<option value="title"><?php esc_html_e( 'Name', 'wp-bnb' ); ?></option>
<option value="price"><?php esc_html_e( 'Price', 'wp-bnb' ); ?></option>
<option value="capacity"><?php esc_html_e( 'Capacity', 'wp-bnb' ); ?></option>
</select>
</div>
</div>
<div class="wp-bnb-search-results wp-bnb-rooms wp-bnb-rooms-<?php echo esc_attr( $layout ); ?> wp-bnb-columns-<?php echo esc_attr( $columns ); ?>">
<div class="wp-bnb-loading">
<span class="wp-bnb-spinner"></span>
<span><?php esc_html_e( 'Loading rooms...', 'wp-bnb' ); ?></span>
</div>
</div>
<div class="wp-bnb-search-pagination">
<button type="button" class="wp-bnb-button wp-bnb-load-more" style="display:none;">
<?php esc_html_e( 'Load More', 'wp-bnb' ); ?>
</button>
</div>
</div>
</div>
<?php
return ob_get_clean();
}
/**
* Render single building shortcode.
*
* @param array $atts Shortcode attributes.
* @return string HTML output.
*/
public static function render_single_building( $atts ): string {
$atts = shortcode_atts(
array(
'id' => 0,
'show_rooms' => 'yes',
'show_address' => 'yes',
'show_contact' => 'yes',
),
$atts,
'bnb_building'
);
$building_id = (int) $atts['id'];
if ( ! $building_id ) {
return '<p class="wp-bnb-error">' . esc_html__( 'Building ID is required.', 'wp-bnb' ) . '</p>';
}
$building = get_post( $building_id );
if ( ! $building || Building::POST_TYPE !== $building->post_type ) {
return '<p class="wp-bnb-error">' . esc_html__( 'Building not found.', 'wp-bnb' ) . '</p>';
}
$show_rooms = 'yes' === $atts['show_rooms'];
$show_address = 'yes' === $atts['show_address'];
$show_contact = 'yes' === $atts['show_contact'];
ob_start();
?>
<div class="wp-bnb-building-single">
<?php if ( has_post_thumbnail( $building->ID ) ) : ?>
<div class="wp-bnb-building-featured-image">
<?php echo get_the_post_thumbnail( $building->ID, 'large' ); ?>
</div>
<?php endif; ?>
<div class="wp-bnb-building-header">
<h2 class="wp-bnb-building-title"><?php echo esc_html( $building->post_title ); ?></h2>
</div>
<div class="wp-bnb-building-details">
<?php if ( $show_address ) : ?>
<?php $address = Building::get_formatted_address( $building->ID ); ?>
<?php if ( ! empty( $address ) ) : ?>
<div class="wp-bnb-building-address">
<h4><?php esc_html_e( 'Address', 'wp-bnb' ); ?></h4>
<address><?php echo nl2br( esc_html( $address ) ); ?></address>
</div>
<?php endif; ?>
<?php endif; ?>
<?php if ( $show_contact ) : ?>
<?php
$phone = get_post_meta( $building->ID, '_bnb_building_phone', true );
$email = get_post_meta( $building->ID, '_bnb_building_email', true );
$website = get_post_meta( $building->ID, '_bnb_building_website', true );
?>
<?php if ( $phone || $email || $website ) : ?>
<div class="wp-bnb-building-contact">
<h4><?php esc_html_e( 'Contact', 'wp-bnb' ); ?></h4>
<?php if ( $phone ) : ?>
<p class="wp-bnb-contact-phone">
<span class="dashicons dashicons-phone"></span>
<a href="tel:<?php echo esc_attr( $phone ); ?>"><?php echo esc_html( $phone ); ?></a>
</p>
<?php endif; ?>
<?php if ( $email ) : ?>
<p class="wp-bnb-contact-email">
<span class="dashicons dashicons-email"></span>
<a href="mailto:<?php echo esc_attr( $email ); ?>"><?php echo esc_html( $email ); ?></a>
</p>
<?php endif; ?>
<?php if ( $website ) : ?>
<p class="wp-bnb-contact-website">
<span class="dashicons dashicons-admin-site"></span>
<a href="<?php echo esc_url( $website ); ?>" target="_blank" rel="noopener"><?php echo esc_html( $website ); ?></a>
</p>
<?php endif; ?>
</div>
<?php endif; ?>
<?php endif; ?>
<?php
$check_in_time = get_post_meta( $building->ID, '_bnb_building_check_in_time', true );
$check_out_time = get_post_meta( $building->ID, '_bnb_building_check_out_time', true );
if ( $check_in_time || $check_out_time ) :
?>
<div class="wp-bnb-building-times">
<h4><?php esc_html_e( 'Check-in / Check-out', 'wp-bnb' ); ?></h4>
<?php if ( $check_in_time ) : ?>
<p><strong><?php esc_html_e( 'Check-in:', 'wp-bnb' ); ?></strong> <?php echo esc_html( $check_in_time ); ?></p>
<?php endif; ?>
<?php if ( $check_out_time ) : ?>
<p><strong><?php esc_html_e( 'Check-out:', 'wp-bnb' ); ?></strong> <?php echo esc_html( $check_out_time ); ?></p>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php if ( ! empty( $building->post_content ) ) : ?>
<div class="wp-bnb-building-description">
<?php echo wp_kses_post( apply_filters( 'the_content', $building->post_content ) ); ?>
</div>
<?php endif; ?>
<?php if ( $show_rooms ) : ?>
<?php $rooms = Room::get_rooms_for_building( $building->ID ); ?>
<?php if ( ! empty( $rooms ) ) : ?>
<div class="wp-bnb-building-rooms">
<h3><?php esc_html_e( 'Available Rooms', 'wp-bnb' ); ?></h3>
<?php
echo self::render_rooms(
array(
'building_id' => $building->ID,
'show_building' => 'no',
'limit' => -1,
)
);
?>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
<?php
return ob_get_clean();
}
/**
* Render single room shortcode.
*
* @param array $atts Shortcode attributes.
* @return string HTML output.
*/
public static function render_single_room( $atts ): string {
$atts = shortcode_atts(
array(
'id' => 0,
'show_gallery' => 'yes',
'show_pricing' => 'yes',
'show_amenities' => 'yes',
'show_availability' => 'yes',
),
$atts,
'bnb_room'
);
$room_id = (int) $atts['id'];
if ( ! $room_id ) {
return '<p class="wp-bnb-error">' . esc_html__( 'Room ID is required.', 'wp-bnb' ) . '</p>';
}
$room = get_post( $room_id );
if ( ! $room || Room::POST_TYPE !== $room->post_type ) {
return '<p class="wp-bnb-error">' . esc_html__( 'Room not found.', 'wp-bnb' ) . '</p>';
}
$show_gallery = 'yes' === $atts['show_gallery'];
$show_pricing = 'yes' === $atts['show_pricing'];
$show_amenities = 'yes' === $atts['show_amenities'];
$show_availability = 'yes' === $atts['show_availability'];
// Get room data.
$room_data = Search::get_room_data( $room );
ob_start();
?>
<div class="wp-bnb-room-single" data-room-id="<?php echo esc_attr( $room->ID ); ?>">
<?php if ( $show_gallery && ( has_post_thumbnail( $room->ID ) || ! empty( $room_data['gallery'] ) ) ) : ?>
<div class="wp-bnb-room-gallery">
<?php if ( has_post_thumbnail( $room->ID ) ) : ?>
<div class="wp-bnb-room-featured-image">
<?php echo get_the_post_thumbnail( $room->ID, 'large' ); ?>
</div>
<?php endif; ?>
<?php if ( ! empty( $room_data['gallery'] ) ) : ?>
<div class="wp-bnb-room-gallery-thumbnails">
<?php foreach ( $room_data['gallery'] as $image ) : ?>
<a href="<?php echo esc_url( $image['url'] ); ?>" class="wp-bnb-gallery-thumb" data-gallery>
<img src="<?php echo esc_url( $image['thumb'] ); ?>" alt="">
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="wp-bnb-room-header">
<div class="wp-bnb-room-header-content">
<h2 class="wp-bnb-room-title"><?php echo esc_html( $room->post_title ); ?></h2>
<?php if ( ! empty( $room_data['building'] ) ) : ?>
<p class="wp-bnb-room-building">
<span class="dashicons dashicons-building"></span>
<a href="<?php echo esc_url( $room_data['building']['permalink'] ); ?>">
<?php echo esc_html( $room_data['building']['title'] ); ?>
</a>
<?php if ( ! empty( $room_data['building']['city'] ) ) : ?>
<span>, <?php echo esc_html( $room_data['building']['city'] ); ?></span>
<?php endif; ?>
</p>
<?php endif; ?>
<?php if ( ! empty( $room_data['room_types'] ) ) : ?>
<span class="wp-bnb-room-type"><?php echo esc_html( implode( ', ', $room_data['room_types'] ) ); ?></span>
<?php endif; ?>
</div>
<?php if ( $show_pricing && ! empty( $room_data['price_formatted'] ) ) : ?>
<div class="wp-bnb-room-header-price">
<span class="wp-bnb-price-label"><?php esc_html_e( 'From', 'wp-bnb' ); ?></span>
<span class="wp-bnb-price-amount"><?php echo esc_html( $room_data['price_formatted'] ); ?></span>
<span class="wp-bnb-price-unit"><?php esc_html_e( '/night', 'wp-bnb' ); ?></span>
</div>
<?php endif; ?>
</div>
<div class="wp-bnb-room-info">
<div class="wp-bnb-room-specs">
<?php if ( ! empty( $room_data['capacity'] ) ) : ?>
<div class="wp-bnb-spec">
<span class="dashicons dashicons-groups"></span>
<span class="wp-bnb-spec-label"><?php esc_html_e( 'Capacity', 'wp-bnb' ); ?></span>
<span class="wp-bnb-spec-value">
<?php
printf(
/* translators: %d: Number of guests */
esc_html( _n( '%d guest', '%d guests', $room_data['capacity'], 'wp-bnb' ) ),
(int) $room_data['capacity']
);
?>
</span>
</div>
<?php endif; ?>
<?php if ( ! empty( $room_data['size'] ) ) : ?>
<div class="wp-bnb-spec">
<span class="dashicons dashicons-editor-expand"></span>
<span class="wp-bnb-spec-label"><?php esc_html_e( 'Size', 'wp-bnb' ); ?></span>
<span class="wp-bnb-spec-value"><?php echo esc_html( $room_data['size'] ); ?> m²</span>
</div>
<?php endif; ?>
<?php if ( ! empty( $room_data['beds'] ) ) : ?>
<div class="wp-bnb-spec">
<span class="dashicons dashicons-admin-home"></span>
<span class="wp-bnb-spec-label"><?php esc_html_e( 'Beds', 'wp-bnb' ); ?></span>
<span class="wp-bnb-spec-value"><?php echo esc_html( $room_data['beds'] ); ?></span>
</div>
<?php endif; ?>
<?php if ( ! empty( $room_data['bathrooms'] ) ) : ?>
<div class="wp-bnb-spec">
<span class="dashicons dashicons-admin-page"></span>
<span class="wp-bnb-spec-label"><?php esc_html_e( 'Bathrooms', 'wp-bnb' ); ?></span>
<span class="wp-bnb-spec-value"><?php echo esc_html( $room_data['bathrooms'] ); ?></span>
</div>
<?php endif; ?>
<?php if ( ! empty( $room_data['floor'] ) ) : ?>
<div class="wp-bnb-spec">
<span class="dashicons dashicons-building"></span>
<span class="wp-bnb-spec-label"><?php esc_html_e( 'Floor', 'wp-bnb' ); ?></span>
<span class="wp-bnb-spec-value"><?php echo esc_html( $room_data['floor'] ); ?></span>
</div>
<?php endif; ?>
</div>
<?php if ( $show_amenities && ! empty( $room_data['amenities'] ) ) : ?>
<div class="wp-bnb-room-amenities-full">
<h4><?php esc_html_e( 'Amenities', 'wp-bnb' ); ?></h4>
<ul class="wp-bnb-amenities-list">
<?php foreach ( $room_data['amenities'] as $amenity ) : ?>
<li class="wp-bnb-amenity">
<?php if ( ! empty( $amenity['icon'] ) ) : ?>
<span class="dashicons dashicons-<?php echo esc_attr( $amenity['icon'] ); ?>"></span>
<?php endif; ?>
<span><?php echo esc_html( $amenity['name'] ); ?></span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</div>
<?php if ( ! empty( $room->post_content ) ) : ?>
<div class="wp-bnb-room-description">
<?php echo wp_kses_post( apply_filters( 'the_content', $room->post_content ) ); ?>
</div>
<?php endif; ?>
<?php if ( $show_pricing ) : ?>
<?php $pricing = Calculator::getRoomPricing( $room->ID ); ?>
<div class="wp-bnb-room-pricing-details">
<h4><?php esc_html_e( 'Pricing', 'wp-bnb' ); ?></h4>
<table class="wp-bnb-pricing-table">
<tbody>
<?php foreach ( PricingTier::cases() as $tier ) : ?>
<?php $price = $pricing[ $tier->value ]['price'] ?? null; ?>
<?php if ( $price ) : ?>
<tr>
<td class="wp-bnb-tier-label"><?php echo esc_html( $tier->label() ); ?></td>
<td class="wp-bnb-tier-price">
<?php echo esc_html( Calculator::formatPrice( $price ) ); ?>
<span class="wp-bnb-tier-unit"><?php echo esc_html( $tier->unit() ); ?></span>
</td>
</tr>
<?php endif; ?>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php if ( $show_availability ) : ?>
<div class="wp-bnb-room-availability">
<h4><?php esc_html_e( 'Check Availability', 'wp-bnb' ); ?></h4>
<form class="wp-bnb-availability-form" data-room-id="<?php echo esc_attr( $room->ID ); ?>">
<div class="wp-bnb-availability-fields">
<div class="wp-bnb-field-group">
<label for="wp-bnb-avail-check-in"><?php esc_html_e( 'Check-in', 'wp-bnb' ); ?></label>
<input type="date" id="wp-bnb-avail-check-in" name="check_in" min="<?php echo esc_attr( gmdate( 'Y-m-d' ) ); ?>" required>
</div>
<div class="wp-bnb-field-group">
<label for="wp-bnb-avail-check-out"><?php esc_html_e( 'Check-out', 'wp-bnb' ); ?></label>
<input type="date" id="wp-bnb-avail-check-out" name="check_out" min="<?php echo esc_attr( gmdate( 'Y-m-d', strtotime( '+1 day' ) ) ); ?>" required>
</div>
<button type="submit" class="wp-bnb-button wp-bnb-button-primary">
<?php esc_html_e( 'Check', 'wp-bnb' ); ?>
</button>
</div>
<div class="wp-bnb-availability-result" style="display:none;"></div>
</form>
</div>
<?php endif; ?>
</div>
<?php
return ob_get_clean();
}
}

View File

@@ -0,0 +1,298 @@
<?php
/**
* Availability Calendar widget.
*
* Displays a mini calendar showing room availability.
*
* @package Magdev\WpBnb\Frontend\Widgets
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Frontend\Widgets;
use Magdev\WpBnb\Booking\Availability;
use Magdev\WpBnb\PostTypes\Room;
/**
* Availability Calendar widget class.
*/
class AvailabilityCalendar extends \WP_Widget {
/**
* Constructor.
*/
public function __construct() {
parent::__construct(
'wp_bnb_availability_calendar',
__( 'WP BnB: Availability Calendar', 'wp-bnb' ),
array(
'classname' => 'wp-bnb-widget-availability-calendar',
'description' => __( 'Display a room availability calendar.', 'wp-bnb' ),
)
);
}
/**
* Output the widget content.
*
* @param array $args Widget arguments.
* @param array $instance Widget instance settings.
* @return void
*/
public function widget( $args, $instance ): void {
$title = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Availability', 'wp-bnb' );
$room_id = ! empty( $instance['room_id'] ) ? (int) $instance['room_id'] : 0;
$months_to_show = ! empty( $instance['months'] ) ? (int) $instance['months'] : 1;
$show_legend = ! empty( $instance['show_legend'] );
$show_navigation = ! empty( $instance['show_navigation'] );
// Auto-detect room from single room page.
if ( ! $room_id && is_singular( Room::POST_TYPE ) ) {
$room_id = get_the_ID();
}
if ( ! $room_id ) {
return;
}
$room = get_post( $room_id );
if ( ! $room || Room::POST_TYPE !== $room->post_type ) {
return;
}
// Limit months to show.
$months_to_show = max( 1, min( 3, $months_to_show ) );
// Get current month or from request.
$year = (int) gmdate( 'Y' );
$month = (int) gmdate( 'n' );
echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
if ( $title ) {
echo $args['before_title'] . esc_html( apply_filters( 'widget_title', $title ) ) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
?>
<div class="wp-bnb-availability-calendar-widget" data-room-id="<?php echo esc_attr( $room_id ); ?>">
<?php for ( $i = 0; $i < $months_to_show; $i++ ) : ?>
<?php
$display_year = $year;
$display_month = $month + $i;
if ( $display_month > 12 ) {
$display_month -= 12;
$display_year++;
}
$calendar = Availability::get_calendar_data( $room_id, $display_year, $display_month );
?>
<div class="wp-bnb-calendar-month" data-year="<?php echo esc_attr( $display_year ); ?>" data-month="<?php echo esc_attr( $display_month ); ?>">
<div class="wp-bnb-calendar-header">
<?php if ( $show_navigation && 0 === $i ) : ?>
<button type="button" class="wp-bnb-calendar-nav wp-bnb-calendar-prev" data-direction="prev" aria-label="<?php esc_attr_e( 'Previous month', 'wp-bnb' ); ?>">
&lsaquo;
</button>
<?php endif; ?>
<span class="wp-bnb-calendar-month-name">
<?php echo esc_html( $calendar['month_name'] . ' ' . $display_year ); ?>
</span>
<?php if ( $show_navigation && $i === $months_to_show - 1 ) : ?>
<button type="button" class="wp-bnb-calendar-nav wp-bnb-calendar-next" data-direction="next" aria-label="<?php esc_attr_e( 'Next month', 'wp-bnb' ); ?>">
&rsaquo;
</button>
<?php endif; ?>
</div>
<table class="wp-bnb-calendar-grid">
<thead>
<tr>
<th><?php esc_html_e( 'Su', 'wp-bnb' ); ?></th>
<th><?php esc_html_e( 'Mo', 'wp-bnb' ); ?></th>
<th><?php esc_html_e( 'Tu', 'wp-bnb' ); ?></th>
<th><?php esc_html_e( 'We', 'wp-bnb' ); ?></th>
<th><?php esc_html_e( 'Th', 'wp-bnb' ); ?></th>
<th><?php esc_html_e( 'Fr', 'wp-bnb' ); ?></th>
<th><?php esc_html_e( 'Sa', 'wp-bnb' ); ?></th>
</tr>
</thead>
<tbody>
<?php
$day = 1;
$total_days = $calendar['days_in_month'];
$first_day = $calendar['first_day_of_week']; // 0 = Sunday.
// Calculate weeks.
$weeks = ceil( ( $first_day + $total_days ) / 7 );
for ( $week = 0; $week < $weeks; $week++ ) :
?>
<tr>
<?php for ( $dow = 0; $dow < 7; $dow++ ) : ?>
<?php
$cell_index = $week * 7 + $dow;
if ( $cell_index < $first_day || $day > $total_days ) {
echo '<td class="wp-bnb-calendar-empty"></td>';
} else {
$day_data = $calendar['days'][ $day ] ?? null;
$classes = array( 'wp-bnb-calendar-day' );
if ( $day_data ) {
if ( $day_data['is_booked'] ) {
$classes[] = 'wp-bnb-booked';
} else {
$classes[] = 'wp-bnb-available';
}
if ( $day_data['is_past'] ) {
$classes[] = 'wp-bnb-past';
}
if ( $day_data['is_today'] ) {
$classes[] = 'wp-bnb-today';
}
}
?>
<td class="<?php echo esc_attr( implode( ' ', $classes ) ); ?>" data-date="<?php echo esc_attr( $day_data['date'] ?? '' ); ?>">
<?php echo esc_html( $day ); ?>
</td>
<?php
$day++;
}
?>
<?php endfor; ?>
</tr>
<?php endfor; ?>
</tbody>
</table>
</div>
<?php endfor; ?>
<?php if ( $show_legend ) : ?>
<div class="wp-bnb-calendar-legend">
<span class="wp-bnb-legend-item wp-bnb-legend-available">
<span class="wp-bnb-legend-color"></span>
<?php esc_html_e( 'Available', 'wp-bnb' ); ?>
</span>
<span class="wp-bnb-legend-item wp-bnb-legend-booked">
<span class="wp-bnb-legend-color"></span>
<?php esc_html_e( 'Booked', 'wp-bnb' ); ?>
</span>
</div>
<?php endif; ?>
</div>
<?php
echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Output the widget settings form.
*
* @param array $instance Current widget instance settings.
* @return void
*/
public function form( $instance ): void {
$title = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Availability', 'wp-bnb' );
$room_id = ! empty( $instance['room_id'] ) ? (int) $instance['room_id'] : 0;
$months = ! empty( $instance['months'] ) ? (int) $instance['months'] : 1;
$show_legend = ! empty( $instance['show_legend'] );
$show_navigation = ! empty( $instance['show_navigation'] );
// Get all rooms.
$rooms = get_posts(
array(
'post_type' => Room::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'orderby' => 'title',
'order' => 'ASC',
)
);
?>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>">
<?php esc_html_e( 'Title:', 'wp-bnb' ); ?>
</label>
<input class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>"
type="text" value="<?php echo esc_attr( $title ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'room_id' ) ); ?>">
<?php esc_html_e( 'Room:', 'wp-bnb' ); ?>
</label>
<select class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'room_id' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'room_id' ) ); ?>">
<option value="0"><?php esc_html_e( '— Auto-detect from page —', 'wp-bnb' ); ?></option>
<?php foreach ( $rooms as $room ) : ?>
<?php
$building_id = get_post_meta( $room->ID, '_bnb_room_building_id', true );
$building = $building_id ? get_post( $building_id ) : null;
?>
<option value="<?php echo esc_attr( $room->ID ); ?>" <?php selected( $room_id, $room->ID ); ?>>
<?php echo esc_html( $room->post_title ); ?>
<?php if ( $building ) : ?>
(<?php echo esc_html( $building->post_title ); ?>)
<?php endif; ?>
</option>
<?php endforeach; ?>
</select>
<small><?php esc_html_e( 'Leave as auto-detect to show calendar of the current room page.', 'wp-bnb' ); ?></small>
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'months' ) ); ?>">
<?php esc_html_e( 'Months to show:', 'wp-bnb' ); ?>
</label>
<select class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'months' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'months' ) ); ?>">
<option value="1" <?php selected( $months, 1 ); ?>>1</option>
<option value="2" <?php selected( $months, 2 ); ?>>2</option>
<option value="3" <?php selected( $months, 3 ); ?>>3</option>
</select>
</p>
<p>
<input class="checkbox" type="checkbox" id="<?php echo esc_attr( $this->get_field_id( 'show_legend' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'show_legend' ) ); ?>"
<?php checked( $show_legend ); ?>>
<label for="<?php echo esc_attr( $this->get_field_id( 'show_legend' ) ); ?>">
<?php esc_html_e( 'Show legend', 'wp-bnb' ); ?>
</label>
</p>
<p>
<input class="checkbox" type="checkbox" id="<?php echo esc_attr( $this->get_field_id( 'show_navigation' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'show_navigation' ) ); ?>"
<?php checked( $show_navigation ); ?>>
<label for="<?php echo esc_attr( $this->get_field_id( 'show_navigation' ) ); ?>">
<?php esc_html_e( 'Allow navigation', 'wp-bnb' ); ?>
</label>
</p>
<?php
}
/**
* Update widget settings.
*
* @param array $new_instance New settings.
* @param array $old_instance Old settings.
* @return array Updated settings.
*/
public function update( $new_instance, $old_instance ): array {
$instance = array();
$instance['title'] = ! empty( $new_instance['title'] ) ? sanitize_text_field( $new_instance['title'] ) : '';
$instance['room_id'] = ! empty( $new_instance['room_id'] ) ? absint( $new_instance['room_id'] ) : 0;
$instance['months'] = ! empty( $new_instance['months'] ) ? absint( $new_instance['months'] ) : 1;
$instance['show_legend'] = ! empty( $new_instance['show_legend'] );
$instance['show_navigation'] = ! empty( $new_instance['show_navigation'] );
return $instance;
}
}

View File

@@ -0,0 +1,261 @@
<?php
/**
* Building Rooms widget.
*
* Displays all rooms in a specific building.
*
* @package Magdev\WpBnb\Frontend\Widgets
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Frontend\Widgets;
use Magdev\WpBnb\Frontend\Search;
use Magdev\WpBnb\PostTypes\Building;
use Magdev\WpBnb\PostTypes\Room;
/**
* Building Rooms widget class.
*/
class BuildingRooms extends \WP_Widget {
/**
* Constructor.
*/
public function __construct() {
parent::__construct(
'wp_bnb_building_rooms',
__( 'WP BnB: Building Rooms', 'wp-bnb' ),
array(
'classname' => 'wp-bnb-widget-building-rooms',
'description' => __( 'Display all rooms in a building.', 'wp-bnb' ),
)
);
}
/**
* Output the widget content.
*
* @param array $args Widget arguments.
* @param array $instance Widget instance settings.
* @return void
*/
public function widget( $args, $instance ): void {
$title = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Rooms', 'wp-bnb' );
$building_id = ! empty( $instance['building_id'] ) ? (int) $instance['building_id'] : 0;
$count = ! empty( $instance['count'] ) ? (int) $instance['count'] : -1;
$show_availability = ! empty( $instance['show_availability'] );
$show_price = ! empty( $instance['show_price'] );
$layout = ! empty( $instance['layout'] ) ? $instance['layout'] : 'list';
// Auto-detect building from single building page.
if ( ! $building_id && is_singular( Building::POST_TYPE ) ) {
$building_id = get_the_ID();
}
if ( ! $building_id ) {
return;
}
// Get rooms for building.
$search_args = array(
'building_id' => $building_id,
'limit' => $count,
'orderby' => 'title',
'order' => 'ASC',
);
$rooms = Search::search( $search_args );
if ( empty( $rooms ) ) {
return;
}
echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
if ( $title ) {
echo $args['before_title'] . esc_html( apply_filters( 'widget_title', $title ) ) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
$list_class = 'compact' === $layout ? 'wp-bnb-building-rooms-compact' : 'wp-bnb-building-rooms-list';
echo '<ul class="' . esc_attr( $list_class ) . '">';
foreach ( $rooms as $room ) {
$status = get_post_meta( $room['id'], '_bnb_room_status', true ) ?: 'available';
?>
<li class="wp-bnb-building-room">
<a href="<?php echo esc_url( $room['permalink'] ); ?>" class="wp-bnb-building-room-link">
<span class="wp-bnb-building-room-title"><?php echo esc_html( $room['title'] ); ?></span>
<?php if ( ! empty( $room['room_number'] ) ) : ?>
<span class="wp-bnb-building-room-number">#<?php echo esc_html( $room['room_number'] ); ?></span>
<?php endif; ?>
<?php if ( $show_availability ) : ?>
<span class="wp-bnb-building-room-status wp-bnb-status-<?php echo esc_attr( $status ); ?>">
<?php
$statuses = Room::get_room_statuses();
echo esc_html( $statuses[ $status ] ?? $status );
?>
</span>
<?php endif; ?>
<?php if ( $show_price && ! empty( $room['price_formatted'] ) ) : ?>
<span class="wp-bnb-building-room-price">
<?php echo esc_html( $room['price_formatted'] ); ?>
</span>
<?php endif; ?>
</a>
<?php if ( 'list' === $layout ) : ?>
<div class="wp-bnb-building-room-meta">
<?php if ( ! empty( $room['capacity'] ) ) : ?>
<span class="wp-bnb-meta-item">
<span class="dashicons dashicons-groups"></span>
<?php echo esc_html( $room['capacity'] ); ?>
</span>
<?php endif; ?>
<?php if ( ! empty( $room['room_types'] ) ) : ?>
<span class="wp-bnb-meta-item">
<?php echo esc_html( $room['room_types'][0] ); ?>
</span>
<?php endif; ?>
</div>
<?php endif; ?>
</li>
<?php
}
echo '</ul>';
// Show view all link if there are more rooms.
$building = get_post( $building_id );
if ( $building && $count > 0 && count( $rooms ) >= $count ) {
$all_rooms = Room::get_rooms_for_building( $building_id );
if ( count( $all_rooms ) > $count ) {
printf(
'<a href="%s" class="wp-bnb-view-all-rooms">%s</a>',
esc_url( get_permalink( $building_id ) ),
esc_html__( 'View all rooms', 'wp-bnb' )
);
}
}
echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Output the widget settings form.
*
* @param array $instance Current widget instance settings.
* @return void
*/
public function form( $instance ): void {
$title = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Rooms', 'wp-bnb' );
$building_id = ! empty( $instance['building_id'] ) ? (int) $instance['building_id'] : 0;
$count = ! empty( $instance['count'] ) ? (int) $instance['count'] : -1;
$show_availability = ! empty( $instance['show_availability'] );
$show_price = ! empty( $instance['show_price'] );
$layout = ! empty( $instance['layout'] ) ? $instance['layout'] : 'list';
// Get all buildings.
$buildings = get_posts(
array(
'post_type' => Building::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'orderby' => 'title',
'order' => 'ASC',
)
);
?>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>">
<?php esc_html_e( 'Title:', 'wp-bnb' ); ?>
</label>
<input class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>"
type="text" value="<?php echo esc_attr( $title ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'building_id' ) ); ?>">
<?php esc_html_e( 'Building:', 'wp-bnb' ); ?>
</label>
<select class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'building_id' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'building_id' ) ); ?>">
<option value="0"><?php esc_html_e( '— Auto-detect from page —', 'wp-bnb' ); ?></option>
<?php foreach ( $buildings as $building ) : ?>
<option value="<?php echo esc_attr( $building->ID ); ?>" <?php selected( $building_id, $building->ID ); ?>>
<?php echo esc_html( $building->post_title ); ?>
</option>
<?php endforeach; ?>
</select>
<small><?php esc_html_e( 'Leave as auto-detect to show rooms of the current building page.', 'wp-bnb' ); ?></small>
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'count' ) ); ?>">
<?php esc_html_e( 'Number of rooms:', 'wp-bnb' ); ?>
</label>
<input class="tiny-text" id="<?php echo esc_attr( $this->get_field_id( 'count' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'count' ) ); ?>"
type="number" min="-1" max="50" value="<?php echo esc_attr( $count ); ?>">
<small><?php esc_html_e( '-1 for all rooms', 'wp-bnb' ); ?></small>
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'layout' ) ); ?>">
<?php esc_html_e( 'Layout:', 'wp-bnb' ); ?>
</label>
<select class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'layout' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'layout' ) ); ?>">
<option value="list" <?php selected( $layout, 'list' ); ?>>
<?php esc_html_e( 'List (with details)', 'wp-bnb' ); ?>
</option>
<option value="compact" <?php selected( $layout, 'compact' ); ?>>
<?php esc_html_e( 'Compact', 'wp-bnb' ); ?>
</option>
</select>
</p>
<p>
<input class="checkbox" type="checkbox" id="<?php echo esc_attr( $this->get_field_id( 'show_availability' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'show_availability' ) ); ?>"
<?php checked( $show_availability ); ?>>
<label for="<?php echo esc_attr( $this->get_field_id( 'show_availability' ) ); ?>">
<?php esc_html_e( 'Show availability status', 'wp-bnb' ); ?>
</label>
</p>
<p>
<input class="checkbox" type="checkbox" id="<?php echo esc_attr( $this->get_field_id( 'show_price' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'show_price' ) ); ?>"
<?php checked( $show_price ); ?>>
<label for="<?php echo esc_attr( $this->get_field_id( 'show_price' ) ); ?>">
<?php esc_html_e( 'Show price', 'wp-bnb' ); ?>
</label>
</p>
<?php
}
/**
* Update widget settings.
*
* @param array $new_instance New settings.
* @param array $old_instance Old settings.
* @return array Updated settings.
*/
public function update( $new_instance, $old_instance ): array {
$instance = array();
$instance['title'] = ! empty( $new_instance['title'] ) ? sanitize_text_field( $new_instance['title'] ) : '';
$instance['building_id'] = ! empty( $new_instance['building_id'] ) ? absint( $new_instance['building_id'] ) : 0;
$instance['count'] = isset( $new_instance['count'] ) ? (int) $new_instance['count'] : -1;
$instance['show_availability'] = ! empty( $new_instance['show_availability'] );
$instance['show_price'] = ! empty( $new_instance['show_price'] );
$instance['layout'] = ! empty( $new_instance['layout'] ) ? sanitize_text_field( $new_instance['layout'] ) : 'list';
return $instance;
}
}

View File

@@ -0,0 +1,233 @@
<?php
/**
* Similar Rooms widget.
*
* Displays rooms similar to the current room based on building or room type.
*
* @package Magdev\WpBnb\Frontend\Widgets
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Frontend\Widgets;
use Magdev\WpBnb\Frontend\Search;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\Taxonomies\RoomType;
/**
* Similar Rooms widget class.
*/
class SimilarRooms extends \WP_Widget {
/**
* Constructor.
*/
public function __construct() {
parent::__construct(
'wp_bnb_similar_rooms',
__( 'WP BnB: Similar Rooms', 'wp-bnb' ),
array(
'classname' => 'wp-bnb-widget-similar-rooms',
'description' => __( 'Display rooms similar to the current room.', 'wp-bnb' ),
)
);
}
/**
* Output the widget content.
*
* @param array $args Widget arguments.
* @param array $instance Widget instance settings.
* @return void
*/
public function widget( $args, $instance ): void {
$title = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Similar Rooms', 'wp-bnb' );
$count = ! empty( $instance['count'] ) ? (int) $instance['count'] : 3;
$match_by = ! empty( $instance['match_by'] ) ? $instance['match_by'] : 'building';
$show_price = ! empty( $instance['show_price'] );
$show_image = ! empty( $instance['show_image'] );
// Get current room.
$current_room_id = 0;
if ( is_singular( Room::POST_TYPE ) ) {
$current_room_id = get_the_ID();
}
if ( ! $current_room_id ) {
return;
}
// Build query based on match type.
$search_args = array(
'limit' => $count + 1, // Get extra in case current room is included.
);
switch ( $match_by ) {
case 'building':
$building_id = get_post_meta( $current_room_id, '_bnb_room_building_id', true );
if ( $building_id ) {
$search_args['building_id'] = (int) $building_id;
}
break;
case 'room_type':
$terms = wp_get_post_terms( $current_room_id, RoomType::TAXONOMY, array( 'fields' => 'slugs' ) );
if ( ! empty( $terms ) ) {
$search_args['room_type'] = $terms[0];
}
break;
case 'amenities':
$amenities = wp_get_post_terms( $current_room_id, 'bnb_amenity', array( 'fields' => 'slugs' ) );
if ( ! empty( $amenities ) ) {
$search_args['amenities'] = array_slice( $amenities, 0, 3 );
}
break;
}
$rooms = Search::search( $search_args );
// Remove current room from results.
$rooms = array_filter(
$rooms,
function ( $room ) use ( $current_room_id ) {
return $room['id'] !== $current_room_id;
}
);
// Limit to requested count.
$rooms = array_slice( $rooms, 0, $count );
if ( empty( $rooms ) ) {
return;
}
echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
if ( $title ) {
echo $args['before_title'] . esc_html( apply_filters( 'widget_title', $title ) ) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
echo '<ul class="wp-bnb-similar-rooms-list">';
foreach ( $rooms as $room ) {
?>
<li class="wp-bnb-similar-room">
<?php if ( $show_image && ! empty( $room['thumbnail'] ) ) : ?>
<div class="wp-bnb-similar-room-image">
<a href="<?php echo esc_url( $room['permalink'] ); ?>">
<img src="<?php echo esc_url( $room['thumbnail'] ); ?>" alt="<?php echo esc_attr( $room['title'] ); ?>">
</a>
</div>
<?php endif; ?>
<div class="wp-bnb-similar-room-content">
<h4 class="wp-bnb-similar-room-title">
<a href="<?php echo esc_url( $room['permalink'] ); ?>">
<?php echo esc_html( $room['title'] ); ?>
</a>
</h4>
<?php if ( $show_price && ! empty( $room['price_formatted'] ) ) : ?>
<span class="wp-bnb-similar-room-price">
<?php echo esc_html( $room['price_formatted'] ); ?>
<span class="wp-bnb-price-unit"><?php esc_html_e( '/night', 'wp-bnb' ); ?></span>
</span>
<?php endif; ?>
</div>
</li>
<?php
}
echo '</ul>';
echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Output the widget settings form.
*
* @param array $instance Current widget instance settings.
* @return void
*/
public function form( $instance ): void {
$title = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Similar Rooms', 'wp-bnb' );
$count = ! empty( $instance['count'] ) ? (int) $instance['count'] : 3;
$match_by = ! empty( $instance['match_by'] ) ? $instance['match_by'] : 'building';
$show_price = ! empty( $instance['show_price'] );
$show_image = ! empty( $instance['show_image'] );
?>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>">
<?php esc_html_e( 'Title:', 'wp-bnb' ); ?>
</label>
<input class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>"
type="text" value="<?php echo esc_attr( $title ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'count' ) ); ?>">
<?php esc_html_e( 'Number of rooms:', 'wp-bnb' ); ?>
</label>
<input class="tiny-text" id="<?php echo esc_attr( $this->get_field_id( 'count' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'count' ) ); ?>"
type="number" min="1" max="10" value="<?php echo esc_attr( $count ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'match_by' ) ); ?>">
<?php esc_html_e( 'Match by:', 'wp-bnb' ); ?>
</label>
<select class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'match_by' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'match_by' ) ); ?>">
<option value="building" <?php selected( $match_by, 'building' ); ?>>
<?php esc_html_e( 'Same Building', 'wp-bnb' ); ?>
</option>
<option value="room_type" <?php selected( $match_by, 'room_type' ); ?>>
<?php esc_html_e( 'Same Room Type', 'wp-bnb' ); ?>
</option>
<option value="amenities" <?php selected( $match_by, 'amenities' ); ?>>
<?php esc_html_e( 'Similar Amenities', 'wp-bnb' ); ?>
</option>
</select>
</p>
<p>
<input class="checkbox" type="checkbox" id="<?php echo esc_attr( $this->get_field_id( 'show_image' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'show_image' ) ); ?>"
<?php checked( $show_image ); ?>>
<label for="<?php echo esc_attr( $this->get_field_id( 'show_image' ) ); ?>">
<?php esc_html_e( 'Show image', 'wp-bnb' ); ?>
</label>
</p>
<p>
<input class="checkbox" type="checkbox" id="<?php echo esc_attr( $this->get_field_id( 'show_price' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'show_price' ) ); ?>"
<?php checked( $show_price ); ?>>
<label for="<?php echo esc_attr( $this->get_field_id( 'show_price' ) ); ?>">
<?php esc_html_e( 'Show price', 'wp-bnb' ); ?>
</label>
</p>
<?php
}
/**
* Update widget settings.
*
* @param array $new_instance New settings.
* @param array $old_instance Old settings.
* @return array Updated settings.
*/
public function update( $new_instance, $old_instance ): array {
$instance = array();
$instance['title'] = ! empty( $new_instance['title'] ) ? sanitize_text_field( $new_instance['title'] ) : '';
$instance['count'] = ! empty( $new_instance['count'] ) ? absint( $new_instance['count'] ) : 3;
$instance['match_by'] = ! empty( $new_instance['match_by'] ) ? sanitize_text_field( $new_instance['match_by'] ) : 'building';
$instance['show_price'] = ! empty( $new_instance['show_price'] );
$instance['show_image'] = ! empty( $new_instance['show_image'] );
return $instance;
}
}

1654
src/Integration/CF7.php Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,877 @@
<?php
/**
* Prometheus Metrics Integration.
*
* Provides meaningful metrics for monitoring BnB operations.
*
* @package Magdev\WpBnb\Integration
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Integration;
use Magdev\WpBnb\PostTypes\Booking;
use Magdev\WpBnb\PostTypes\Building;
use Magdev\WpBnb\PostTypes\Guest;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\PostTypes\Service;
/**
* Prometheus Metrics Integration class.
*
* Exposes BnB metrics via the wp-prometheus plugin.
*/
class Prometheus {
/**
* Option key for enabling metrics.
*/
public const OPTION_ENABLED = 'wp_bnb_metrics_enabled';
/**
* Initialize the Prometheus integration.
*
* @return void
*/
public static function init(): void {
// Only hook if metrics are enabled.
if ( ! self::is_enabled() ) {
return;
}
// Hook into wp-prometheus collector.
add_action( 'wp_prometheus_collect_metrics', array( self::class, 'collect_metrics' ) );
// Register Grafana dashboard.
add_action( 'wp_prometheus_register_dashboards', array( self::class, 'register_dashboards' ) );
}
/**
* Check if metrics collection is enabled.
*
* @return bool
*/
public static function is_enabled(): bool {
return 'yes' === get_option( self::OPTION_ENABLED, 'yes' );
}
/**
* Enable metrics collection.
*
* @return void
*/
public static function enable(): void {
update_option( self::OPTION_ENABLED, 'yes' );
}
/**
* Disable metrics collection.
*
* @return void
*/
public static function disable(): void {
update_option( self::OPTION_ENABLED, 'no' );
}
/**
* Collect and register all BnB metrics.
*
* @param object $collector The wp-prometheus collector instance.
* @return void
*/
public static function collect_metrics( $collector ): void {
self::collect_inventory_metrics( $collector );
self::collect_booking_metrics( $collector );
self::collect_guest_metrics( $collector );
self::collect_occupancy_metrics( $collector );
self::collect_revenue_metrics( $collector );
}
/**
* Collect inventory metrics (buildings, rooms, services).
*
* @param object $collector The wp-prometheus collector instance.
* @return void
*/
private static function collect_inventory_metrics( $collector ): void {
// Buildings total.
$buildings_total = wp_count_posts( Building::POST_TYPE );
$gauge = $collector->register_gauge(
'wp_bnb_buildings_total',
'Total number of buildings',
array()
);
$gauge->set( (int) $buildings_total->publish, array() );
// Rooms by status.
$rooms_gauge = $collector->register_gauge(
'wp_bnb_rooms_total',
'Total number of rooms by status',
array( 'status' )
);
$room_statuses = array( 'available', 'occupied', 'maintenance', 'inactive' );
foreach ( $room_statuses as $status ) {
$count = self::count_rooms_by_status( $status );
$rooms_gauge->set( $count, array( $status ) );
}
// Services by status.
$services_gauge = $collector->register_gauge(
'wp_bnb_services_total',
'Total number of services by status',
array( 'status' )
);
$service_statuses = array( 'active', 'inactive' );
foreach ( $service_statuses as $status ) {
$count = self::count_services_by_status( $status );
$services_gauge->set( $count, array( $status ) );
}
}
/**
* Collect booking metrics.
*
* @param object $collector The wp-prometheus collector instance.
* @return void
*/
private static function collect_booking_metrics( $collector ): void {
// Bookings by status.
$bookings_gauge = $collector->register_gauge(
'wp_bnb_bookings_total',
'Total number of bookings by status',
array( 'status' )
);
$booking_statuses = array( 'pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled' );
foreach ( $booking_statuses as $status ) {
$count = self::count_bookings_by_status( $status );
$bookings_gauge->set( $count, array( $status ) );
}
// Today's check-ins.
$checkins_gauge = $collector->register_gauge(
'wp_bnb_checkins_today',
'Number of check-ins scheduled for today',
array()
);
$checkins_gauge->set( self::count_todays_checkins(), array() );
// Today's check-outs.
$checkouts_gauge = $collector->register_gauge(
'wp_bnb_checkouts_today',
'Number of check-outs scheduled for today',
array()
);
$checkouts_gauge->set( self::count_todays_checkouts(), array() );
// Upcoming bookings (next 7 days).
$upcoming_gauge = $collector->register_gauge(
'wp_bnb_bookings_upcoming_7days',
'Number of bookings starting in the next 7 days',
array()
);
$upcoming_gauge->set( self::count_upcoming_bookings( 7 ), array() );
// Average booking duration (nights).
$avg_duration = $collector->register_gauge(
'wp_bnb_booking_avg_duration_nights',
'Average booking duration in nights',
array()
);
$avg_duration->set( self::get_average_booking_duration(), array() );
}
/**
* Collect guest metrics.
*
* @param object $collector The wp-prometheus collector instance.
* @return void
*/
private static function collect_guest_metrics( $collector ): void {
// Total guests.
$guests_total = wp_count_posts( Guest::POST_TYPE );
$guests_gauge = $collector->register_gauge(
'wp_bnb_guests_total',
'Total number of registered guests',
array()
);
$guests_gauge->set( (int) $guests_total->publish, array() );
// Guests by status.
$guests_status_gauge = $collector->register_gauge(
'wp_bnb_guests_by_status',
'Number of guests by status',
array( 'status' )
);
$guest_statuses = array( 'active', 'blocked', 'vip' );
foreach ( $guest_statuses as $status ) {
$count = self::count_guests_by_status( $status );
$guests_status_gauge->set( $count, array( $status ) );
}
// Repeat guests (guests with more than one booking).
$repeat_gauge = $collector->register_gauge(
'wp_bnb_guests_repeat',
'Number of guests with more than one booking',
array()
);
$repeat_gauge->set( self::count_repeat_guests(), array() );
// New guests this month.
$new_guests_gauge = $collector->register_gauge(
'wp_bnb_guests_new_this_month',
'Number of new guests registered this month',
array()
);
$new_guests_gauge->set( self::count_new_guests_this_month(), array() );
}
/**
* Collect occupancy metrics.
*
* @param object $collector The wp-prometheus collector instance.
* @return void
*/
private static function collect_occupancy_metrics( $collector ): void {
// Current occupancy rate (percentage).
$occupancy_gauge = $collector->register_gauge(
'wp_bnb_occupancy_rate_current',
'Current room occupancy rate (percentage)',
array()
);
$occupancy_gauge->set( self::get_current_occupancy_rate(), array() );
// Occupancy rate this month.
$occupancy_month_gauge = $collector->register_gauge(
'wp_bnb_occupancy_rate_this_month',
'Room occupancy rate for the current month (percentage)',
array()
);
$occupancy_month_gauge->set( self::get_monthly_occupancy_rate(), array() );
// Rooms currently occupied.
$occupied_gauge = $collector->register_gauge(
'wp_bnb_rooms_currently_occupied',
'Number of rooms currently occupied',
array()
);
$occupied_gauge->set( self::count_currently_occupied_rooms(), array() );
// Total room capacity (beds).
$capacity_gauge = $collector->register_gauge(
'wp_bnb_total_capacity_beds',
'Total bed capacity across all rooms',
array()
);
$capacity_gauge->set( self::get_total_bed_capacity(), array() );
}
/**
* Collect revenue metrics.
*
* @param object $collector The wp-prometheus collector instance.
* @return void
*/
private static function collect_revenue_metrics( $collector ): void {
$currency = get_option( 'wp_bnb_currency', 'CHF' );
// Revenue this month.
$revenue_month_gauge = $collector->register_gauge(
'wp_bnb_revenue_this_month',
'Total revenue for the current month',
array( 'currency' )
);
$revenue_month_gauge->set( self::get_revenue_this_month(), array( $currency ) );
// Revenue year to date.
$revenue_ytd_gauge = $collector->register_gauge(
'wp_bnb_revenue_ytd',
'Total revenue year to date',
array( 'currency' )
);
$revenue_ytd_gauge->set( self::get_revenue_ytd(), array( $currency ) );
// Average booking value.
$avg_value_gauge = $collector->register_gauge(
'wp_bnb_booking_avg_value',
'Average booking value',
array( 'currency' )
);
$avg_value_gauge->set( self::get_average_booking_value(), array( $currency ) );
// Revenue from services this month.
$services_revenue_gauge = $collector->register_gauge(
'wp_bnb_services_revenue_this_month',
'Revenue from additional services this month',
array( 'currency' )
);
$services_revenue_gauge->set( self::get_services_revenue_this_month(), array( $currency ) );
}
/**
* Register Grafana dashboards.
*
* @param object $provider The wp-prometheus dashboard provider instance.
* @return void
*/
public static function register_dashboards( $provider ): void {
$dashboard_file = WP_BNB_PATH . 'assets/grafana/wp-bnb-dashboard.json';
if ( file_exists( $dashboard_file ) ) {
$provider->register_dashboard(
'wp-bnb',
array(
'title' => __( 'WP BnB Dashboard', 'wp-bnb' ),
'description' => __( 'Monitor occupancy, bookings, revenue, and guest statistics for your B&B.', 'wp-bnb' ),
'icon' => 'dashicons-building',
'file' => $dashboard_file,
'plugin' => 'WP BnB Manager',
)
);
}
}
// =========================================================================
// Helper Methods - Inventory
// =========================================================================
/**
* Count rooms by status.
*
* @param string $status Room status.
* @return int
*/
private static function count_rooms_by_status( string $status ): int {
global $wpdb;
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID)
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm.meta_key = '_bnb_room_status'
AND pm.meta_value = %s",
Room::POST_TYPE,
$status
)
);
}
/**
* Count services by status.
*
* @param string $status Service status.
* @return int
*/
private static function count_services_by_status( string $status ): int {
global $wpdb;
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID)
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm.meta_key = '_bnb_service_status'
AND pm.meta_value = %s",
Service::POST_TYPE,
$status
)
);
}
// =========================================================================
// Helper Methods - Bookings
// =========================================================================
/**
* Count bookings by status.
*
* @param string $status Booking status.
* @return int
*/
private static function count_bookings_by_status( string $status ): int {
global $wpdb;
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID)
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm.meta_key = '_bnb_booking_status'
AND pm.meta_value = %s",
Booking::POST_TYPE,
$status
)
);
}
/**
* Count today's check-ins.
*
* @return int
*/
private static function count_todays_checkins(): int {
global $wpdb;
$today = gmdate( 'Y-m-d' );
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID)
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm_date ON p.ID = pm_date.post_id
INNER JOIN {$wpdb->postmeta} pm_status ON p.ID = pm_status.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm_date.meta_key = '_bnb_booking_check_in'
AND pm_date.meta_value = %s
AND pm_status.meta_key = '_bnb_booking_status'
AND pm_status.meta_value IN ('confirmed', 'pending')",
Booking::POST_TYPE,
$today
)
);
}
/**
* Count today's check-outs.
*
* @return int
*/
private static function count_todays_checkouts(): int {
global $wpdb;
$today = gmdate( 'Y-m-d' );
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID)
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm_date ON p.ID = pm_date.post_id
INNER JOIN {$wpdb->postmeta} pm_status ON p.ID = pm_status.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm_date.meta_key = '_bnb_booking_check_out'
AND pm_date.meta_value = %s
AND pm_status.meta_key = '_bnb_booking_status'
AND pm_status.meta_value = 'checked_in'",
Booking::POST_TYPE,
$today
)
);
}
/**
* Count upcoming bookings within given days.
*
* @param int $days Number of days to look ahead.
* @return int
*/
private static function count_upcoming_bookings( int $days ): int {
global $wpdb;
$today = gmdate( 'Y-m-d' );
$end_date = gmdate( 'Y-m-d', strtotime( "+{$days} days" ) );
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID)
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm_date ON p.ID = pm_date.post_id
INNER JOIN {$wpdb->postmeta} pm_status ON p.ID = pm_status.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm_date.meta_key = '_bnb_booking_check_in'
AND pm_date.meta_value >= %s
AND pm_date.meta_value <= %s
AND pm_status.meta_key = '_bnb_booking_status'
AND pm_status.meta_value IN ('confirmed', 'pending')",
Booking::POST_TYPE,
$today,
$end_date
)
);
}
/**
* Get average booking duration in nights.
*
* @return float
*/
private static function get_average_booking_duration(): float {
global $wpdb;
$result = $wpdb->get_var(
$wpdb->prepare(
"SELECT AVG(DATEDIFF(pm_out.meta_value, pm_in.meta_value))
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm_in ON p.ID = pm_in.post_id
INNER JOIN {$wpdb->postmeta} pm_out ON p.ID = pm_out.post_id
INNER JOIN {$wpdb->postmeta} pm_status ON p.ID = pm_status.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm_in.meta_key = '_bnb_booking_check_in'
AND pm_out.meta_key = '_bnb_booking_check_out'
AND pm_status.meta_key = '_bnb_booking_status'
AND pm_status.meta_value NOT IN ('cancelled')",
Booking::POST_TYPE
)
);
return round( (float) $result, 1 );
}
// =========================================================================
// Helper Methods - Guests
// =========================================================================
/**
* Count guests by status.
*
* @param string $status Guest status.
* @return int
*/
private static function count_guests_by_status( string $status ): int {
global $wpdb;
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID)
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm.meta_key = '_bnb_guest_status'
AND pm.meta_value = %s",
Guest::POST_TYPE,
$status
)
);
}
/**
* Count repeat guests (more than one booking).
*
* @return int
*/
private static function count_repeat_guests(): int {
global $wpdb;
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT pm.meta_value)
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm.meta_key = '_bnb_booking_guest_id'
AND pm.meta_value != ''
GROUP BY pm.meta_value
HAVING COUNT(*) > 1",
Booking::POST_TYPE
)
);
}
/**
* Count new guests registered this month.
*
* @return int
*/
private static function count_new_guests_this_month(): int {
global $wpdb;
$first_of_month = gmdate( 'Y-m-01 00:00:00' );
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*)
FROM {$wpdb->posts}
WHERE post_type = %s
AND post_status = 'publish'
AND post_date >= %s",
Guest::POST_TYPE,
$first_of_month
)
);
}
// =========================================================================
// Helper Methods - Occupancy
// =========================================================================
/**
* Get current occupancy rate (percentage of rooms occupied today).
*
* @return float
*/
private static function get_current_occupancy_rate(): float {
$total_rooms = self::count_available_rooms();
if ( $total_rooms <= 0 ) {
return 0.0;
}
$occupied = self::count_currently_occupied_rooms();
return round( ( $occupied / $total_rooms ) * 100, 1 );
}
/**
* Get monthly occupancy rate.
*
* @return float
*/
private static function get_monthly_occupancy_rate(): float {
global $wpdb;
$total_rooms = self::count_available_rooms();
if ( $total_rooms <= 0 ) {
return 0.0;
}
$first_of_month = gmdate( 'Y-m-01' );
$today = gmdate( 'Y-m-d' );
$days_so_far = (int) gmdate( 'd' );
$total_room_nights = $total_rooms * $days_so_far;
// Count booked nights this month (simplified: count bookings that overlap with this month).
$booked_nights = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT SUM(
DATEDIFF(
LEAST(pm_out.meta_value, %s),
GREATEST(pm_in.meta_value, %s)
)
)
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm_in ON p.ID = pm_in.post_id
INNER JOIN {$wpdb->postmeta} pm_out ON p.ID = pm_out.post_id
INNER JOIN {$wpdb->postmeta} pm_status ON p.ID = pm_status.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm_in.meta_key = '_bnb_booking_check_in'
AND pm_out.meta_key = '_bnb_booking_check_out'
AND pm_status.meta_key = '_bnb_booking_status'
AND pm_status.meta_value IN ('confirmed', 'checked_in', 'checked_out')
AND pm_in.meta_value <= %s
AND pm_out.meta_value >= %s",
$today,
$first_of_month,
Booking::POST_TYPE,
$today,
$first_of_month
)
);
if ( $total_room_nights <= 0 ) {
return 0.0;
}
return round( ( $booked_nights / $total_room_nights ) * 100, 1 );
}
/**
* Count currently occupied rooms.
*
* @return int
*/
private static function count_currently_occupied_rooms(): int {
global $wpdb;
$today = gmdate( 'Y-m-d' );
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT pm_room.meta_value)
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm_room ON p.ID = pm_room.post_id
INNER JOIN {$wpdb->postmeta} pm_in ON p.ID = pm_in.post_id
INNER JOIN {$wpdb->postmeta} pm_out ON p.ID = pm_out.post_id
INNER JOIN {$wpdb->postmeta} pm_status ON p.ID = pm_status.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm_room.meta_key = '_bnb_booking_room_id'
AND pm_in.meta_key = '_bnb_booking_check_in'
AND pm_out.meta_key = '_bnb_booking_check_out'
AND pm_status.meta_key = '_bnb_booking_status'
AND pm_status.meta_value IN ('confirmed', 'checked_in')
AND pm_in.meta_value <= %s
AND pm_out.meta_value > %s",
Booking::POST_TYPE,
$today,
$today
)
);
}
/**
* Count available rooms (not in maintenance/inactive).
*
* @return int
*/
private static function count_available_rooms(): int {
global $wpdb;
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID)
FROM {$wpdb->posts} p
LEFT JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id AND pm.meta_key = '_bnb_room_status'
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND (pm.meta_value IS NULL OR pm.meta_value IN ('available', 'occupied'))",
Room::POST_TYPE
)
);
}
/**
* Get total bed capacity.
*
* @return int
*/
private static function get_total_bed_capacity(): int {
global $wpdb;
$result = $wpdb->get_var(
$wpdb->prepare(
"SELECT SUM(CAST(pm.meta_value AS UNSIGNED))
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm.meta_key = '_bnb_room_beds'",
Room::POST_TYPE
)
);
return (int) $result;
}
// =========================================================================
// Helper Methods - Revenue
// =========================================================================
/**
* Get revenue for the current month.
*
* @return float
*/
private static function get_revenue_this_month(): float {
global $wpdb;
$first_of_month = gmdate( 'Y-m-01' );
$result = $wpdb->get_var(
$wpdb->prepare(
"SELECT SUM(CAST(pm_price.meta_value AS DECIMAL(10,2)))
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm_price ON p.ID = pm_price.post_id
INNER JOIN {$wpdb->postmeta} pm_in ON p.ID = pm_in.post_id
INNER JOIN {$wpdb->postmeta} pm_status ON p.ID = pm_status.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm_price.meta_key = '_bnb_booking_total_price'
AND pm_in.meta_key = '_bnb_booking_check_in'
AND pm_in.meta_value >= %s
AND pm_status.meta_key = '_bnb_booking_status'
AND pm_status.meta_value IN ('confirmed', 'checked_in', 'checked_out')",
Booking::POST_TYPE,
$first_of_month
)
);
return round( (float) $result, 2 );
}
/**
* Get revenue year to date.
*
* @return float
*/
private static function get_revenue_ytd(): float {
global $wpdb;
$first_of_year = gmdate( 'Y-01-01' );
$result = $wpdb->get_var(
$wpdb->prepare(
"SELECT SUM(CAST(pm_price.meta_value AS DECIMAL(10,2)))
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm_price ON p.ID = pm_price.post_id
INNER JOIN {$wpdb->postmeta} pm_in ON p.ID = pm_in.post_id
INNER JOIN {$wpdb->postmeta} pm_status ON p.ID = pm_status.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm_price.meta_key = '_bnb_booking_total_price'
AND pm_in.meta_key = '_bnb_booking_check_in'
AND pm_in.meta_value >= %s
AND pm_status.meta_key = '_bnb_booking_status'
AND pm_status.meta_value IN ('confirmed', 'checked_in', 'checked_out')",
Booking::POST_TYPE,
$first_of_year
)
);
return round( (float) $result, 2 );
}
/**
* Get average booking value.
*
* @return float
*/
private static function get_average_booking_value(): float {
global $wpdb;
$result = $wpdb->get_var(
$wpdb->prepare(
"SELECT AVG(CAST(pm_price.meta_value AS DECIMAL(10,2)))
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm_price ON p.ID = pm_price.post_id
INNER JOIN {$wpdb->postmeta} pm_status ON p.ID = pm_status.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm_price.meta_key = '_bnb_booking_total_price'
AND pm_status.meta_key = '_bnb_booking_status'
AND pm_status.meta_value IN ('confirmed', 'checked_in', 'checked_out')",
Booking::POST_TYPE
)
);
return round( (float) $result, 2 );
}
/**
* Get revenue from services this month.
*
* @return float
*/
private static function get_services_revenue_this_month(): float {
global $wpdb;
$first_of_month = gmdate( 'Y-m-01' );
$result = $wpdb->get_var(
$wpdb->prepare(
"SELECT SUM(CAST(pm_services.meta_value AS DECIMAL(10,2)))
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm_services ON p.ID = pm_services.post_id
INNER JOIN {$wpdb->postmeta} pm_in ON p.ID = pm_in.post_id
INNER JOIN {$wpdb->postmeta} pm_status ON p.ID = pm_status.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm_services.meta_key = '_bnb_booking_services_total'
AND pm_in.meta_key = '_bnb_booking_check_in'
AND pm_in.meta_value >= %s
AND pm_status.meta_key = '_bnb_booking_status'
AND pm_status.meta_value IN ('confirmed', 'checked_in', 'checked_out')",
Booking::POST_TYPE,
$first_of_month
)
);
return round( (float) $result, 2 );
}
}

View File

@@ -0,0 +1,282 @@
<?php
/**
* WooCommerce Admin Columns.
*
* Adds cross-reference columns and links between bookings and orders.
*
* @package Magdev\WpBnb\Integration\WooCommerce
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Integration\WooCommerce;
use Magdev\WpBnb\PostTypes\Booking;
/**
* Admin Columns class.
*
* Enhances admin list tables with booking-order cross-references.
*/
final class AdminColumns {
/**
* Initialize admin columns.
*
* @return void
*/
public static function init(): void {
// Add WC Order column to bookings list.
add_filter( 'manage_' . Booking::POST_TYPE . '_posts_columns', array( self::class, 'add_booking_columns' ) );
add_action( 'manage_' . Booking::POST_TYPE . '_posts_custom_column', array( self::class, 'render_booking_column' ), 10, 2 );
// Add Booking column to WC orders list.
add_filter( 'manage_edit-shop_order_columns', array( self::class, 'add_order_columns' ) );
add_action( 'manage_shop_order_posts_custom_column', array( self::class, 'render_order_column' ), 10, 2 );
// Also support HPOS orders list.
add_filter( 'manage_woocommerce_page_wc-orders_columns', array( self::class, 'add_order_columns' ) );
add_action( 'manage_woocommerce_page_wc-orders_custom_column', array( self::class, 'render_order_column_hpos' ), 10, 2 );
// Add row actions.
add_filter( 'post_row_actions', array( self::class, 'add_booking_row_actions' ), 10, 2 );
// Add order row actions.
add_filter( 'woocommerce_admin_order_actions', array( self::class, 'add_order_actions' ), 10, 2 );
}
/**
* Add columns to booking admin list.
*
* @param array $columns Existing columns.
* @return array Modified columns.
*/
public static function add_booking_columns( array $columns ): array {
// Insert WC Order column after status.
$new_columns = array();
foreach ( $columns as $key => $value ) {
$new_columns[ $key ] = $value;
if ( 'status' === $key ) {
$new_columns['wc_order'] = __( 'WC Order', 'wp-bnb' );
}
}
// If status column doesn't exist, add at the end.
if ( ! isset( $new_columns['wc_order'] ) ) {
$new_columns['wc_order'] = __( 'WC Order', 'wp-bnb' );
}
return $new_columns;
}
/**
* Render booking admin column.
*
* @param string $column Column name.
* @param int $post_id Post ID.
* @return void
*/
public static function render_booking_column( string $column, int $post_id ): void {
if ( 'wc_order' !== $column ) {
return;
}
$order = Manager::get_order_for_booking( $post_id );
if ( ! $order ) {
echo '<span class="na">&ndash;</span>';
return;
}
$order_number = $order->get_order_number();
$order_status = $order->get_status();
$edit_url = $order->get_edit_order_url();
printf(
'<a href="%s" class="order-view" title="%s">#%s</a>',
esc_url( $edit_url ),
esc_attr__( 'View order', 'wp-bnb' ),
esc_html( $order_number )
);
// Status badge.
$status_name = wc_get_order_status_name( $order_status );
printf(
'<br><mark class="order-status status-%s"><span>%s</span></mark>',
esc_attr( $order_status ),
esc_html( $status_name )
);
}
/**
* Add columns to WooCommerce orders list.
*
* @param array $columns Existing columns.
* @return array Modified columns.
*/
public static function add_order_columns( array $columns ): array {
// Insert Booking column after order_status.
$new_columns = array();
foreach ( $columns as $key => $value ) {
$new_columns[ $key ] = $value;
if ( 'order_status' === $key ) {
$new_columns['bnb_booking'] = __( 'Booking', 'wp-bnb' );
}
}
// If status column doesn't exist, add before actions.
if ( ! isset( $new_columns['bnb_booking'] ) ) {
$columns_before = array_slice( $columns, 0, -1, true );
$columns_after = array_slice( $columns, -1, null, true );
$new_columns = $columns_before + array( 'bnb_booking' => __( 'Booking', 'wp-bnb' ) ) + $columns_after;
}
return $new_columns;
}
/**
* Render order admin column (legacy post-based orders).
*
* @param string $column Column name.
* @param int $post_id Post ID (Order ID).
* @return void
*/
public static function render_order_column( string $column, int $post_id ): void {
if ( 'bnb_booking' !== $column ) {
return;
}
$order = wc_get_order( $post_id );
if ( ! $order ) {
echo '<span class="na">&ndash;</span>';
return;
}
self::render_booking_info_for_order( $order );
}
/**
* Render order admin column (HPOS).
*
* @param string $column Column name.
* @param \WC_Order $order Order object.
* @return void
*/
public static function render_order_column_hpos( string $column, $order ): void {
if ( 'bnb_booking' !== $column ) {
return;
}
if ( ! $order instanceof \WC_Order ) {
echo '<span class="na">&ndash;</span>';
return;
}
self::render_booking_info_for_order( $order );
}
/**
* Render booking info for an order.
*
* @param \WC_Order $order WooCommerce order.
* @return void
*/
private static function render_booking_info_for_order( \WC_Order $order ): void {
$booking_id = Manager::get_booking_for_order( $order );
if ( ! $booking_id ) {
echo '<span class="na">&ndash;</span>';
return;
}
$booking = get_post( $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 );
// Booking link.
printf(
'<a href="%s" title="%s">%s</a>',
esc_url( get_edit_post_link( $booking_id ) ),
esc_attr__( 'View booking', 'wp-bnb' ),
$booking ? esc_html( wp_trim_words( $booking->post_title, 3 ) ) : '#' . esc_html( $booking_id )
);
// Dates.
if ( $check_in && $check_out ) {
$check_in_date = \DateTime::createFromFormat( 'Y-m-d', $check_in );
$check_out_date = \DateTime::createFromFormat( 'Y-m-d', $check_out );
if ( $check_in_date && $check_out_date ) {
printf(
'<br><small>%s - %s</small>',
esc_html( $check_in_date->format( 'd.m' ) ),
esc_html( $check_out_date->format( 'd.m.y' ) )
);
}
}
// Status badge.
if ( $status ) {
printf(
'<br><span class="bnb-status-badge bnb-status-%s" style="font-size: 10px;">%s</span>',
esc_attr( $status ),
esc_html( ucfirst( str_replace( '_', ' ', $status ) ) )
);
}
}
/**
* Add row actions to booking list.
*
* @param array $actions Existing actions.
* @param \WP_Post $post Post object.
* @return array Modified actions.
*/
public static function add_booking_row_actions( array $actions, \WP_Post $post ): array {
if ( Booking::POST_TYPE !== $post->post_type ) {
return $actions;
}
$order = Manager::get_order_for_booking( $post->ID );
if ( $order ) {
$actions['view_order'] = sprintf(
'<a href="%s" aria-label="%s">%s</a>',
esc_url( $order->get_edit_order_url() ),
/* translators: %s: Order number */
esc_attr( sprintf( __( 'View order #%s', 'wp-bnb' ), $order->get_order_number() ) ),
__( 'View Order', 'wp-bnb' )
);
}
return $actions;
}
/**
* Add actions to WooCommerce order row.
*
* @param array $actions Existing actions.
* @param \WC_Order $order Order object.
* @return array Modified actions.
*/
public static function add_order_actions( array $actions, \WC_Order $order ): array {
$booking_id = Manager::get_booking_for_order( $order );
if ( $booking_id ) {
$actions['view_booking'] = array(
'url' => get_edit_post_link( $booking_id ),
'name' => __( 'View Booking', 'wp-bnb' ),
'action' => 'view_booking',
);
}
return $actions;
}
}

View File

@@ -0,0 +1,545 @@
<?php
/**
* WooCommerce Cart Handler.
*
* Handles cart item data, availability validation, and dynamic pricing.
*
* @package Magdev\WpBnb\Integration\WooCommerce
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Integration\WooCommerce;
use Magdev\WpBnb\Booking\Availability;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\PostTypes\Service;
use Magdev\WpBnb\Pricing\Calculator;
/**
* Cart Handler class.
*
* Manages booking data in the WooCommerce cart, validates availability,
* and calculates dynamic pricing based on dates and services.
*/
final class CartHandler {
/**
* Initialize cart handler.
*
* @return void
*/
public static function init(): void {
// Add booking data to cart item.
add_filter( 'woocommerce_add_cart_item_data', array( self::class, 'add_cart_item_data' ), 10, 3 );
// Restore booking data from session.
add_filter( 'woocommerce_get_cart_item_from_session', array( self::class, 'get_cart_item_from_session' ), 10, 2 );
// Validate before adding to cart.
add_filter( 'woocommerce_add_to_cart_validation', array( self::class, 'validate_add_to_cart' ), 10, 5 );
// Re-validate availability on cart load.
add_action( 'woocommerce_cart_loaded_from_session', array( self::class, 'validate_cart_items' ) );
// Display booking info in cart.
add_filter( 'woocommerce_get_item_data', array( self::class, 'display_cart_item_data' ), 10, 2 );
// Calculate dynamic price.
add_action( 'woocommerce_before_calculate_totals', array( self::class, 'calculate_cart_item_prices' ) );
// Prevent quantity changes for room products.
add_filter( 'woocommerce_quantity_input_args', array( self::class, 'lock_quantity' ), 10, 2 );
// Add booking data to order item meta.
add_action( 'woocommerce_checkout_create_order_line_item', array( self::class, 'add_order_item_meta' ), 10, 4 );
}
/**
* Add a room to cart with booking data.
*
* @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 $guests Number of guests.
* @param array $services Array of service selections.
* @return string|bool Cart item key or false on failure.
*/
public static function add_room_to_cart(
int $room_id,
string $check_in,
string $check_out,
int $guests = 1,
array $services = array()
) {
// Get product ID for room.
$product_id = ProductSync::get_product_for_room( $room_id );
if ( ! $product_id ) {
// Try to sync the room first.
$product_id = ProductSync::sync_room_to_product( $room_id );
if ( ! $product_id ) {
wc_add_notice( __( 'Unable to add room to cart. Product not found.', 'wp-bnb' ), 'error' );
return false;
}
}
// Store booking data in session temporarily for the filter.
WC()->session->set(
'bnb_pending_booking',
array(
'room_id' => $room_id,
'check_in' => $check_in,
'check_out' => $check_out,
'guests' => $guests,
'services' => $services,
)
);
// Add to cart.
$cart_item_key = WC()->cart->add_to_cart( $product_id, 1 );
// Clean up session.
WC()->session->set( 'bnb_pending_booking', null );
return $cart_item_key;
}
/**
* Add booking data to cart item when adding to cart.
*
* @param array $cart_item_data Cart item data.
* @param int $product_id Product ID.
* @param int $variation_id Variation ID.
* @return array Modified cart item data.
*/
public static function add_cart_item_data( array $cart_item_data, int $product_id, int $variation_id ): array {
// Check if this is a room product.
$room_id = ProductSync::get_room_for_product( $product_id );
if ( ! $room_id ) {
return $cart_item_data;
}
// Get booking data from session or POST.
$booking_data = WC()->session->get( 'bnb_pending_booking' );
if ( ! $booking_data ) {
// Try to get from POST (for direct form submissions).
// phpcs:disable WordPress.Security.NonceVerification.Missing
$booking_data = array(
'room_id' => isset( $_POST['bnb_room_id'] ) ? absint( $_POST['bnb_room_id'] ) : $room_id,
'check_in' => isset( $_POST['bnb_check_in'] ) ? sanitize_text_field( wp_unslash( $_POST['bnb_check_in'] ) ) : '',
'check_out' => isset( $_POST['bnb_check_out'] ) ? sanitize_text_field( wp_unslash( $_POST['bnb_check_out'] ) ) : '',
'guests' => isset( $_POST['bnb_guests'] ) ? absint( $_POST['bnb_guests'] ) : 1,
'services' => isset( $_POST['bnb_services'] ) ? self::sanitize_services( $_POST['bnb_services'] ) : array(),
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
// Validate required fields.
if ( empty( $booking_data['check_in'] ) || empty( $booking_data['check_out'] ) ) {
return $cart_item_data;
}
// Calculate nights.
$check_in = new \DateTime( $booking_data['check_in'] );
$check_out = new \DateTime( $booking_data['check_out'] );
$nights = (int) $check_in->diff( $check_out )->days;
if ( $nights < 1 ) {
return $cart_item_data;
}
// Calculate price breakdown.
$calculator = new Calculator( $room_id, $booking_data['check_in'], $booking_data['check_out'] );
$price_breakdown = $calculator->calculate();
$room_total = $price_breakdown['total'];
// Calculate services total.
$services_total = 0;
$services_data = array();
foreach ( $booking_data['services'] as $service_selection ) {
$service_id = $service_selection['service_id'] ?? 0;
$quantity = $service_selection['quantity'] ?? 1;
if ( ! $service_id ) {
continue;
}
$service_price = Service::calculate_service_price( $service_id, $quantity, $nights );
$service_data = Service::get_service_data( $service_id );
$services_data[] = array(
'service_id' => $service_id,
'quantity' => $quantity,
'price' => $service_price,
'pricing_type' => $service_data['pricing_type'] ?? 'per_booking',
'name' => $service_data['name'] ?? '',
);
$services_total += $service_price;
}
// Store booking data.
$cart_item_data[ Manager::CART_ITEM_KEY ] = array(
'room_id' => $room_id,
'check_in' => $booking_data['check_in'],
'check_out' => $booking_data['check_out'],
'guests' => $booking_data['guests'],
'nights' => $nights,
'services' => $services_data,
'price_breakdown' => array(
'room_total' => $room_total,
'services_total' => $services_total,
'grand_total' => $room_total + $services_total,
'full_breakdown' => $price_breakdown,
),
);
// Generate unique key based on booking data to allow multiple bookings.
$cart_item_data['unique_key'] = md5(
$room_id . $booking_data['check_in'] . $booking_data['check_out'] . microtime()
);
return $cart_item_data;
}
/**
* Restore booking data from session.
*
* @param array $cart_item Cart item data.
* @param array $values Session values.
* @return array Modified cart item data.
*/
public static function get_cart_item_from_session( array $cart_item, array $values ): array {
if ( isset( $values[ Manager::CART_ITEM_KEY ] ) ) {
$cart_item[ Manager::CART_ITEM_KEY ] = $values[ Manager::CART_ITEM_KEY ];
}
return $cart_item;
}
/**
* Validate room availability before adding to cart.
*
* @param bool $passed Whether validation passed.
* @param int $product_id Product ID.
* @param int $quantity Quantity.
* @param int $variation_id Variation ID.
* @param array $variations Variations.
* @return bool
*/
public static function validate_add_to_cart( bool $passed, int $product_id, int $quantity, int $variation_id = 0, array $variations = array() ): bool {
// Check if this is a room product.
$room_id = ProductSync::get_room_for_product( $product_id );
if ( ! $room_id ) {
return $passed;
}
// Get booking data.
$booking_data = WC()->session->get( 'bnb_pending_booking' );
if ( ! $booking_data ) {
// phpcs:disable WordPress.Security.NonceVerification.Missing
$booking_data = array(
'check_in' => isset( $_POST['bnb_check_in'] ) ? sanitize_text_field( wp_unslash( $_POST['bnb_check_in'] ) ) : '',
'check_out' => isset( $_POST['bnb_check_out'] ) ? sanitize_text_field( wp_unslash( $_POST['bnb_check_out'] ) ) : '',
'guests' => isset( $_POST['bnb_guests'] ) ? absint( $_POST['bnb_guests'] ) : 1,
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
// Validate dates provided.
if ( empty( $booking_data['check_in'] ) || empty( $booking_data['check_out'] ) ) {
wc_add_notice( __( 'Please select check-in and check-out dates.', 'wp-bnb' ), 'error' );
return false;
}
// Validate date format.
$check_in = \DateTime::createFromFormat( 'Y-m-d', $booking_data['check_in'] );
$check_out = \DateTime::createFromFormat( 'Y-m-d', $booking_data['check_out'] );
if ( ! $check_in || ! $check_out ) {
wc_add_notice( __( 'Invalid date format. Please use the date picker.', 'wp-bnb' ), 'error' );
return false;
}
// Validate check-out after check-in.
if ( $check_out <= $check_in ) {
wc_add_notice( __( 'Check-out date must be after check-in date.', 'wp-bnb' ), 'error' );
return false;
}
// Validate not in past.
$today = new \DateTime( 'today' );
if ( $check_in < $today ) {
wc_add_notice( __( 'Check-in date cannot be in the past.', 'wp-bnb' ), 'error' );
return false;
}
// Check availability.
$is_available = Availability::check_availability(
$room_id,
$booking_data['check_in'],
$booking_data['check_out']
);
if ( ! $is_available ) {
wc_add_notice( __( 'Sorry, this room is not available for the selected dates.', 'wp-bnb' ), 'error' );
return false;
}
// Check capacity.
$capacity = get_post_meta( $room_id, '_bnb_room_capacity', true );
if ( $capacity && $booking_data['guests'] > (int) $capacity ) {
wc_add_notice(
sprintf(
/* translators: %d: Room capacity */
__( 'This room has a maximum capacity of %d guests.', 'wp-bnb' ),
$capacity
),
'error'
);
return false;
}
// Check if same room with same dates already in cart.
foreach ( WC()->cart->get_cart() as $cart_item ) {
if ( isset( $cart_item[ Manager::CART_ITEM_KEY ] ) ) {
$existing = $cart_item[ Manager::CART_ITEM_KEY ];
if ( $existing['room_id'] === $room_id
&& $existing['check_in'] === $booking_data['check_in']
&& $existing['check_out'] === $booking_data['check_out']
) {
wc_add_notice( __( 'This room with the same dates is already in your cart.', 'wp-bnb' ), 'error' );
return false;
}
}
}
return $passed;
}
/**
* Validate cart items on cart load.
*
* @param \WC_Cart $cart Cart object.
* @return void
*/
public static function validate_cart_items( \WC_Cart $cart ): void {
$items_to_remove = array();
foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) {
if ( ! isset( $cart_item[ Manager::CART_ITEM_KEY ] ) ) {
continue;
}
$booking_data = $cart_item[ Manager::CART_ITEM_KEY ];
// Check if dates are still valid.
$check_in = \DateTime::createFromFormat( 'Y-m-d', $booking_data['check_in'] );
$today = new \DateTime( 'today' );
if ( $check_in < $today ) {
$items_to_remove[] = array(
'key' => $cart_item_key,
'message' => __( 'A room booking was removed because the check-in date has passed.', 'wp-bnb' ),
);
continue;
}
// Check if room still available.
$is_available = Availability::check_availability(
$booking_data['room_id'],
$booking_data['check_in'],
$booking_data['check_out']
);
if ( ! $is_available ) {
$items_to_remove[] = array(
'key' => $cart_item_key,
'message' => __( 'A room booking was removed because the room is no longer available for those dates.', 'wp-bnb' ),
);
}
}
// Remove invalid items.
foreach ( $items_to_remove as $item ) {
$cart->remove_cart_item( $item['key'] );
wc_add_notice( $item['message'], 'error' );
}
}
/**
* Display booking info in cart.
*
* @param array $item_data Item data for display.
* @param array $cart_item Cart item.
* @return array Modified item data.
*/
public static function display_cart_item_data( array $item_data, array $cart_item ): array {
if ( ! isset( $cart_item[ Manager::CART_ITEM_KEY ] ) ) {
return $item_data;
}
$booking_data = $cart_item[ Manager::CART_ITEM_KEY ];
// Format dates.
$date_format = get_option( 'date_format' );
$check_in = \DateTime::createFromFormat( 'Y-m-d', $booking_data['check_in'] );
$check_out = \DateTime::createFromFormat( 'Y-m-d', $booking_data['check_out'] );
$item_data[] = array(
'key' => __( 'Check-in', 'wp-bnb' ),
'value' => $check_in ? $check_in->format( $date_format ) : $booking_data['check_in'],
);
$item_data[] = array(
'key' => __( 'Check-out', 'wp-bnb' ),
'value' => $check_out ? $check_out->format( $date_format ) : $booking_data['check_out'],
);
$item_data[] = array(
'key' => __( 'Nights', 'wp-bnb' ),
'value' => $booking_data['nights'],
);
$item_data[] = array(
'key' => __( 'Guests', 'wp-bnb' ),
'value' => $booking_data['guests'],
);
// Display services.
if ( ! empty( $booking_data['services'] ) ) {
$services_list = array();
foreach ( $booking_data['services'] as $service ) {
$services_list[] = $service['name'] . ' &times; ' . $service['quantity'];
}
$item_data[] = array(
'key' => __( 'Services', 'wp-bnb' ),
'value' => implode( ', ', $services_list ),
);
}
return $item_data;
}
/**
* Calculate dynamic prices for cart items.
*
* @param \WC_Cart $cart Cart object.
* @return void
*/
public static function calculate_cart_item_prices( \WC_Cart $cart ): void {
if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
return;
}
foreach ( $cart->get_cart() as $cart_item ) {
if ( ! isset( $cart_item[ Manager::CART_ITEM_KEY ] ) ) {
continue;
}
$booking_data = $cart_item[ Manager::CART_ITEM_KEY ];
$grand_total = $booking_data['price_breakdown']['grand_total'] ?? 0;
/**
* Filter the cart item price for a booking.
*
* @param float $price The calculated price.
* @param array $cart_item The cart item.
*/
$grand_total = apply_filters( 'wp_bnb_wc_cart_item_price', $grand_total, $cart_item );
// Set the price.
$cart_item['data']->set_price( $grand_total );
}
}
/**
* Lock quantity to 1 for room products.
*
* @param array $args Input arguments.
* @param \WC_Product $product Product object.
* @return array Modified arguments.
*/
public static function lock_quantity( array $args, \WC_Product $product ): array {
if ( ProductSync::is_room_product( $product ) ) {
$args['min_value'] = 1;
$args['max_value'] = 1;
$args['readonly'] = true;
}
return $args;
}
/**
* Add booking data to order item meta.
*
* @param \WC_Order_Item_Product $item Order item.
* @param string $cart_item_key Cart item key.
* @param array $values Cart item values.
* @param \WC_Order $order Order object.
* @return void
*/
public static function add_order_item_meta( \WC_Order_Item_Product $item, string $cart_item_key, array $values, \WC_Order $order ): void {
if ( ! isset( $values[ Manager::CART_ITEM_KEY ] ) ) {
return;
}
$booking_data = $values[ Manager::CART_ITEM_KEY ];
// Store booking data in order item meta.
$item->add_meta_data( '_bnb_room_id', $booking_data['room_id'] );
$item->add_meta_data( '_bnb_check_in', $booking_data['check_in'] );
$item->add_meta_data( '_bnb_check_out', $booking_data['check_out'] );
$item->add_meta_data( '_bnb_guests', $booking_data['guests'] );
$item->add_meta_data( '_bnb_nights', $booking_data['nights'] );
$item->add_meta_data( '_bnb_services', wp_json_encode( $booking_data['services'] ) );
$item->add_meta_data( '_bnb_price_breakdown', wp_json_encode( $booking_data['price_breakdown'] ) );
// Add visible meta for admin display.
$date_format = get_option( 'date_format' );
$check_in = \DateTime::createFromFormat( 'Y-m-d', $booking_data['check_in'] );
$check_out = \DateTime::createFromFormat( 'Y-m-d', $booking_data['check_out'] );
$item->add_meta_data( __( 'Check-in', 'wp-bnb' ), $check_in ? $check_in->format( $date_format ) : $booking_data['check_in'] );
$item->add_meta_data( __( 'Check-out', 'wp-bnb' ), $check_out ? $check_out->format( $date_format ) : $booking_data['check_out'] );
$item->add_meta_data( __( 'Nights', 'wp-bnb' ), $booking_data['nights'] );
$item->add_meta_data( __( 'Guests', 'wp-bnb' ), $booking_data['guests'] );
}
/**
* Sanitize services array from POST data.
*
* @param mixed $services Raw services data.
* @return array Sanitized services array.
*/
private static function sanitize_services( $services ): array {
if ( ! is_array( $services ) ) {
return array();
}
$sanitized = array();
foreach ( $services as $service ) {
if ( ! is_array( $service ) ) {
continue;
}
$service_id = isset( $service['service_id'] ) ? absint( $service['service_id'] ) : 0;
$quantity = isset( $service['quantity'] ) ? absint( $service['quantity'] ) : 1;
if ( $service_id > 0 ) {
$sanitized[] = array(
'service_id' => $service_id,
'quantity' => max( 1, $quantity ),
);
}
}
return $sanitized;
}
}

View File

@@ -0,0 +1,347 @@
<?php
/**
* WooCommerce Checkout Handler.
*
* Handles checkout field customization and validation.
*
* @package Magdev\WpBnb\Integration\WooCommerce
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Integration\WooCommerce;
use Magdev\WpBnb\Booking\Availability;
use Magdev\WpBnb\PostTypes\Guest;
use Magdev\WpBnb\PostTypes\Room;
/**
* Checkout Handler class.
*
* Manages checkout field customization, pre-filling, and validation.
*/
final class CheckoutHandler {
/**
* Initialize checkout handler.
*
* @return void
*/
public static function init(): void {
// Add custom checkout fields.
add_filter( 'woocommerce_checkout_fields', array( self::class, 'add_checkout_fields' ) );
// Pre-fill checkout fields.
add_filter( 'woocommerce_checkout_get_value', array( self::class, 'prefill_checkout_fields' ), 10, 2 );
// Validate checkout.
add_action( 'woocommerce_after_checkout_validation', array( self::class, 'validate_checkout' ), 10, 2 );
// Display booking summary in order review.
add_action( 'woocommerce_review_order_before_submit', array( self::class, 'display_booking_summary' ) );
// Save guest notes to order meta.
add_action( 'woocommerce_checkout_create_order', array( self::class, 'save_guest_notes' ), 10, 2 );
}
/**
* Add custom checkout fields.
*
* @param array $fields Checkout fields.
* @return array Modified checkout fields.
*/
public static function add_checkout_fields( array $fields ): array {
// Only add fields if cart contains BnB bookings.
if ( ! self::cart_has_bookings() ) {
return $fields;
}
// Add special requests field.
$fields['order']['bnb_guest_notes'] = array(
'type' => 'textarea',
'label' => __( 'Special Requests', 'wp-bnb' ),
'placeholder' => __( 'Any special requests, dietary requirements, or preferences...', 'wp-bnb' ),
'class' => array( 'form-row-wide' ),
'required' => false,
'priority' => 90,
);
// Add expected arrival time.
$fields['order']['bnb_arrival_time'] = array(
'type' => 'select',
'label' => __( 'Expected Arrival Time', 'wp-bnb' ),
'class' => array( 'form-row-wide' ),
'required' => false,
'priority' => 85,
'options' => self::get_arrival_time_options(),
);
return $fields;
}
/**
* Pre-fill checkout fields from guest data.
*
* @param mixed $value Current value.
* @param string $input Input field name.
* @return mixed Pre-filled value.
*/
public static function prefill_checkout_fields( $value, string $input ) {
// Only for logged-in users.
if ( ! is_user_logged_in() ) {
return $value;
}
// Try to find an existing guest record by email.
$current_user = wp_get_current_user();
$guest = self::find_guest_by_email( $current_user->user_email );
if ( ! $guest ) {
return $value;
}
$mappings = array(
'billing_first_name' => '_bnb_guest_first_name',
'billing_last_name' => '_bnb_guest_last_name',
'billing_email' => '_bnb_guest_email',
'billing_phone' => '_bnb_guest_phone',
'billing_address_1' => '_bnb_guest_address',
'billing_city' => '_bnb_guest_city',
'billing_postcode' => '_bnb_guest_postal_code',
'billing_country' => '_bnb_guest_country',
);
if ( isset( $mappings[ $input ] ) ) {
$meta_value = get_post_meta( $guest->ID, $mappings[ $input ], true );
if ( $meta_value ) {
return $meta_value;
}
}
return $value;
}
/**
* Validate checkout for BnB bookings.
*
* @param array $data Checkout data.
* @param \WP_Error $errors Error object.
* @return void
*/
public static function validate_checkout( array $data, \WP_Error $errors ): void {
// Re-validate availability for all bookings.
foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) {
if ( ! isset( $cart_item[ Manager::CART_ITEM_KEY ] ) ) {
continue;
}
$booking_data = $cart_item[ Manager::CART_ITEM_KEY ];
// Check availability one more time.
$is_available = Availability::check_availability(
$booking_data['room_id'],
$booking_data['check_in'],
$booking_data['check_out']
);
if ( ! $is_available ) {
$room = get_post( $booking_data['room_id'] );
$errors->add(
'bnb_room_unavailable',
sprintf(
/* translators: %s: Room name */
__( 'Sorry, %s is no longer available for the selected dates. Please update your cart.', 'wp-bnb' ),
$room ? $room->post_title : __( 'the room', 'wp-bnb' )
)
);
}
// Validate check-in date not in past.
$check_in = \DateTime::createFromFormat( 'Y-m-d', $booking_data['check_in'] );
$today = new \DateTime( 'today' );
if ( $check_in < $today ) {
$errors->add(
'bnb_date_passed',
__( 'A booking in your cart has a check-in date in the past. Please update your cart.', 'wp-bnb' )
);
}
}
}
/**
* Display booking summary in order review section.
*
* @return void
*/
public static function display_booking_summary(): void {
if ( ! self::cart_has_bookings() ) {
return;
}
$bookings = array();
foreach ( WC()->cart->get_cart() as $cart_item ) {
if ( ! isset( $cart_item[ Manager::CART_ITEM_KEY ] ) ) {
continue;
}
$booking_data = $cart_item[ Manager::CART_ITEM_KEY ];
$room = get_post( $booking_data['room_id'] );
if ( ! $room ) {
continue;
}
$building = Room::get_building( $booking_data['room_id'] );
$date_format = get_option( 'date_format' );
$check_in = \DateTime::createFromFormat( 'Y-m-d', $booking_data['check_in'] );
$check_out = \DateTime::createFromFormat( 'Y-m-d', $booking_data['check_out'] );
$bookings[] = array(
'room_name' => $room->post_title,
'building_name' => $building ? $building->post_title : '',
'check_in' => $check_in ? $check_in->format( $date_format ) : '',
'check_out' => $check_out ? $check_out->format( $date_format ) : '',
'nights' => $booking_data['nights'],
'guests' => $booking_data['guests'],
);
}
if ( empty( $bookings ) ) {
return;
}
?>
<div class="bnb-checkout-booking-summary">
<h3><?php esc_html_e( 'Booking Summary', 'wp-bnb' ); ?></h3>
<?php foreach ( $bookings as $booking ) : ?>
<div class="bnb-booking-item">
<strong><?php echo esc_html( $booking['room_name'] ); ?></strong>
<?php if ( $booking['building_name'] ) : ?>
<span class="bnb-building-name"><?php echo esc_html( $booking['building_name'] ); ?></span>
<?php endif; ?>
<div class="bnb-booking-details">
<span>
<?php
printf(
/* translators: 1: Check-in date, 2: Check-out date */
esc_html__( '%1$s to %2$s', 'wp-bnb' ),
esc_html( $booking['check_in'] ),
esc_html( $booking['check_out'] )
);
?>
</span>
<span>
<?php
printf(
/* translators: %d: Number of nights */
esc_html( _n( '%d night', '%d nights', $booking['nights'], 'wp-bnb' ) ),
$booking['nights']
);
?>
</span>
<span>
<?php
printf(
/* translators: %d: Number of guests */
esc_html( _n( '%d guest', '%d guests', $booking['guests'], 'wp-bnb' ) ),
$booking['guests']
);
?>
</span>
</div>
</div>
<?php endforeach; ?>
</div>
<?php
}
/**
* Save guest notes to order meta.
*
* @param \WC_Order $order Order object.
* @param array $data Checkout data.
* @return void
*/
public static function save_guest_notes( \WC_Order $order, array $data ): void {
// phpcs:disable WordPress.Security.NonceVerification.Missing
if ( isset( $_POST['bnb_guest_notes'] ) && ! empty( $_POST['bnb_guest_notes'] ) ) {
$order->update_meta_data(
'_bnb_guest_notes',
sanitize_textarea_field( wp_unslash( $_POST['bnb_guest_notes'] ) )
);
}
if ( isset( $_POST['bnb_arrival_time'] ) && ! empty( $_POST['bnb_arrival_time'] ) ) {
$order->update_meta_data(
'_bnb_arrival_time',
sanitize_text_field( wp_unslash( $_POST['bnb_arrival_time'] ) )
);
}
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
/**
* Check if cart contains BnB bookings.
*
* @return bool
*/
public static function cart_has_bookings(): bool {
if ( ! WC()->cart ) {
return false;
}
foreach ( WC()->cart->get_cart() as $cart_item ) {
if ( isset( $cart_item[ Manager::CART_ITEM_KEY ] ) ) {
return true;
}
}
return false;
}
/**
* Get arrival time options.
*
* @return array
*/
private static function get_arrival_time_options(): array {
$options = array(
'' => __( 'Select arrival time (optional)', 'wp-bnb' ),
'0-2' => __( 'Early morning (00:00 - 02:00)', 'wp-bnb' ),
'2-6' => __( 'Night (02:00 - 06:00)', 'wp-bnb' ),
'6-10' => __( 'Morning (06:00 - 10:00)', 'wp-bnb' ),
'10-14' => __( 'Late morning (10:00 - 14:00)', 'wp-bnb' ),
'14-18' => __( 'Afternoon (14:00 - 18:00)', 'wp-bnb' ),
'18-22' => __( 'Evening (18:00 - 22:00)', 'wp-bnb' ),
'22-24' => __( 'Late evening (22:00 - 00:00)', 'wp-bnb' ),
);
return $options;
}
/**
* Find guest by email address.
*
* @param string $email Email address.
* @return \WP_Post|null Guest post or null.
*/
private static function find_guest_by_email( string $email ): ?\WP_Post {
$guests = get_posts(
array(
'post_type' => Guest::POST_TYPE,
'posts_per_page' => 1,
'post_status' => 'publish',
'meta_query' => array(
array(
'key' => '_bnb_guest_email',
'value' => $email,
),
),
)
);
return $guests[0] ?? null;
}
}

View File

@@ -0,0 +1,633 @@
<?php
/**
* WooCommerce Invoice Generator.
*
* Generates PDF invoices for WooCommerce orders.
*
* @package Magdev\WpBnb\Integration\WooCommerce
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Integration\WooCommerce;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\Pricing\Calculator;
use Mpdf\Mpdf;
/**
* Invoice Generator class.
*
* Creates PDF invoices for bookings using mPDF.
*/
final class InvoiceGenerator {
/**
* Invoice storage directory relative to uploads.
*/
private const INVOICE_DIR = 'wp-bnb-invoices';
/**
* Initialize invoice generator.
*
* @return void
*/
public static function init(): void {
// Attach invoice to order emails.
add_filter( 'woocommerce_email_attachments', array( self::class, 'attach_invoice_to_email' ), 10, 4 );
// Add admin order action button.
add_action( 'woocommerce_admin_order_actions_end', array( self::class, 'add_order_action_button' ) );
// AJAX handler for invoice generation.
add_action( 'wp_ajax_wp_bnb_generate_invoice', array( self::class, 'ajax_generate_invoice' ) );
// Handle invoice download.
add_action( 'init', array( self::class, 'handle_invoice_download' ) );
}
/**
* Generate invoice for an order.
*
* @param \WC_Order $order WooCommerce order.
* @return string|null Invoice file path or null on failure.
*/
public static function generate_invoice( \WC_Order $order ): ?string {
/**
* Fires before generating an invoice.
*
* @param \WC_Order $order WooCommerce order.
*/
do_action( 'wp_bnb_wc_before_invoice_generate', $order );
// Get or generate invoice number.
$invoice_number = self::get_invoice_number( $order );
// Get booking data.
$booking_id = Manager::get_booking_for_order( $order );
// Generate HTML content.
$html = self::get_invoice_html( $order, $invoice_number, $booking_id );
// Generate PDF.
try {
$temp_dir = get_temp_dir() . 'mpdf';
if ( ! file_exists( $temp_dir ) ) {
wp_mkdir_p( $temp_dir );
}
$mpdf = new Mpdf(
array(
'mode' => 'utf-8',
'format' => 'A4',
'margin_left' => 15,
'margin_right' => 15,
'margin_top' => 15,
'margin_bottom' => 20,
'tempDir' => $temp_dir,
)
);
$mpdf->SetTitle( sprintf( 'Invoice %s', $invoice_number ) );
$mpdf->SetAuthor( get_option( 'wp_bnb_business_name', get_bloginfo( 'name' ) ) );
$mpdf->SetCreator( 'WP BnB' );
$mpdf->WriteHTML( $html );
// Save to file.
$file_path = self::get_invoice_path( $order, $invoice_number );
$mpdf->Output( $file_path, 'F' );
// Store invoice number and path in order meta.
$order->update_meta_data( '_bnb_invoice_number', $invoice_number );
$order->update_meta_data( '_bnb_invoice_path', $file_path );
$order->update_meta_data( '_bnb_invoice_date', current_time( 'mysql' ) );
$order->save();
/**
* Fires after generating an invoice.
*
* @param \WC_Order $order WooCommerce order.
* @param string $file_path Invoice file path.
*/
do_action( 'wp_bnb_wc_after_invoice_generate', $order, $file_path );
return $file_path;
} catch ( \Exception $e ) {
error_log( 'WP BnB Invoice generation failed: ' . $e->getMessage() );
return null;
}
}
/**
* Get invoice number for an order.
*
* @param \WC_Order $order WooCommerce order.
* @return string Invoice number.
*/
public static function get_invoice_number( \WC_Order $order ): string {
// Check if already has invoice number.
$existing = $order->get_meta( '_bnb_invoice_number', true );
if ( $existing ) {
return $existing;
}
// Generate new invoice number.
return Manager::get_next_invoice_number();
}
/**
* Get invoice file path.
*
* @param \WC_Order $order WooCommerce order.
* @param string $invoice_number Invoice number.
* @return string File path.
*/
private static function get_invoice_path( \WC_Order $order, string $invoice_number ): string {
$upload_dir = wp_upload_dir();
$invoice_dir = $upload_dir['basedir'] . '/' . self::INVOICE_DIR;
// Create directory if needed.
if ( ! file_exists( $invoice_dir ) ) {
wp_mkdir_p( $invoice_dir );
// Add .htaccess to protect invoices.
$htaccess = $invoice_dir . '/.htaccess';
if ( ! file_exists( $htaccess ) ) {
file_put_contents( $htaccess, 'Deny from all' );
}
// Add index.php for extra protection.
$index = $invoice_dir . '/index.php';
if ( ! file_exists( $index ) ) {
file_put_contents( $index, '<?php // Silence is golden.' );
}
}
// Sanitize invoice number for filename.
$safe_number = sanitize_file_name( $invoice_number );
return $invoice_dir . '/invoice-' . $safe_number . '-' . $order->get_id() . '.pdf';
}
/**
* Check if invoice exists for an order.
*
* @param \WC_Order $order WooCommerce order.
* @return bool
*/
public static function invoice_exists( \WC_Order $order ): bool {
$path = $order->get_meta( '_bnb_invoice_path', true );
return $path && file_exists( $path );
}
/**
* Attach invoice to email.
*
* @param array $attachments Attachments array.
* @param string $email_id Email ID.
* @param \WC_Order $order WooCommerce order.
* @param \WC_Email $email Email object.
* @return array Modified attachments.
*/
public static function attach_invoice_to_email( array $attachments, string $email_id, $order, $email ): array {
// Only attach to specific emails.
$allowed_emails = array(
'customer_completed_order',
'customer_processing_order',
'customer_invoice',
);
if ( ! in_array( $email_id, $allowed_emails, true ) ) {
return $attachments;
}
// Check if order is a WC_Order.
if ( ! $order instanceof \WC_Order ) {
return $attachments;
}
// Check if invoice attachment is enabled.
if ( ! Manager::is_invoice_attach_enabled() ) {
return $attachments;
}
// Check if this order has a booking.
$booking_id = Manager::get_booking_for_order( $order );
if ( ! $booking_id ) {
return $attachments;
}
// Generate invoice if it doesn't exist.
if ( ! self::invoice_exists( $order ) ) {
self::generate_invoice( $order );
}
// Get invoice path.
$invoice_path = $order->get_meta( '_bnb_invoice_path', true );
if ( $invoice_path && file_exists( $invoice_path ) ) {
$attachments[] = $invoice_path;
}
return $attachments;
}
/**
* Add order action button for invoice.
*
* @param \WC_Order $order WooCommerce order.
* @return void
*/
public static function add_order_action_button( \WC_Order $order ): void {
// Check if this order has a booking.
$booking_id = Manager::get_booking_for_order( $order );
if ( ! $booking_id ) {
return;
}
// Generate download URL.
$download_url = add_query_arg(
array(
'bnb_download_invoice' => $order->get_id(),
'_wpnonce' => wp_create_nonce( 'bnb_download_invoice_' . $order->get_id() ),
),
admin_url( 'admin.php' )
);
?>
<a class="button tips" href="<?php echo esc_url( $download_url ); ?>"
data-tip="<?php esc_attr_e( 'Download Invoice', 'wp-bnb' ); ?>">
<span class="dashicons dashicons-pdf" style="vertical-align: middle;"></span>
</a>
<?php
}
/**
* AJAX handler for generating invoice.
*
* @return void
*/
public static function ajax_generate_invoice(): void {
check_ajax_referer( 'wp_bnb_admin_nonce', 'nonce' );
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'wp-bnb' ) ) );
}
$order_id = isset( $_POST['order_id'] ) ? absint( $_POST['order_id'] ) : 0;
if ( ! $order_id ) {
wp_send_json_error( array( 'message' => __( 'Invalid order ID.', 'wp-bnb' ) ) );
}
$order = wc_get_order( $order_id );
if ( ! $order ) {
wp_send_json_error( array( 'message' => __( 'Order not found.', 'wp-bnb' ) ) );
}
$file_path = self::generate_invoice( $order );
if ( $file_path ) {
wp_send_json_success( array( 'message' => __( 'Invoice generated successfully.', 'wp-bnb' ) ) );
} else {
wp_send_json_error( array( 'message' => __( 'Failed to generate invoice.', 'wp-bnb' ) ) );
}
}
/**
* Handle invoice download request.
*
* @return void
*/
public static function handle_invoice_download(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET['bnb_download_invoice'] ) ) {
return;
}
$order_id = absint( $_GET['bnb_download_invoice'] );
// Verify nonce.
if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_key( $_GET['_wpnonce'] ), 'bnb_download_invoice_' . $order_id ) ) {
wp_die( esc_html__( 'Security check failed.', 'wp-bnb' ) );
}
// Check capabilities.
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'You do not have permission to download invoices.', 'wp-bnb' ) );
}
$order = wc_get_order( $order_id );
if ( ! $order ) {
wp_die( esc_html__( 'Order not found.', 'wp-bnb' ) );
}
// Generate invoice if needed.
if ( ! self::invoice_exists( $order ) ) {
self::generate_invoice( $order );
}
$invoice_path = $order->get_meta( '_bnb_invoice_path', true );
if ( ! $invoice_path || ! file_exists( $invoice_path ) ) {
wp_die( esc_html__( 'Invoice not found.', 'wp-bnb' ) );
}
$invoice_number = $order->get_meta( '_bnb_invoice_number', true );
$filename = 'invoice-' . sanitize_file_name( $invoice_number ) . '.pdf';
// Output file.
header( 'Content-Type: application/pdf' );
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
header( 'Content-Length: ' . filesize( $invoice_path ) );
header( 'Cache-Control: no-cache, no-store, must-revalidate' );
readfile( $invoice_path );
exit;
}
/**
* Get invoice HTML content.
*
* @param \WC_Order $order WooCommerce order.
* @param string $invoice_number Invoice number.
* @param int|null $booking_id Booking ID.
* @return string HTML content.
*/
private static function get_invoice_html( \WC_Order $order, string $invoice_number, ?int $booking_id ): string {
// Get business info.
$business = self::get_business_info();
// Get booking details.
$check_in = '';
$check_out = '';
$room_name = '';
$building_name = '';
$nights = 0;
$guests = 0;
if ( $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 );
$guests = get_post_meta( $booking_id, '_bnb_booking_adults', true );
$room_id = get_post_meta( $booking_id, '_bnb_booking_room_id', true );
if ( $room_id ) {
$room = get_post( $room_id );
$room_name = $room ? $room->post_title : '';
$building = Room::get_building( $room_id );
$building_name = $building ? $building->post_title : '';
}
if ( $check_in && $check_out ) {
$check_in_date = \DateTime::createFromFormat( 'Y-m-d', $check_in );
$check_out_date = \DateTime::createFromFormat( 'Y-m-d', $check_out );
if ( $check_in_date && $check_out_date ) {
$nights = $check_in_date->diff( $check_out_date )->days;
}
}
}
// Format dates.
$date_format = get_option( 'date_format' );
$check_in_display = $check_in ? date_i18n( $date_format, strtotime( $check_in ) ) : '';
$check_out_display = $check_out ? date_i18n( $date_format, strtotime( $check_out ) ) : '';
$order_date = $order->get_date_created() ? $order->get_date_created()->date_i18n( $date_format ) : '';
// Get logo.
$logo_html = '';
$logo_id = Manager::get_invoice_logo();
if ( $logo_id ) {
$logo_url = wp_get_attachment_url( $logo_id );
if ( $logo_url ) {
$logo_html = '<img src="' . esc_url( $logo_url ) . '" style="max-height: 60px; max-width: 200px;">';
}
}
// Get footer.
$footer_text = Manager::get_invoice_footer();
// Build HTML.
$html = '<html><head><style>' . self::get_invoice_css() . '</style></head><body>';
// Header.
$html .= '<div class="invoice-header">';
$html .= '<div class="logo">' . $logo_html . '</div>';
$html .= '<div class="invoice-title">';
$html .= '<h1>' . esc_html__( 'INVOICE', 'wp-bnb' ) . '</h1>';
$html .= '<p class="invoice-number">' . esc_html( $invoice_number ) . '</p>';
$html .= '<p class="invoice-date">' . esc_html( $order_date ) . '</p>';
$html .= '</div>';
$html .= '</div>';
// Addresses.
$html .= '<div class="addresses">';
// From.
$html .= '<div class="address from">';
$html .= '<h3>' . esc_html__( 'From', 'wp-bnb' ) . '</h3>';
$html .= '<p><strong>' . esc_html( $business['name'] ) . '</strong></p>';
if ( $business['street'] ) {
$html .= '<p>' . esc_html( $business['street'] ) . '</p>';
}
if ( $business['city'] || $business['postal'] ) {
$html .= '<p>' . esc_html( $business['postal'] . ' ' . $business['city'] ) . '</p>';
}
if ( $business['country'] ) {
$html .= '<p>' . esc_html( $business['country'] ) . '</p>';
}
if ( $business['email'] ) {
$html .= '<p>' . esc_html( $business['email'] ) . '</p>';
}
if ( $business['phone'] ) {
$html .= '<p>' . esc_html( $business['phone'] ) . '</p>';
}
$html .= '</div>';
// To.
$html .= '<div class="address to">';
$html .= '<h3>' . esc_html__( 'Bill To', 'wp-bnb' ) . '</h3>';
$html .= '<p><strong>' . esc_html( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() ) . '</strong></p>';
if ( $order->get_billing_address_1() ) {
$html .= '<p>' . esc_html( $order->get_billing_address_1() ) . '</p>';
}
if ( $order->get_billing_city() || $order->get_billing_postcode() ) {
$html .= '<p>' . esc_html( $order->get_billing_postcode() . ' ' . $order->get_billing_city() ) . '</p>';
}
if ( $order->get_billing_country() ) {
$html .= '<p>' . esc_html( WC()->countries->countries[ $order->get_billing_country() ] ?? $order->get_billing_country() ) . '</p>';
}
if ( $order->get_billing_email() ) {
$html .= '<p>' . esc_html( $order->get_billing_email() ) . '</p>';
}
$html .= '</div>';
$html .= '</div>';
// Booking details.
if ( $booking_id && $room_name ) {
$html .= '<div class="booking-details">';
$html .= '<h3>' . esc_html__( 'Booking Details', 'wp-bnb' ) . '</h3>';
$html .= '<table class="details-table">';
$html .= '<tr><td><strong>' . esc_html__( 'Room', 'wp-bnb' ) . '</strong></td><td>' . esc_html( $room_name );
if ( $building_name ) {
$html .= ' <small>(' . esc_html( $building_name ) . ')</small>';
}
$html .= '</td></tr>';
$html .= '<tr><td><strong>' . esc_html__( 'Check-in', 'wp-bnb' ) . '</strong></td><td>' . esc_html( $check_in_display ) . '</td></tr>';
$html .= '<tr><td><strong>' . esc_html__( 'Check-out', 'wp-bnb' ) . '</strong></td><td>' . esc_html( $check_out_display ) . '</td></tr>';
$html .= '<tr><td><strong>' . esc_html__( 'Nights', 'wp-bnb' ) . '</strong></td><td>' . esc_html( $nights ) . '</td></tr>';
$html .= '<tr><td><strong>' . esc_html__( 'Guests', 'wp-bnb' ) . '</strong></td><td>' . esc_html( $guests ) . '</td></tr>';
$html .= '</table>';
$html .= '</div>';
}
// Line items.
$html .= '<div class="line-items">';
$html .= '<table class="items-table">';
$html .= '<thead><tr>';
$html .= '<th class="description">' . esc_html__( 'Description', 'wp-bnb' ) . '</th>';
$html .= '<th class="qty">' . esc_html__( 'Qty', 'wp-bnb' ) . '</th>';
$html .= '<th class="price">' . esc_html__( 'Price', 'wp-bnb' ) . '</th>';
$html .= '<th class="total">' . esc_html__( 'Total', 'wp-bnb' ) . '</th>';
$html .= '</tr></thead>';
$html .= '<tbody>';
foreach ( $order->get_items() as $item ) {
$qty = $item->get_quantity();
$total = $item->get_total();
$price = $qty > 0 ? $total / $qty : $total;
$html .= '<tr>';
$html .= '<td class="description">' . esc_html( $item->get_name() ) . '</td>';
$html .= '<td class="qty">' . esc_html( $qty ) . '</td>';
$html .= '<td class="price">' . wc_price( $price ) . '</td>';
$html .= '<td class="total">' . wc_price( $total ) . '</td>';
$html .= '</tr>';
}
$html .= '</tbody>';
$html .= '</table>';
$html .= '</div>';
// Totals.
$html .= '<div class="totals">';
$html .= '<table class="totals-table">';
$html .= '<tr><td>' . esc_html__( 'Subtotal', 'wp-bnb' ) . '</td><td>' . wc_price( $order->get_subtotal() ) . '</td></tr>';
if ( $order->get_total_tax() > 0 ) {
$html .= '<tr><td>' . esc_html__( 'Tax', 'wp-bnb' ) . '</td><td>' . wc_price( $order->get_total_tax() ) . '</td></tr>';
}
$html .= '<tr class="grand-total"><td><strong>' . esc_html__( 'Total', 'wp-bnb' ) . '</strong></td><td><strong>' . wc_price( $order->get_total() ) . '</strong></td></tr>';
$html .= '</table>';
$html .= '</div>';
// Payment info.
$html .= '<div class="payment-info">';
$html .= '<p><strong>' . esc_html__( 'Payment Status:', 'wp-bnb' ) . '</strong> ';
if ( $order->is_paid() ) {
$html .= '<span class="paid">' . esc_html__( 'PAID', 'wp-bnb' ) . '</span>';
} else {
$html .= '<span class="unpaid">' . esc_html__( 'PENDING', 'wp-bnb' ) . '</span>';
}
$html .= '</p>';
$html .= '<p><strong>' . esc_html__( 'Payment Method:', 'wp-bnb' ) . '</strong> ' . esc_html( $order->get_payment_method_title() ) . '</p>';
$html .= '</div>';
// Footer.
$html .= '<div class="footer">';
$html .= '<p>' . esc_html__( 'Thank you for your stay!', 'wp-bnb' ) . '</p>';
if ( $footer_text ) {
$html .= '<p class="custom-footer">' . esc_html( $footer_text ) . '</p>';
}
$html .= '</div>';
$html .= '</body></html>';
/**
* Filter the invoice HTML.
*
* @param string $html Invoice HTML.
* @param \WC_Order $order WooCommerce order.
*/
return apply_filters( 'wp_bnb_wc_invoice_html', $html, $order );
}
/**
* Get invoice CSS styles.
*
* @return string CSS content.
*/
private static function get_invoice_css(): string {
return '
body { font-family: DejaVu Sans, sans-serif; font-size: 10pt; color: #333; line-height: 1.4; }
h1 { font-size: 24pt; color: #2271b1; margin: 0; text-align: right; }
h3 { font-size: 11pt; color: #50575e; margin: 15pt 0 5pt 0; }
.invoice-header { margin-bottom: 30pt; overflow: hidden; }
.logo { float: left; width: 50%; }
.invoice-title { float: right; width: 50%; text-align: right; }
.invoice-number { font-size: 14pt; font-weight: bold; color: #333; margin: 5pt 0; }
.invoice-date { font-size: 10pt; color: #787c82; margin: 0; }
.addresses { margin-bottom: 20pt; overflow: hidden; }
.address { width: 48%; }
.address.from { float: left; }
.address.to { float: right; }
.address p { margin: 2pt 0; font-size: 9pt; }
.booking-details { margin-bottom: 20pt; background: #f9f9f9; padding: 10pt; border-radius: 4pt; }
.details-table { width: 100%; border-collapse: collapse; }
.details-table td { padding: 4pt 8pt; font-size: 9pt; border-bottom: 1px solid #e1e4e8; }
.details-table td:first-child { width: 120pt; }
.line-items { margin-bottom: 20pt; }
.items-table { width: 100%; border-collapse: collapse; }
.items-table th { background: #f6f7f7; text-align: left; padding: 8pt; font-size: 9pt; border-bottom: 2px solid #c3c4c7; }
.items-table td { padding: 8pt; font-size: 9pt; border-bottom: 1px solid #e1e4e8; }
.items-table .qty, .items-table .price, .items-table .total { text-align: right; width: 70pt; }
.totals { margin-bottom: 20pt; }
.totals-table { width: 250pt; margin-left: auto; border-collapse: collapse; }
.totals-table td { padding: 6pt 8pt; font-size: 10pt; }
.totals-table td:last-child { text-align: right; }
.totals-table .grand-total td { border-top: 2px solid #333; font-size: 12pt; }
.payment-info { margin-bottom: 30pt; padding: 10pt; background: #f9f9f9; border-radius: 4pt; }
.payment-info p { margin: 4pt 0; font-size: 9pt; }
.payment-info .paid { color: #00a32a; font-weight: bold; }
.payment-info .unpaid { color: #dba617; font-weight: bold; }
.footer { text-align: center; margin-top: 40pt; padding-top: 15pt; border-top: 1px solid #c3c4c7; }
.footer p { font-size: 9pt; color: #787c82; margin: 3pt 0; }
.custom-footer { font-size: 8pt; }
';
}
/**
* Get business information from settings.
*
* @return array Business info.
*/
private static function get_business_info(): array {
return array(
'name' => get_option( 'wp_bnb_business_name', get_bloginfo( 'name' ) ),
'street' => get_option( 'wp_bnb_address_street', '' ),
'city' => get_option( 'wp_bnb_address_city', '' ),
'postal' => get_option( 'wp_bnb_address_postal', '' ),
'country' => get_option( 'wp_bnb_address_country', '' ),
'email' => get_option( 'wp_bnb_contact_email', get_option( 'admin_email' ) ),
'phone' => get_option( 'wp_bnb_contact_phone', '' ),
);
}
}

View File

@@ -0,0 +1,435 @@
<?php
/**
* WooCommerce Integration Manager.
*
* Main integration class that initializes WooCommerce features.
*
* @package Magdev\WpBnb\Integration\WooCommerce
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Integration\WooCommerce;
/**
* WooCommerce Integration Manager class.
*
* Manages the integration with WooCommerce for payment processing,
* invoice generation, order management, and refund handling.
*/
final class Manager {
/**
* Option key for enabling WooCommerce integration.
*/
public const OPTION_ENABLED = 'wp_bnb_wc_enabled';
/**
* Option key for auto-syncing products.
*/
public const OPTION_AUTO_SYNC = 'wp_bnb_wc_auto_sync_products';
/**
* Option key for auto-confirming bookings on payment.
*/
public const OPTION_AUTO_CONFIRM = 'wp_bnb_wc_auto_confirm_booking';
/**
* Option key for attaching invoices to emails.
*/
public const OPTION_INVOICE_ATTACH = 'wp_bnb_wc_invoice_attach';
/**
* Option key for product category.
*/
public const OPTION_PRODUCT_CATEGORY = 'wp_bnb_wc_product_category';
/**
* Option key for services as products.
*/
public const OPTION_SERVICES_AS_PRODUCTS = 'wp_bnb_wc_services_as_products';
/**
* Option key for invoice number prefix.
*/
public const OPTION_INVOICE_PREFIX = 'wp_bnb_invoice_prefix';
/**
* Option key for invoice starting number.
*/
public const OPTION_INVOICE_START_NUMBER = 'wp_bnb_invoice_start_number';
/**
* Option key for last invoice number.
*/
public const OPTION_INVOICE_LAST_NUMBER = 'wp_bnb_invoice_last_number';
/**
* Option key for invoice logo.
*/
public const OPTION_INVOICE_LOGO = 'wp_bnb_invoice_logo';
/**
* Option key for invoice footer text.
*/
public const OPTION_INVOICE_FOOTER = 'wp_bnb_invoice_footer';
/**
* Order meta key for linked booking ID.
*/
public const ORDER_BOOKING_META = '_bnb_booking_id';
/**
* Order meta key for linked room ID.
*/
public const ORDER_ROOM_META = '_bnb_room_id';
/**
* Booking meta key for linked WC order ID.
*/
public const BOOKING_ORDER_META = '_bnb_booking_wc_order_id';
/**
* Room meta key for linked WC product ID.
*/
public const ROOM_PRODUCT_META = '_bnb_wc_product_id';
/**
* Product meta key for linked room ID.
*/
public const PRODUCT_ROOM_META = '_bnb_room_id';
/**
* Cart item data key for booking data.
*/
public const CART_ITEM_KEY = 'bnb_booking_data';
/**
* Whether components have been initialized.
*
* @var bool
*/
private static bool $initialized = false;
/**
* Initialize the WooCommerce integration.
*
* @return void
*/
public static function init(): void {
// Prevent double initialization.
if ( self::$initialized ) {
return;
}
// Only proceed if WooCommerce is active.
if ( ! self::is_wc_active() ) {
return;
}
// Declare HPOS compatibility.
add_action( 'before_woocommerce_init', array( self::class, 'declare_hpos_compatibility' ) );
// Only initialize components if integration is enabled.
if ( ! self::is_enabled() ) {
return;
}
// Initialize sub-components after WooCommerce is fully loaded.
add_action( 'woocommerce_loaded', array( self::class, 'init_components' ) );
// Add admin notice if WooCommerce deactivated while integration enabled.
add_action( 'admin_notices', array( self::class, 'admin_notices' ) );
self::$initialized = true;
}
/**
* Declare High-Performance Order Storage (HPOS) compatibility.
*
* @return void
*/
public static function declare_hpos_compatibility(): void {
if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility(
'custom_order_tables',
WP_BNB_PATH . 'wp-bnb.php',
true
);
}
}
/**
* Initialize all WooCommerce integration components.
*
* @return void
*/
public static function init_components(): void {
// Product synchronization (room <-> WC product).
ProductSync::init();
// Cart handling (booking data, availability, pricing).
CartHandler::init();
// Checkout customization.
CheckoutHandler::init();
// Order handling (booking creation on payment).
OrderHandler::init();
// Invoice generation.
InvoiceGenerator::init();
// Refund handling.
RefundHandler::init();
// Admin column enhancements.
if ( is_admin() ) {
AdminColumns::init();
}
}
/**
* Check if WooCommerce is active.
*
* @return bool
*/
public static function is_wc_active(): bool {
return class_exists( 'WooCommerce' ) || class_exists( 'WC_Product' );
}
/**
* Check if WooCommerce integration is enabled.
*
* @return bool
*/
public static function is_enabled(): bool {
return 'yes' === get_option( self::OPTION_ENABLED, 'no' );
}
/**
* Enable WooCommerce integration.
*
* @return void
*/
public static function enable(): void {
update_option( self::OPTION_ENABLED, 'yes' );
}
/**
* Disable WooCommerce integration.
*
* @return void
*/
public static function disable(): void {
update_option( self::OPTION_ENABLED, 'no' );
}
/**
* Check if auto-sync products is enabled.
*
* @return bool
*/
public static function is_auto_sync_enabled(): bool {
return 'yes' === get_option( self::OPTION_AUTO_SYNC, 'yes' );
}
/**
* Check if auto-confirm booking is enabled.
*
* @return bool
*/
public static function is_auto_confirm_enabled(): bool {
return 'yes' === get_option( self::OPTION_AUTO_CONFIRM, 'yes' );
}
/**
* Check if invoice attachment is enabled.
*
* @return bool
*/
public static function is_invoice_attach_enabled(): bool {
return 'yes' === get_option( self::OPTION_INVOICE_ATTACH, 'yes' );
}
/**
* Get the WooCommerce product category for rooms.
*
* @return int Product category term ID, or 0 if not set.
*/
public static function get_product_category(): int {
return absint( get_option( self::OPTION_PRODUCT_CATEGORY, 0 ) );
}
/**
* Get invoice number prefix.
*
* @return string
*/
public static function get_invoice_prefix(): string {
return get_option( self::OPTION_INVOICE_PREFIX, 'INV-' );
}
/**
* Get invoice starting number.
*
* @return int
*/
public static function get_invoice_start_number(): int {
return absint( get_option( self::OPTION_INVOICE_START_NUMBER, 1000 ) );
}
/**
* Get and increment the next invoice number.
*
* @return string The next invoice number with prefix.
*/
public static function get_next_invoice_number(): string {
$last_number = absint( get_option( self::OPTION_INVOICE_LAST_NUMBER, 0 ) );
$start_number = self::get_invoice_start_number();
// Use start number if no invoices generated yet.
$next_number = ( 0 === $last_number ) ? $start_number : $last_number + 1;
// Update the last number.
update_option( self::OPTION_INVOICE_LAST_NUMBER, $next_number );
return self::get_invoice_prefix() . str_pad( (string) $next_number, 5, '0', STR_PAD_LEFT );
}
/**
* Get invoice logo attachment ID.
*
* @return int Attachment ID or 0.
*/
public static function get_invoice_logo(): int {
return absint( get_option( self::OPTION_INVOICE_LOGO, 0 ) );
}
/**
* Get invoice footer text.
*
* @return string
*/
public static function get_invoice_footer(): string {
return get_option( self::OPTION_INVOICE_FOOTER, '' );
}
/**
* Display admin notices.
*
* @return void
*/
public static function admin_notices(): void {
// Show notice if integration enabled but WC not active.
if ( self::is_enabled() && ! self::is_wc_active() ) {
?>
<div class="notice notice-warning is-dismissible">
<p>
<strong><?php esc_html_e( 'WP BnB:', 'wp-bnb' ); ?></strong>
<?php esc_html_e( 'WooCommerce integration is enabled but WooCommerce is not active. Please install and activate WooCommerce or disable the integration in WP BnB settings.', 'wp-bnb' ); ?>
</p>
</div>
<?php
}
}
/**
* Map WooCommerce order status to booking status.
*
* @param string $wc_status WooCommerce order status (without 'wc-' prefix).
* @return string Booking status.
*/
public static function map_wc_status_to_booking( string $wc_status ): string {
$map = array(
'pending' => 'pending',
'on-hold' => 'pending',
'processing' => self::is_auto_confirm_enabled() ? 'confirmed' : 'pending',
'completed' => 'confirmed',
'cancelled' => 'cancelled',
'refunded' => 'cancelled',
'failed' => 'cancelled',
);
/**
* Filter the WooCommerce to booking status mapping.
*
* @param array $map Status mapping array.
*/
$map = apply_filters( 'wp_bnb_wc_status_map', $map );
return $map[ $wc_status ] ?? 'pending';
}
/**
* Map booking status to WooCommerce order status.
*
* @param string $booking_status Booking status.
* @return string WooCommerce order status (without 'wc-' prefix).
*/
public static function map_booking_status_to_wc( string $booking_status ): string {
$map = array(
'pending' => 'on-hold',
'confirmed' => 'processing',
'checked_in' => 'processing',
'checked_out' => 'completed',
'cancelled' => 'cancelled',
);
return $map[ $booking_status ] ?? 'on-hold';
}
/**
* Get WooCommerce order for a booking.
*
* @param int $booking_id Booking post ID.
* @return \WC_Order|null WooCommerce order or null.
*/
public static function get_order_for_booking( int $booking_id ): ?\WC_Order {
$order_id = get_post_meta( $booking_id, self::BOOKING_ORDER_META, true );
if ( ! $order_id ) {
return null;
}
$order = wc_get_order( $order_id );
return $order instanceof \WC_Order ? $order : null;
}
/**
* Get booking ID for a WooCommerce order.
*
* @param \WC_Order|int $order WooCommerce order or order ID.
* @return int|null Booking post ID or null.
*/
public static function get_booking_for_order( $order ): ?int {
if ( is_int( $order ) ) {
$order = wc_get_order( $order );
}
if ( ! $order instanceof \WC_Order ) {
return null;
}
$booking_id = $order->get_meta( self::ORDER_BOOKING_META, true );
return $booking_id ? absint( $booking_id ) : null;
}
/**
* Link a booking to a WooCommerce order (bidirectional).
*
* @param int $booking_id Booking post ID.
* @param \WC_Order $order WooCommerce order.
* @return void
*/
public static function link_booking_to_order( int $booking_id, \WC_Order $order ): void {
// Store order ID in booking meta.
update_post_meta( $booking_id, self::BOOKING_ORDER_META, $order->get_id() );
// Store booking ID in order meta (HPOS compatible).
$order->update_meta_data( self::ORDER_BOOKING_META, $booking_id );
$order->save();
}
}

View File

@@ -0,0 +1,584 @@
<?php
/**
* WooCommerce Order Handler.
*
* Handles order-to-booking creation and status synchronization.
*
* @package Magdev\WpBnb\Integration\WooCommerce
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Integration\WooCommerce;
use Magdev\WpBnb\Booking\EmailNotifier;
use Magdev\WpBnb\PostTypes\Booking;
use Magdev\WpBnb\PostTypes\Guest;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\Pricing\Calculator;
/**
* Order Handler class.
*
* Creates bookings from WooCommerce orders on payment completion
* and synchronizes order/booking statuses.
*/
final class OrderHandler {
/**
* Flag to prevent recursive status updates.
*
* @var bool
*/
private static bool $updating_status = false;
/**
* Initialize order handler.
*
* @return void
*/
public static function init(): void {
// Create booking on payment completion.
add_action( 'woocommerce_payment_complete', array( self::class, 'on_payment_complete' ) );
// Also handle manual order status changes to processing/completed.
add_action( 'woocommerce_order_status_processing', array( self::class, 'on_order_processing' ) );
add_action( 'woocommerce_order_status_completed', array( self::class, 'on_order_completed' ) );
// Sync order status changes to booking.
add_action( 'woocommerce_order_status_changed', array( self::class, 'sync_order_status_to_booking' ), 10, 4 );
// Display booking info in admin order page.
add_action( 'woocommerce_admin_order_data_after_billing_address', array( self::class, 'display_booking_info_admin' ) );
// Display booking info on thank you page.
add_action( 'woocommerce_thankyou', array( self::class, 'display_booking_info_thankyou' ) );
// Display booking info in order details.
add_action( 'woocommerce_order_details_after_order_table', array( self::class, 'display_booking_info_order_details' ) );
}
/**
* Handle payment completion.
*
* @param int $order_id Order ID.
* @return void
*/
public static function on_payment_complete( int $order_id ): void {
self::maybe_create_booking( $order_id );
}
/**
* Handle order status change to processing.
*
* @param int $order_id Order ID.
* @return void
*/
public static function on_order_processing( int $order_id ): void {
self::maybe_create_booking( $order_id );
}
/**
* Handle order status change to completed.
*
* @param int $order_id Order ID.
* @return void
*/
public static function on_order_completed( int $order_id ): void {
self::maybe_create_booking( $order_id );
}
/**
* Create booking from order if not already created.
*
* @param int $order_id Order ID.
* @return void
*/
private static function maybe_create_booking( int $order_id ): void {
$order = wc_get_order( $order_id );
if ( ! $order instanceof \WC_Order ) {
return;
}
// Check if booking already created.
$existing_booking = Manager::get_booking_for_order( $order );
if ( $existing_booking ) {
return;
}
// Check if order has room bookings.
$has_bookings = false;
foreach ( $order->get_items() as $item ) {
$room_id = $item->get_meta( '_bnb_room_id', true );
if ( $room_id ) {
$has_bookings = true;
break;
}
}
if ( ! $has_bookings ) {
return;
}
// Create booking.
self::create_booking_from_order( $order );
}
/**
* Create booking(s) from WooCommerce order.
*
* @param \WC_Order $order WooCommerce order.
* @return int|null Booking ID or null on failure.
*/
public static function create_booking_from_order( \WC_Order $order ): ?int {
/**
* Fires before creating a booking from an order.
*
* @param \WC_Order $order WooCommerce order.
*/
do_action( 'wp_bnb_wc_before_booking_from_order', $order );
// Find or create guest.
$guest_id = self::find_or_create_guest( $order );
// Get booking data from order items.
$booking_id = null;
foreach ( $order->get_items() as $item ) {
$room_id = $item->get_meta( '_bnb_room_id', true );
if ( ! $room_id ) {
continue;
}
$check_in = $item->get_meta( '_bnb_check_in', true );
$check_out = $item->get_meta( '_bnb_check_out', true );
$guests = $item->get_meta( '_bnb_guests', true );
$nights = $item->get_meta( '_bnb_nights', true );
$services = $item->get_meta( '_bnb_services', true );
$breakdown = $item->get_meta( '_bnb_price_breakdown', true );
// Decode JSON if necessary.
if ( is_string( $services ) ) {
$services = json_decode( $services, true ) ?: array();
}
if ( is_string( $breakdown ) ) {
$breakdown = json_decode( $breakdown, true ) ?: array();
}
// Determine initial status.
$status = Manager::is_auto_confirm_enabled() ? 'confirmed' : 'pending';
// Get guest notes.
$guest_notes = $order->get_meta( '_bnb_guest_notes', true );
// Create booking post.
$booking_data = array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'publish',
'post_title' => self::generate_booking_title( $guest_id, $check_in, $check_out ),
);
/**
* Filter the booking data before creation.
*
* @param array $booking_data Booking post data.
* @param \WC_Order $order WooCommerce order.
*/
$booking_data = apply_filters( 'wp_bnb_wc_booking_from_order_data', $booking_data, $order );
$booking_id = wp_insert_post( $booking_data );
if ( ! $booking_id || is_wp_error( $booking_id ) ) {
continue;
}
// Store booking meta.
update_post_meta( $booking_id, '_bnb_booking_room_id', $room_id );
update_post_meta( $booking_id, '_bnb_booking_check_in', $check_in );
update_post_meta( $booking_id, '_bnb_booking_check_out', $check_out );
update_post_meta( $booking_id, '_bnb_booking_status', $status );
update_post_meta( $booking_id, '_bnb_booking_adults', max( 1, (int) $guests ) );
update_post_meta( $booking_id, '_bnb_booking_children', 0 );
update_post_meta( $booking_id, '_bnb_booking_guest_notes', $guest_notes );
update_post_meta( $booking_id, '_bnb_booking_source', 'woocommerce_order_' . $order->get_id() );
// Store guest info.
if ( $guest_id ) {
update_post_meta( $booking_id, '_bnb_booking_guest_id', $guest_id );
update_post_meta( $booking_id, '_bnb_booking_guest_name', Guest::get_full_name( $guest_id ) );
update_post_meta( $booking_id, '_bnb_booking_guest_email', get_post_meta( $guest_id, '_bnb_guest_email', true ) );
update_post_meta( $booking_id, '_bnb_booking_guest_phone', get_post_meta( $guest_id, '_bnb_guest_phone', true ) );
} else {
// Use order billing info.
$guest_name = $order->get_billing_first_name() . ' ' . $order->get_billing_last_name();
update_post_meta( $booking_id, '_bnb_booking_guest_name', $guest_name );
update_post_meta( $booking_id, '_bnb_booking_guest_email', $order->get_billing_email() );
update_post_meta( $booking_id, '_bnb_booking_guest_phone', $order->get_billing_phone() );
}
// Store pricing.
$total = $item->get_total();
update_post_meta( $booking_id, '_bnb_booking_calculated_price', $total );
if ( ! empty( $breakdown ) ) {
update_post_meta( $booking_id, '_bnb_booking_price_breakdown', $breakdown );
}
// Store services.
if ( ! empty( $services ) ) {
update_post_meta( $booking_id, Booking::SERVICES_META_KEY, $services );
}
// Generate booking reference.
$reference = self::generate_booking_reference( $booking_id );
update_post_meta( $booking_id, '_bnb_booking_reference', $reference );
// Store confirmed timestamp if auto-confirmed.
if ( 'confirmed' === $status ) {
update_post_meta( $booking_id, '_bnb_booking_confirmed_at', current_time( 'mysql' ) );
}
// Link booking to order.
Manager::link_booking_to_order( $booking_id, $order );
// Also store room ID in order meta for quick access.
$order->update_meta_data( Manager::ORDER_ROOM_META, $room_id );
// Store check-in/out in order meta.
$order->update_meta_data( '_bnb_check_in', $check_in );
$order->update_meta_data( '_bnb_check_out', $check_out );
$order->save();
// Trigger status change action for email notifications.
if ( 'confirmed' === $status ) {
/**
* Fires when booking status changes (for email notifications).
*
* @param int $booking_id Booking post ID.
* @param string $old_status Old status.
* @param string $new_status New status.
*/
do_action( 'wp_bnb_booking_status_changed', $booking_id, 'pending', 'confirmed' );
}
}
/**
* Fires after creating a booking from an order.
*
* @param int|null $booking_id Last booking ID created.
* @param \WC_Order $order WooCommerce order.
*/
do_action( 'wp_bnb_wc_after_booking_from_order', $booking_id, $order );
return $booking_id;
}
/**
* Sync order status changes to booking status.
*
* @param int $order_id Order ID.
* @param string $old_status Old status.
* @param string $new_status New status.
* @param \WC_Order $order Order object.
* @return void
*/
public static function sync_order_status_to_booking( int $order_id, string $old_status, string $new_status, \WC_Order $order ): void {
// Prevent recursive updates.
if ( self::$updating_status ) {
return;
}
$booking_id = Manager::get_booking_for_order( $order );
if ( ! $booking_id ) {
return;
}
// Map WC status to booking status.
$booking_status = Manager::map_wc_status_to_booking( $new_status );
// Get current booking status.
$current_booking_status = get_post_meta( $booking_id, '_bnb_booking_status', true );
// Don't update if status is the same.
if ( $current_booking_status === $booking_status ) {
return;
}
// Don't downgrade from checked_in/checked_out.
if ( in_array( $current_booking_status, array( 'checked_in', 'checked_out' ), true ) ) {
return;
}
self::$updating_status = true;
// Update booking status.
update_post_meta( $booking_id, '_bnb_booking_status', $booking_status );
// Update confirmed timestamp if confirming.
if ( 'confirmed' === $booking_status && 'pending' === $current_booking_status ) {
update_post_meta( $booking_id, '_bnb_booking_confirmed_at', current_time( 'mysql' ) );
}
// Trigger booking status changed action.
do_action( 'wp_bnb_booking_status_changed', $booking_id, $current_booking_status, $booking_status );
self::$updating_status = false;
}
/**
* Find or create guest from order data.
*
* @param \WC_Order $order WooCommerce order.
* @return int|null Guest ID or null.
*/
private static function find_or_create_guest( \WC_Order $order ): ?int {
$email = $order->get_billing_email();
if ( ! $email ) {
return null;
}
// Try to find existing guest by email.
$existing_guests = get_posts(
array(
'post_type' => Guest::POST_TYPE,
'posts_per_page' => 1,
'post_status' => 'publish',
'meta_query' => array(
array(
'key' => '_bnb_guest_email',
'value' => $email,
),
),
)
);
if ( ! empty( $existing_guests ) ) {
return $existing_guests[0]->ID;
}
// Create new guest.
$first_name = $order->get_billing_first_name();
$last_name = $order->get_billing_last_name();
$full_name = trim( $first_name . ' ' . $last_name );
$guest_data = array(
'post_type' => Guest::POST_TYPE,
'post_status' => 'publish',
'post_title' => $full_name ?: $email,
);
$guest_id = wp_insert_post( $guest_data );
if ( ! $guest_id || is_wp_error( $guest_id ) ) {
return null;
}
// Store guest meta.
update_post_meta( $guest_id, '_bnb_guest_first_name', $first_name );
update_post_meta( $guest_id, '_bnb_guest_last_name', $last_name );
update_post_meta( $guest_id, '_bnb_guest_email', $email );
update_post_meta( $guest_id, '_bnb_guest_phone', $order->get_billing_phone() );
update_post_meta( $guest_id, '_bnb_guest_address', $order->get_billing_address_1() );
update_post_meta( $guest_id, '_bnb_guest_city', $order->get_billing_city() );
update_post_meta( $guest_id, '_bnb_guest_postal_code', $order->get_billing_postcode() );
update_post_meta( $guest_id, '_bnb_guest_country', $order->get_billing_country() );
update_post_meta( $guest_id, '_bnb_guest_status', 'active' );
update_post_meta( $guest_id, '_bnb_guest_source', 'woocommerce' );
return $guest_id;
}
/**
* Generate booking title.
*
* @param int|null $guest_id Guest ID.
* @param string $check_in Check-in date.
* @param string $check_out Check-out date.
* @return string Booking title.
*/
private static function generate_booking_title( ?int $guest_id, string $check_in, string $check_out ): string {
$guest_name = $guest_id ? Guest::get_full_name( $guest_id ) : __( 'Guest', 'wp-bnb' );
$check_in_date = \DateTime::createFromFormat( 'Y-m-d', $check_in );
$check_out_date = \DateTime::createFromFormat( 'Y-m-d', $check_out );
if ( ! $check_in_date || ! $check_out_date ) {
return $guest_name;
}
// Format: "Guest Name (DD.MM - DD.MM.YYYY)" or span years.
if ( $check_in_date->format( 'Y' ) === $check_out_date->format( 'Y' ) ) {
return sprintf(
'%s (%s - %s)',
$guest_name,
$check_in_date->format( 'd.m' ),
$check_out_date->format( 'd.m.Y' )
);
}
return sprintf(
'%s (%s - %s)',
$guest_name,
$check_in_date->format( 'd.m.Y' ),
$check_out_date->format( 'd.m.Y' )
);
}
/**
* Generate booking reference.
*
* @param int $booking_id Booking ID.
* @return string Booking reference.
*/
private static function generate_booking_reference( int $booking_id ): string {
return sprintf(
'BNB-%s-%05d',
gmdate( 'Y' ),
$booking_id
);
}
/**
* Display booking info in admin order page.
*
* @param \WC_Order $order WooCommerce order.
* @return void
*/
public static function display_booking_info_admin( \WC_Order $order ): void {
$booking_id = Manager::get_booking_for_order( $order );
if ( ! $booking_id ) {
return;
}
$booking = get_post( $booking_id );
$status = get_post_meta( $booking_id, '_bnb_booking_status', true );
$room_id = get_post_meta( $booking_id, '_bnb_booking_room_id', true );
$room = $room_id ? get_post( $room_id ) : null;
?>
<div class="order_data_column bnb-order-booking-info">
<h3><?php esc_html_e( 'Booking Information', 'wp-bnb' ); ?></h3>
<p>
<strong><?php esc_html_e( 'Booking:', 'wp-bnb' ); ?></strong>
<a href="<?php echo esc_url( get_edit_post_link( $booking_id ) ); ?>">
<?php echo esc_html( $booking ? $booking->post_title : "#{$booking_id}" ); ?>
</a>
</p>
<?php if ( $room ) : ?>
<p>
<strong><?php esc_html_e( 'Room:', 'wp-bnb' ); ?></strong>
<a href="<?php echo esc_url( get_edit_post_link( $room_id ) ); ?>">
<?php echo esc_html( $room->post_title ); ?>
</a>
</p>
<?php endif; ?>
<p>
<strong><?php esc_html_e( 'Status:', 'wp-bnb' ); ?></strong>
<span class="bnb-status-badge bnb-status-<?php echo esc_attr( $status ); ?>">
<?php echo esc_html( ucfirst( str_replace( '_', ' ', $status ) ) ); ?>
</span>
</p>
</div>
<?php
}
/**
* Display booking info on thank you page.
*
* @param int $order_id Order ID.
* @return void
*/
public static function display_booking_info_thankyou( int $order_id ): void {
$order = wc_get_order( $order_id );
if ( ! $order ) {
return;
}
$booking_id = Manager::get_booking_for_order( $order );
if ( ! $booking_id ) {
return;
}
$reference = get_post_meta( $booking_id, '_bnb_booking_reference', true );
$check_in = get_post_meta( $booking_id, '_bnb_booking_check_in', true );
$check_out = get_post_meta( $booking_id, '_bnb_booking_check_out', true );
$room_id = get_post_meta( $booking_id, '_bnb_booking_room_id', true );
$room = $room_id ? get_post( $room_id ) : null;
$building = $room_id ? Room::get_building( $room_id ) : null;
$date_format = get_option( 'date_format' );
$check_in_date = \DateTime::createFromFormat( 'Y-m-d', $check_in );
$check_out_date = \DateTime::createFromFormat( 'Y-m-d', $check_out );
?>
<section class="woocommerce-booking-confirmation">
<h2><?php esc_html_e( 'Booking Confirmed', 'wp-bnb' ); ?></h2>
<table class="woocommerce-table woocommerce-table--booking-details">
<tbody>
<tr>
<th><?php esc_html_e( 'Booking Reference:', 'wp-bnb' ); ?></th>
<td><strong><?php echo esc_html( $reference ); ?></strong></td>
</tr>
<?php if ( $room ) : ?>
<tr>
<th><?php esc_html_e( 'Room:', 'wp-bnb' ); ?></th>
<td>
<?php echo esc_html( $room->post_title ); ?>
<?php if ( $building ) : ?>
<br><small><?php echo esc_html( $building->post_title ); ?></small>
<?php endif; ?>
</td>
</tr>
<?php endif; ?>
<tr>
<th><?php esc_html_e( 'Check-in:', 'wp-bnb' ); ?></th>
<td><?php echo esc_html( $check_in_date ? $check_in_date->format( $date_format ) : $check_in ); ?></td>
</tr>
<tr>
<th><?php esc_html_e( 'Check-out:', 'wp-bnb' ); ?></th>
<td><?php echo esc_html( $check_out_date ? $check_out_date->format( $date_format ) : $check_out ); ?></td>
</tr>
</tbody>
</table>
<p class="woocommerce-notice woocommerce-notice--success">
<?php esc_html_e( 'Your booking has been confirmed. A confirmation email has been sent to your email address.', 'wp-bnb' ); ?>
</p>
</section>
<?php
}
/**
* Display booking info in order details (customer account).
*
* @param \WC_Order $order WooCommerce order.
* @return void
*/
public static function display_booking_info_order_details( \WC_Order $order ): void {
$booking_id = Manager::get_booking_for_order( $order );
if ( ! $booking_id ) {
return;
}
$reference = get_post_meta( $booking_id, '_bnb_booking_reference', true );
$status = get_post_meta( $booking_id, '_bnb_booking_status', true );
if ( $reference ) {
?>
<p class="woocommerce-booking-reference">
<strong><?php esc_html_e( 'Booking Reference:', 'wp-bnb' ); ?></strong>
<?php echo esc_html( $reference ); ?>
<span class="bnb-status-badge bnb-status-<?php echo esc_attr( $status ); ?>">
<?php echo esc_html( ucfirst( str_replace( '_', ' ', $status ) ) ); ?>
</span>
</p>
<?php
}
}
}

View File

@@ -0,0 +1,515 @@
<?php
/**
* WooCommerce Product Synchronization.
*
* Synchronizes rooms with WooCommerce products.
*
* @package Magdev\WpBnb\Integration\WooCommerce
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Integration\WooCommerce;
use Magdev\WpBnb\PostTypes\Building;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\Pricing\Calculator;
use Magdev\WpBnb\Pricing\PricingTier;
use Magdev\WpBnb\Taxonomies\Amenity;
use Magdev\WpBnb\Taxonomies\RoomType;
/**
* Product Synchronization class.
*
* Creates and maintains WooCommerce products that correspond to BnB rooms.
*/
final class ProductSync {
/**
* Initialize product synchronization.
*
* @return void
*/
public static function init(): void {
// Sync product when room is saved.
add_action( 'save_post_' . Room::POST_TYPE, array( self::class, 'on_room_save' ), 20, 2 );
// Delete product when room is deleted.
add_action( 'before_delete_post', array( self::class, 'on_room_delete' ) );
// Add linked room info to product edit screen.
add_action( 'woocommerce_product_options_general_product_data', array( self::class, 'add_product_room_info' ) );
// AJAX handler for syncing all rooms.
add_action( 'wp_ajax_wp_bnb_sync_all_rooms', array( self::class, 'ajax_sync_all_rooms' ) );
}
/**
* Handle room save - create or update product.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @return void
*/
public static function on_room_save( int $post_id, \WP_Post $post ): void {
// Skip autosaves, revisions, and other non-published posts.
if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
return;
}
if ( 'publish' !== $post->post_status ) {
return;
}
// Check if auto-sync is enabled.
if ( ! Manager::is_auto_sync_enabled() ) {
return;
}
// Sync the product.
self::sync_room_to_product( $post_id );
}
/**
* Handle room deletion - delete linked product.
*
* @param int $post_id Post ID.
* @return void
*/
public static function on_room_delete( int $post_id ): void {
$post = get_post( $post_id );
if ( ! $post || Room::POST_TYPE !== $post->post_type ) {
return;
}
self::delete_product_for_room( $post_id );
}
/**
* Sync a room to a WooCommerce product.
*
* Creates a new product if one doesn't exist, or updates existing one.
*
* @param int $room_id Room post ID.
* @return int|null Product ID or null on failure.
*/
public static function sync_room_to_product( int $room_id ): ?int {
$room = get_post( $room_id );
if ( ! $room || Room::POST_TYPE !== $room->post_type ) {
return null;
}
// Check for existing product.
$product_id = self::get_product_for_room( $room_id );
if ( $product_id ) {
// Update existing product.
return self::update_product( $product_id, $room );
}
// Create new product.
return self::create_product_for_room( $room );
}
/**
* Create a WooCommerce product for a room.
*
* @param \WP_Post $room Room post object.
* @return int|null Product ID or null on failure.
*/
public static function create_product_for_room( \WP_Post $room ): ?int {
/**
* Fires before creating a WC product for a room.
*
* @param int $room_id Room post ID.
*/
do_action( 'wp_bnb_wc_before_product_sync', $room->ID );
// Create a simple virtual product.
$product = new \WC_Product_Simple();
// Configure the product.
self::configure_product( $product, $room );
// Save the product.
$product_id = $product->save();
if ( ! $product_id ) {
return null;
}
// Store bidirectional links.
update_post_meta( $room->ID, Manager::ROOM_PRODUCT_META, $product_id );
update_post_meta( $product_id, Manager::PRODUCT_ROOM_META, $room->ID );
/**
* Fires after creating a WC product for a room.
*
* @param int $room_id Room post ID.
* @param int $product_id WC product ID.
*/
do_action( 'wp_bnb_wc_after_product_sync', $room->ID, $product_id );
return $product_id;
}
/**
* Update an existing WooCommerce product.
*
* @param int $product_id Product ID.
* @param \WP_Post $room Room post object.
* @return int|null Product ID or null on failure.
*/
private static function update_product( int $product_id, \WP_Post $room ): ?int {
$product = wc_get_product( $product_id );
if ( ! $product ) {
// Product was deleted, create a new one.
return self::create_product_for_room( $room );
}
/**
* Fires before updating a WC product for a room.
*
* @param int $room_id Room post ID.
*/
do_action( 'wp_bnb_wc_before_product_sync', $room->ID );
// Configure the product.
self::configure_product( $product, $room );
// Save the product.
$product->save();
/**
* Fires after updating a WC product for a room.
*
* @param int $room_id Room post ID.
* @param int $product_id WC product ID.
*/
do_action( 'wp_bnb_wc_after_product_sync', $room->ID, $product_id );
return $product_id;
}
/**
* Configure a WooCommerce product from room data.
*
* @param \WC_Product $product Product object.
* @param \WP_Post $room Room post object.
* @return void
*/
private static function configure_product( \WC_Product $product, \WP_Post $room ): void {
// Get room data.
$building = Room::get_building( $room->ID );
$building_name = $building ? $building->post_title : '';
$pricing = Calculator::getRoomPricing( $room->ID );
// Basic info.
$product->set_name( $room->post_title );
$product->set_slug( 'bnb-room-' . $room->ID );
$product->set_status( 'publish' );
// SKU.
$product->set_sku( 'bnb-room-' . $room->ID );
// Virtual product (no shipping).
$product->set_virtual( true );
// Description.
$description = $room->post_content;
if ( $building_name ) {
$description = sprintf(
/* translators: %s: Building name */
__( 'Room at %s', 'wp-bnb' ),
$building_name
) . "\n\n" . $description;
}
$product->set_description( $description );
$product->set_short_description( $room->post_excerpt ?: wp_trim_words( $room->post_content, 30 ) );
// Price (use short-term/nightly rate as base).
$base_price = $pricing[ PricingTier::SHORT_TERM->value ]['price'] ?? 0;
if ( $base_price > 0 ) {
$product->set_regular_price( (string) $base_price );
}
// Featured image.
$thumbnail_id = get_post_thumbnail_id( $room->ID );
if ( $thumbnail_id ) {
$product->set_image_id( $thumbnail_id );
}
// Gallery images.
$gallery_ids = get_post_meta( $room->ID, '_bnb_room_gallery', true );
if ( $gallery_ids ) {
$ids = array_filter( explode( ',', $gallery_ids ), 'is_numeric' );
$product->set_gallery_image_ids( array_map( 'absint', $ids ) );
}
// Stock management (disabled - availability handled by booking system).
$product->set_manage_stock( false );
$product->set_stock_status( 'instock' );
// Catalog visibility - visible.
$product->set_catalog_visibility( 'visible' );
// Product category.
$category_id = Manager::get_product_category();
if ( $category_id ) {
$product->set_category_ids( array( $category_id ) );
}
// Store room metadata.
$capacity = get_post_meta( $room->ID, '_bnb_room_capacity', true );
$beds = get_post_meta( $room->ID, '_bnb_room_beds', true );
$product->update_meta_data( '_bnb_room_capacity', $capacity );
$product->update_meta_data( '_bnb_room_beds', $beds );
$product->update_meta_data( '_bnb_building_id', $building ? $building->ID : 0 );
$product->update_meta_data( '_bnb_building_name', $building_name );
/**
* Filter the product data before save.
*
* @param array $data Product data array.
* @param int $room_id Room post ID.
* @param \WP_Post $room Room post object.
*/
$data = apply_filters(
'wp_bnb_wc_product_data',
array(
'name' => $product->get_name(),
'price' => $product->get_regular_price(),
'description' => $product->get_description(),
),
$room->ID,
$room
);
// Apply filtered data.
if ( isset( $data['name'] ) ) {
$product->set_name( $data['name'] );
}
if ( isset( $data['price'] ) ) {
$product->set_regular_price( (string) $data['price'] );
}
if ( isset( $data['description'] ) ) {
$product->set_description( $data['description'] );
}
}
/**
* Delete the WooCommerce product for a room.
*
* @param int $room_id Room post ID.
* @return bool True if deleted, false otherwise.
*/
public static function delete_product_for_room( int $room_id ): bool {
$product_id = self::get_product_for_room( $room_id );
if ( ! $product_id ) {
return false;
}
$product = wc_get_product( $product_id );
if ( ! $product ) {
return false;
}
// Delete the product (force delete, not trash).
$product->delete( true );
// Clean up room meta.
delete_post_meta( $room_id, Manager::ROOM_PRODUCT_META );
return true;
}
/**
* Get the WooCommerce product ID for a room.
*
* @param int $room_id Room post ID.
* @return int|null Product ID or null.
*/
public static function get_product_for_room( int $room_id ): ?int {
$product_id = get_post_meta( $room_id, Manager::ROOM_PRODUCT_META, true );
if ( ! $product_id ) {
return null;
}
// Verify product still exists.
$product = wc_get_product( $product_id );
if ( ! $product ) {
// Clean up stale meta.
delete_post_meta( $room_id, Manager::ROOM_PRODUCT_META );
return null;
}
return absint( $product_id );
}
/**
* Get the room ID for a WooCommerce product.
*
* @param int $product_id Product ID.
* @return int|null Room ID or null.
*/
public static function get_room_for_product( int $product_id ): ?int {
$room_id = get_post_meta( $product_id, Manager::PRODUCT_ROOM_META, true );
if ( ! $room_id ) {
return null;
}
// Verify room still exists.
$room = get_post( $room_id );
if ( ! $room || Room::POST_TYPE !== $room->post_type ) {
// Clean up stale meta.
delete_post_meta( $product_id, Manager::PRODUCT_ROOM_META );
return null;
}
return absint( $room_id );
}
/**
* Sync all published rooms to WooCommerce products.
*
* @return array{created: int, updated: int, errors: array<string>}
*/
public static function sync_all_rooms(): array {
$result = array(
'created' => 0,
'updated' => 0,
'errors' => array(),
);
$rooms = get_posts(
array(
'post_type' => Room::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
)
);
foreach ( $rooms as $room_id ) {
$existing_product = self::get_product_for_room( $room_id );
$product_id = self::sync_room_to_product( $room_id );
if ( $product_id ) {
if ( $existing_product ) {
++$result['updated'];
} else {
++$result['created'];
}
} else {
$room = get_post( $room_id );
$result['errors'][] = sprintf(
/* translators: %s: Room title */
__( 'Failed to sync room: %s', 'wp-bnb' ),
$room ? $room->post_title : "#{$room_id}"
);
}
}
return $result;
}
/**
* AJAX handler for syncing all rooms.
*
* @return void
*/
public static function ajax_sync_all_rooms(): void {
check_ajax_referer( 'wp_bnb_admin_nonce', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'wp-bnb' ) ) );
}
$result = self::sync_all_rooms();
wp_send_json_success(
array(
'message' => sprintf(
/* translators: 1: Created count, 2: Updated count */
__( 'Sync complete. Created: %1$d, Updated: %2$d', 'wp-bnb' ),
$result['created'],
$result['updated']
),
'created' => $result['created'],
'updated' => $result['updated'],
'errors' => $result['errors'],
)
);
}
/**
* Add linked room info to WooCommerce product edit screen.
*
* @return void
*/
public static function add_product_room_info(): void {
global $post;
if ( ! $post ) {
return;
}
$room_id = self::get_room_for_product( $post->ID );
if ( ! $room_id ) {
return;
}
$room = get_post( $room_id );
if ( ! $room ) {
return;
}
?>
<div class="options_group show_if_simple">
<p class="form-field">
<label><?php esc_html_e( 'Linked BnB Room', 'wp-bnb' ); ?></label>
<span class="description">
<a href="<?php echo esc_url( get_edit_post_link( $room_id ) ); ?>">
<?php echo esc_html( $room->post_title ); ?>
</a>
<br>
<small><?php esc_html_e( 'This product is automatically synced from the linked room. Changes should be made in the room settings.', 'wp-bnb' ); ?></small>
</span>
</p>
</div>
<?php
}
/**
* Check if a product is a BnB room product.
*
* @param int|\WC_Product $product Product ID or object.
* @return bool
*/
public static function is_room_product( $product ): bool {
if ( is_int( $product ) ) {
$product = wc_get_product( $product );
}
if ( ! $product instanceof \WC_Product ) {
return false;
}
$room_id = $product->get_meta( Manager::PRODUCT_ROOM_META, true );
return ! empty( $room_id );
}
}

View File

@@ -0,0 +1,394 @@
<?php
/**
* WooCommerce Refund Handler.
*
* Handles refund processing and booking cancellation.
*
* @package Magdev\WpBnb\Integration\WooCommerce
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Integration\WooCommerce;
use Magdev\WpBnb\Booking\EmailNotifier;
use Magdev\WpBnb\PostTypes\Booking;
/**
* Refund Handler class.
*
* Processes refunds and updates booking status accordingly.
*/
final class RefundHandler {
/**
* Booking meta key for refund amount.
*/
public const REFUND_AMOUNT_META = '_bnb_booking_refund_amount';
/**
* Booking meta key for refund reason.
*/
public const REFUND_REASON_META = '_bnb_booking_refund_reason';
/**
* Booking meta key for refund date.
*/
public const REFUND_DATE_META = '_bnb_booking_refund_date';
/**
* Initialize refund handler.
*
* @return void
*/
public static function init(): void {
// Handle refund creation.
add_action( 'woocommerce_refund_created', array( self::class, 'on_refund_created' ), 10, 2 );
// Handle order fully refunded.
add_action( 'woocommerce_order_fully_refunded', array( self::class, 'on_order_fully_refunded' ), 10, 2 );
// Add refund notice in admin order page.
add_action( 'woocommerce_admin_order_totals_after_refunded', array( self::class, 'add_booking_refund_notice' ) );
}
/**
* Handle refund creation.
*
* @param int $refund_id Refund ID.
* @param array $args Refund arguments.
* @return void
*/
public static function on_refund_created( int $refund_id, array $args ): void {
$order_id = $args['order_id'] ?? 0;
if ( ! $order_id ) {
return;
}
$order = wc_get_order( $order_id );
if ( ! $order instanceof \WC_Order ) {
return;
}
// Check if order has a linked booking.
$booking_id = Manager::get_booking_for_order( $order );
if ( ! $booking_id ) {
return;
}
$refund_amount = abs( floatval( $args['amount'] ?? 0 ) );
$refund_reason = $args['reason'] ?? '';
/**
* Fires before processing a refund for a booking.
*
* @param \WC_Order $order WooCommerce order.
* @param float $refund_amount Refund amount.
*/
do_action( 'wp_bnb_wc_before_refund_process', $order, $refund_amount );
// Check if this is a full or partial refund.
$is_full_refund = self::is_full_refund( $order );
if ( $is_full_refund ) {
// Full refund - cancel the booking.
self::cancel_booking_on_refund( $booking_id, $refund_amount, $refund_reason );
} else {
// Partial refund - store refund info but don't cancel.
self::record_partial_refund( $booking_id, $refund_amount, $refund_reason );
}
/**
* Fires after processing a refund for a booking.
*
* @param \WC_Order $order WooCommerce order.
* @param int $booking_id Booking ID.
* @param bool $cancelled Whether booking was cancelled.
*/
do_action( 'wp_bnb_wc_after_refund_process', $order, $booking_id, $is_full_refund );
}
/**
* Handle order fully refunded.
*
* @param int $order_id Order ID.
* @param int $refund_id Refund ID.
* @return void
*/
public static function on_order_fully_refunded( int $order_id, int $refund_id ): void {
$order = wc_get_order( $order_id );
if ( ! $order instanceof \WC_Order ) {
return;
}
$booking_id = Manager::get_booking_for_order( $order );
if ( ! $booking_id ) {
return;
}
// Get current booking status.
$current_status = get_post_meta( $booking_id, '_bnb_booking_status', true );
// Don't cancel if already cancelled.
if ( 'cancelled' === $current_status ) {
return;
}
// Cancel the booking.
$total_refunded = $order->get_total_refunded();
self::cancel_booking_on_refund( $booking_id, $total_refunded, __( 'Order fully refunded', 'wp-bnb' ) );
}
/**
* Check if the order is fully refunded.
*
* @param \WC_Order $order WooCommerce order.
* @return bool
*/
public static function is_full_refund( \WC_Order $order ): bool {
$order_total = floatval( $order->get_total() );
$total_refunded = floatval( $order->get_total_refunded() );
// Consider it full refund if refunded amount >= order total.
return $total_refunded >= $order_total;
}
/**
* Cancel booking on full refund.
*
* @param int $booking_id Booking ID.
* @param float $refund_amount Refund amount.
* @param string $reason Refund reason.
* @return void
*/
public static function cancel_booking_on_refund( int $booking_id, float $refund_amount, string $reason ): void {
// Get current status.
$old_status = get_post_meta( $booking_id, '_bnb_booking_status', true );
// Don't cancel if already cancelled.
if ( 'cancelled' === $old_status ) {
// Just update refund info.
self::record_refund_meta( $booking_id, $refund_amount, $reason );
return;
}
/**
* Filter whether to cancel booking on refund.
*
* @param bool $cancel Whether to cancel.
* @param \WC_Order $order WooCommerce order.
* @param float $refund_amount Refund amount.
*/
$should_cancel = apply_filters( 'wp_bnb_wc_should_cancel_on_refund', true, $booking_id, $refund_amount );
if ( ! $should_cancel ) {
self::record_partial_refund( $booking_id, $refund_amount, $reason );
return;
}
// Update booking status to cancelled.
update_post_meta( $booking_id, '_bnb_booking_status', 'cancelled' );
// Store refund information.
self::record_refund_meta( $booking_id, $refund_amount, $reason );
// Add cancellation note.
$note = sprintf(
/* translators: %s: Refund amount */
__( 'Booking cancelled due to WooCommerce refund (%s)', 'wp-bnb' ),
wc_price( $refund_amount )
);
$existing_notes = get_post_meta( $booking_id, '_bnb_booking_notes', true );
$new_notes = $existing_notes ? $existing_notes . "\n\n" . $note : $note;
update_post_meta( $booking_id, '_bnb_booking_notes', $new_notes );
/**
* Fires when booking status changes (triggers email notifications).
*
* @param int $booking_id Booking ID.
* @param string $old_status Old status.
* @param string $new_status New status.
*/
do_action( 'wp_bnb_booking_status_changed', $booking_id, $old_status, 'cancelled' );
}
/**
* Record partial refund without cancelling.
*
* @param int $booking_id Booking ID.
* @param float $refund_amount Refund amount.
* @param string $reason Refund reason.
* @return void
*/
private static function record_partial_refund( int $booking_id, float $refund_amount, string $reason ): void {
// Get existing refund amount and add to it.
$existing_refund = floatval( get_post_meta( $booking_id, self::REFUND_AMOUNT_META, true ) );
$total_refund = $existing_refund + $refund_amount;
// Update refund meta.
self::record_refund_meta( $booking_id, $total_refund, $reason );
// Add note about partial refund.
$note = sprintf(
/* translators: %s: Refund amount */
__( 'Partial refund processed: %s', 'wp-bnb' ),
wc_price( $refund_amount )
);
$existing_notes = get_post_meta( $booking_id, '_bnb_booking_notes', true );
$new_notes = $existing_notes ? $existing_notes . "\n\n" . $note : $note;
update_post_meta( $booking_id, '_bnb_booking_notes', $new_notes );
}
/**
* Record refund metadata.
*
* @param int $booking_id Booking ID.
* @param float $refund_amount Total refund amount.
* @param string $reason Refund reason.
* @return void
*/
private static function record_refund_meta( int $booking_id, float $refund_amount, string $reason ): void {
update_post_meta( $booking_id, self::REFUND_AMOUNT_META, $refund_amount );
update_post_meta( $booking_id, self::REFUND_DATE_META, current_time( 'mysql' ) );
if ( $reason ) {
update_post_meta( $booking_id, self::REFUND_REASON_META, $reason );
}
}
/**
* Calculate refund amount for a booking.
*
* @param int $booking_id Booking ID.
* @param string $type Refund type: 'full' or 'nights_remaining'.
* @return float Refund amount.
*/
public static function calculate_refund_amount( int $booking_id, string $type = 'full' ): float {
$calculated_price = floatval( get_post_meta( $booking_id, '_bnb_booking_calculated_price', true ) );
if ( 'full' === $type ) {
return $calculated_price;
}
// Calculate pro-rata based on nights remaining.
$check_in = get_post_meta( $booking_id, '_bnb_booking_check_in', true );
$check_out = get_post_meta( $booking_id, '_bnb_booking_check_out', true );
if ( ! $check_in || ! $check_out ) {
return $calculated_price;
}
$today = new \DateTime( 'today' );
$check_in_date = \DateTime::createFromFormat( 'Y-m-d', $check_in );
$check_out_date = \DateTime::createFromFormat( 'Y-m-d', $check_out );
if ( ! $check_in_date || ! $check_out_date ) {
return $calculated_price;
}
// If check-in hasn't happened, full refund.
if ( $today < $check_in_date ) {
return $calculated_price;
}
// If check-out has passed, no refund.
if ( $today >= $check_out_date ) {
return 0.0;
}
// Calculate remaining nights.
$total_nights = $check_in_date->diff( $check_out_date )->days;
$nights_used = $check_in_date->diff( $today )->days;
$nights_remaining = $total_nights - $nights_used;
if ( $total_nights <= 0 ) {
return 0.0;
}
// Pro-rata refund.
$nightly_rate = $calculated_price / $total_nights;
return $nightly_rate * $nights_remaining;
}
/**
* Get refund info for a booking.
*
* @param int $booking_id Booking ID.
* @return array|null Refund info or null.
*/
public static function get_booking_refund_info( int $booking_id ): ?array {
$amount = get_post_meta( $booking_id, self::REFUND_AMOUNT_META, true );
if ( ! $amount ) {
return null;
}
return array(
'amount' => floatval( $amount ),
'reason' => get_post_meta( $booking_id, self::REFUND_REASON_META, true ),
'date' => get_post_meta( $booking_id, self::REFUND_DATE_META, true ),
);
}
/**
* Add booking refund notice in admin order page.
*
* @param int $order_id Order ID.
* @return void
*/
public static function add_booking_refund_notice( int $order_id ): void {
$order = wc_get_order( $order_id );
if ( ! $order instanceof \WC_Order ) {
return;
}
$booking_id = Manager::get_booking_for_order( $order );
if ( ! $booking_id ) {
return;
}
$refund_info = self::get_booking_refund_info( $booking_id );
if ( ! $refund_info ) {
return;
}
$booking_status = get_post_meta( $booking_id, '_bnb_booking_status', true );
?>
<tr>
<td class="label"><?php esc_html_e( 'Booking Status', 'wp-bnb' ); ?>:</td>
<td width="1%"></td>
<td class="total">
<span class="bnb-status-badge bnb-status-<?php echo esc_attr( $booking_status ); ?>">
<?php echo esc_html( ucfirst( str_replace( '_', ' ', $booking_status ) ) ); ?>
</span>
</td>
</tr>
<?php if ( 'cancelled' === $booking_status ) : ?>
<tr>
<td class="label" colspan="2">
<small class="description">
<?php
printf(
/* translators: %s: Booking edit link */
esc_html__( 'Booking was cancelled due to refund. %s', 'wp-bnb' ),
'<a href="' . esc_url( get_edit_post_link( $booking_id ) ) . '">' . esc_html__( 'View booking', 'wp-bnb' ) . '</a>'
);
?>
</small>
</td>
</tr>
<?php endif; ?>
<?php
}
}

View File

@@ -126,13 +126,60 @@ final class Manager {
/**
* Check if license is valid.
*
* Localhost environments bypass the license check to allow
* full functionality during development.
*
* @return bool
*/
public static function is_license_valid(): bool {
// Bypass license check for localhost environments.
if ( self::is_localhost() ) {
return true;
}
$status = get_option( self::OPTION_LICENSE_STATUS, 'unchecked' );
return 'valid' === $status;
}
/**
* Check if running on localhost.
*
* Detects common local development environments:
* - localhost / 127.0.0.1 / ::1
* - .local, .test, .localhost domains
* - Private IP ranges (192.168.x.x, 10.x.x.x, 172.16-31.x.x)
*
* @return bool
*/
public static function is_localhost(): bool {
$site_url = get_site_url();
$parsed = wp_parse_url( $site_url );
$host = $parsed['host'] ?? '';
// Check for localhost variations.
if ( in_array( $host, array( 'localhost', '127.0.0.1', '::1' ), true ) ) {
return true;
}
// Check for common local development TLDs.
$local_tlds = array( '.local', '.test', '.localhost', '.dev', '.ddev.site' );
foreach ( $local_tlds as $tld ) {
if ( str_ends_with( $host, $tld ) ) {
return true;
}
}
// Check for private IP ranges.
if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
// 10.x.x.x, 172.16-31.x.x, 192.168.x.x.
if ( ! filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE ) ) {
return true;
}
}
return false;
}
/**
* Get license key.
*

473
src/License/Updater.php Normal file
View File

@@ -0,0 +1,473 @@
<?php
/**
* Plugin Updater class.
*
* Integrates with WordPress plugin update system to check for and install
* updates from the license server.
*
* @package Magdev\WpBnb\License
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\License;
use Magdev\WcLicensedProductClient\SecureLicenseClient;
use Magdev\WcLicensedProductClient\LicenseClient;
use Magdev\WcLicensedProductClient\Dto\UpdateInfo;
use Symfony\Component\HttpClient\HttpClient;
/**
* Handles plugin auto-updates from the license server.
*/
final class Updater {
/**
* Singleton instance.
*
* @var Updater|null
*/
private static ?Updater $instance = null;
/**
* Plugin basename (e.g., wp-bnb/wp-bnb.php).
*
* @var string
*/
private string $plugin_basename;
/**
* Plugin slug.
*
* @var string
*/
private string $plugin_slug;
/**
* Current plugin version.
*
* @var string
*/
private string $current_version;
/**
* Cache key for update info.
*
* @var string
*/
private const CACHE_KEY = 'wp_bnb_update_info';
/**
* Cache key for last check timestamp.
*
* @var string
*/
private const LAST_CHECK_KEY = 'wp_bnb_update_last_check';
/**
* Default cache duration in seconds (12 hours).
*
* @var int
*/
private const DEFAULT_CHECK_FREQUENCY = 12;
// Option keys for update settings.
public const OPTION_NOTIFICATIONS_ENABLED = 'wp_bnb_update_notifications_enabled';
public const OPTION_AUTO_INSTALL_ENABLED = 'wp_bnb_auto_install_enabled';
public const OPTION_CHECK_FREQUENCY = 'wp_bnb_update_check_frequency';
/**
* License client instance.
*
* @var SecureLicenseClient|LicenseClient|null
*/
private SecureLicenseClient|LicenseClient|null $client = null;
/**
* Constructor.
*
* @param string $plugin_file Full path to the main plugin file.
* @param string $current_version Current plugin version.
*/
public function __construct( string $plugin_file, string $current_version ) {
$this->plugin_basename = plugin_basename( $plugin_file );
$this->plugin_slug = dirname( $this->plugin_basename );
$this->current_version = $current_version;
self::$instance = $this;
}
/**
* Get the singleton instance.
*
* @return Updater|null
*/
public static function get_instance(): ?Updater {
return self::$instance;
}
/**
* Initialize update hooks.
*
* @return void
*/
public function init(): void {
// Allow complete disable via constant.
if ( defined( 'WP_BNB_DISABLE_AUTO_UPDATE' ) && WP_BNB_DISABLE_AUTO_UPDATE ) {
return;
}
// Hook into WordPress update system.
add_filter( 'pre_set_site_transient_update_plugins', array( $this, 'check_for_updates' ) );
add_filter( 'plugins_api', array( $this, 'plugin_info' ), 10, 3 );
add_action( 'upgrader_process_complete', array( $this, 'after_update' ), 10, 2 );
// Auto-install filter for WordPress background updates.
add_filter( 'auto_update_plugin', array( $this, 'auto_update_plugin' ), 10, 2 );
// Clear update cache when license settings change.
add_action( 'update_option_' . Manager::OPTION_LICENSE_KEY, array( $this, 'clear_cache' ) );
add_action( 'update_option_' . Manager::OPTION_SERVER_URL, array( $this, 'clear_cache' ) );
// AJAX handler for manual update check.
add_action( 'wp_ajax_wp_bnb_check_updates', array( $this, 'ajax_check_updates' ) );
}
/**
* Check if update notifications are enabled.
*
* @return bool
*/
public static function is_notifications_enabled(): bool {
return 'yes' === get_option( self::OPTION_NOTIFICATIONS_ENABLED, 'yes' );
}
/**
* Check if auto-install is enabled.
*
* @return bool
*/
public static function is_auto_install_enabled(): bool {
return 'yes' === get_option( self::OPTION_AUTO_INSTALL_ENABLED, 'no' );
}
/**
* Get the update check frequency in hours.
*
* @return int
*/
public static function get_check_frequency(): int {
$frequency = (int) get_option( self::OPTION_CHECK_FREQUENCY, self::DEFAULT_CHECK_FREQUENCY );
// Clamp between 1 and 168 hours (1 week).
return max( 1, min( 168, $frequency ) );
}
/**
* Get cache duration in seconds based on check frequency.
*
* @return int
*/
private function get_cache_duration(): int {
return self::get_check_frequency() * 3600;
}
/**
* Filter for WordPress auto-update system.
*
* @param bool|null $update Whether to update the plugin.
* @param object $item The plugin update object.
* @return bool|null
*/
public function auto_update_plugin( $update, object $item ) {
// Only affect our plugin.
if ( ! isset( $item->plugin ) || $item->plugin !== $this->plugin_basename ) {
return $update;
}
// Check if auto-install is enabled and license is valid.
if ( self::is_auto_install_enabled() && Manager::is_license_valid() ) {
return true;
}
return $update;
}
/**
* Get current plugin version.
*
* @return string
*/
public function get_current_version(): string {
return $this->current_version;
}
/**
* Get last update check timestamp.
*
* @return int
*/
public static function get_last_check(): int {
return (int) get_option( self::LAST_CHECK_KEY, 0 );
}
/**
* AJAX handler: Check for updates.
*
* @return void
*/
public function ajax_check_updates(): void {
check_ajax_referer( 'wp_bnb_admin_nonce', 'nonce' );
if ( ! current_user_can( 'update_plugins' ) ) {
wp_send_json_error( array(
'message' => __( 'You do not have permission to check for updates.', 'wp-bnb' ),
) );
}
$update_info = $this->get_cached_update_info( true );
if ( null === $update_info ) {
wp_send_json_success( array(
'update_available' => false,
'current_version' => $this->current_version,
'message' => __( 'Could not check for updates. Please verify your license configuration.', 'wp-bnb' ),
) );
}
$response = array(
'update_available' => $update_info->updateAvailable && version_compare( $this->current_version, $update_info->version ?? '', '<' ),
'current_version' => $this->current_version,
'latest_version' => $update_info->version ?? $this->current_version,
'last_check' => time(),
);
if ( $response['update_available'] ) {
$response['message'] = sprintf(
/* translators: %s: New version number */
__( 'A new version (%s) is available.', 'wp-bnb' ),
$update_info->version
);
$response['changelog'] = $update_info->changelog ?? '';
} else {
$response['message'] = __( 'You are running the latest version.', 'wp-bnb' );
}
wp_send_json_success( $response );
}
/**
* Initialize the license client.
*
* @return bool
*/
private function init_client(): bool {
if ( null !== $this->client ) {
return true;
}
$server_url = Manager::get_server_url();
$server_secret = Manager::get_server_secret();
if ( empty( $server_url ) ) {
return false;
}
try {
if ( ! empty( $server_secret ) ) {
$this->client = new SecureLicenseClient(
httpClient: HttpClient::create(),
baseUrl: $server_url,
serverSecret: $server_secret,
);
} else {
$this->client = new LicenseClient(
httpClient: HttpClient::create(),
baseUrl: $server_url,
);
}
return true;
} catch ( \Throwable $e ) {
return false;
}
}
/**
* Check for plugin updates.
*
* @param object $transient The update_plugins transient.
* @return object Modified transient.
*/
public function check_for_updates( object $transient ): object {
if ( empty( $transient->checked ) ) {
return $transient;
}
// Respect notifications enabled setting.
if ( ! self::is_notifications_enabled() ) {
return $transient;
}
$update_info = $this->get_update_info();
if ( null === $update_info || ! $update_info->updateAvailable ) {
return $transient;
}
// Compare versions.
if ( version_compare( $this->current_version, $update_info->version ?? '', '>=' ) ) {
return $transient;
}
// Add to update response.
$transient->response[ $this->plugin_basename ] = (object) array(
'slug' => $update_info->slug ?? $this->plugin_slug,
'plugin' => $this->plugin_basename,
'new_version' => $update_info->version,
'url' => $update_info->homepage ?? '',
'package' => $update_info->downloadUrl,
'icons' => $update_info->icons ?? array(),
'tested' => $update_info->tested ?? '',
'requires' => $update_info->requires ?? '',
'requires_php' => $update_info->requiresPhp ?? '',
);
return $transient;
}
/**
* Provide plugin information for the details modal.
*
* @param false|object|array $result The result object or array.
* @param string $action The API action being performed.
* @param object $args Plugin API arguments.
* @return false|object
*/
public function plugin_info( $result, string $action, object $args ) {
if ( 'plugin_information' !== $action ) {
return $result;
}
if ( ! isset( $args->slug ) || $args->slug !== $this->plugin_slug ) {
return $result;
}
$update_info = $this->get_update_info();
if ( null === $update_info ) {
return $result;
}
$plugin_info = (object) array(
'name' => $update_info->name ?? 'WP BnB Manager',
'slug' => $update_info->slug ?? $this->plugin_slug,
'version' => $update_info->version ?? $this->current_version,
'author' => '<a href="https://src.bundespruefstelle.ch/magdev">Marco Graetsch</a>',
'homepage' => $update_info->homepage ?? 'https://src.bundespruefstelle.ch/magdev/wp-bnb',
'requires' => $update_info->requires ?? '6.0',
'tested' => $update_info->tested ?? '',
'requires_php' => $update_info->requiresPhp ?? '8.3',
'last_updated' => $update_info->lastUpdated?->format( 'Y-m-d' ) ?? '',
'download_link' => $update_info->downloadUrl ?? '',
'sections' => $update_info->sections ?? array(
'description' => __( 'A comprehensive Bed & Breakfast management plugin for WordPress.', 'wp-bnb' ),
'changelog' => $update_info->changelog ?? '',
),
);
if ( ! empty( $update_info->icons ) ) {
$plugin_info->icons = $update_info->icons;
}
return $plugin_info;
}
/**
* Clear update cache after upgrade.
*
* @param \WP_Upgrader $upgrader WP_Upgrader instance.
* @param array $hook_extra Extra arguments passed to hooked filters.
* @return void
*/
public function after_update( \WP_Upgrader $upgrader, array $hook_extra ): void {
if ( ! isset( $hook_extra['plugins'] ) || ! is_array( $hook_extra['plugins'] ) ) {
return;
}
if ( in_array( $this->plugin_basename, $hook_extra['plugins'], true ) ) {
$this->clear_cache();
}
}
/**
* Get update info from cache or server.
*
* @param bool $force_refresh Force refresh from server.
* @return UpdateInfo|null
*/
public function get_cached_update_info( bool $force_refresh = false ): ?UpdateInfo {
if ( ! $force_refresh ) {
$cached = get_transient( self::CACHE_KEY );
if ( false !== $cached && $cached instanceof UpdateInfo ) {
return $cached;
}
}
// Check if license is configured.
$license_key = Manager::get_license_key();
if ( empty( $license_key ) ) {
return null;
}
if ( ! $this->init_client() ) {
return null;
}
try {
$domain = $this->get_current_domain();
$update_info = $this->client->checkForUpdates(
licenseKey: $license_key,
domain: $domain,
pluginSlug: $this->plugin_slug,
currentVersion: $this->current_version,
);
// Cache the result and update last check timestamp.
set_transient( self::CACHE_KEY, $update_info, $this->get_cache_duration() );
update_option( self::LAST_CHECK_KEY, time() );
return $update_info;
} catch ( \Throwable $e ) {
// Silently fail and return null - don't break WordPress.
return null;
}
}
/**
* Get update info from cache or server (alias for WordPress update system).
*
* @param bool $force_refresh Force refresh from server.
* @return UpdateInfo|null
*/
private function get_update_info( bool $force_refresh = false ): ?UpdateInfo {
return $this->get_cached_update_info( $force_refresh );
}
/**
* Get current domain.
*
* @return string
*/
private function get_current_domain(): string {
$site_url = get_site_url();
$parsed = wp_parse_url( $site_url );
return $parsed['host'] ?? '';
}
/**
* Clear the update cache.
*
* @return void
*/
public function clear_cache(): void {
delete_transient( self::CACHE_KEY );
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,13 @@ use Magdev\WpBnb\Pricing\Calculator;
*/
final class Booking {
/**
* Services meta key.
*
* @var string
*/
public const SERVICES_META_KEY = '_bnb_booking_services';
/**
* Post type slug.
*
@@ -47,8 +54,24 @@ final class Booking {
add_action( 'restrict_manage_posts', array( self::class, 'add_filters' ) );
add_action( 'pre_get_posts', array( self::class, 'filter_query' ) );
add_filter( 'enter_title_here', array( self::class, 'change_title_placeholder' ), 10, 2 );
add_filter( 'wp_insert_post_data', array( self::class, 'auto_generate_title' ), 10, 2 );
add_action( 'admin_notices', array( self::class, 'show_conflict_notice' ) );
// Disable Gutenberg block editor for Bookings - use classic editor for form-based UI.
add_filter( 'use_block_editor_for_post_type', array( self::class, 'disable_block_editor' ), 10, 2 );
}
/**
* Disable block editor for Bookings post type.
*
* @param bool $use_block_editor Whether to use block editor.
* @param string $post_type Post type.
* @return bool
*/
public static function disable_block_editor( bool $use_block_editor, string $post_type ): bool {
if ( self::POST_TYPE === $post_type ) {
return false;
}
return $use_block_editor;
}
/**
@@ -125,6 +148,15 @@ final class Booking {
'high'
);
add_meta_box(
'bnb_booking_services',
__( 'Additional Services', 'wp-bnb' ),
array( self::class, 'render_services_meta_box' ),
self::POST_TYPE,
'normal',
'default'
);
add_meta_box(
'bnb_booking_pricing',
__( 'Pricing', 'wp-bnb' ),
@@ -280,13 +312,65 @@ final class Booking {
* @return void
*/
public static function render_guest_meta_box( \WP_Post $post ): void {
$guest_id = (int) get_post_meta( $post->ID, self::META_PREFIX . 'guest_id', true );
$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 );
$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 );
// If guest_id exists, get guest data from Guest CPT.
$linked_guest = null;
if ( $guest_id ) {
$linked_guest = get_post( $guest_id );
if ( $linked_guest && Guest::POST_TYPE === $linked_guest->post_type ) {
$guest_name = Guest::get_full_name( $guest_id );
$guest_email = get_post_meta( $guest_id, '_bnb_guest_email', true );
$guest_phone = get_post_meta( $guest_id, '_bnb_guest_phone', true );
} else {
$linked_guest = null;
$guest_id = 0;
}
}
?>
<input type="hidden" id="bnb_booking_guest_id" name="bnb_booking_guest_id" value="<?php echo esc_attr( $guest_id ); ?>">
<?php if ( $linked_guest ) : ?>
<div id="bnb-linked-guest-info" class="bnb-linked-guest">
<p>
<span class="dashicons dashicons-admin-users"></span>
<strong><?php echo esc_html( $linked_guest->post_title ); ?></strong>
<a href="<?php echo esc_url( get_edit_post_link( $guest_id ) ); ?>" target="_blank" class="button button-small">
<?php esc_html_e( 'View Guest Profile', 'wp-bnb' ); ?>
</a>
<button type="button" id="bnb-unlink-guest" class="button button-small button-link-delete">
<?php esc_html_e( 'Unlink', 'wp-bnb' ); ?>
</button>
</p>
<?php if ( $guest_email ) : ?>
<p><small><?php echo esc_html( $guest_email ); ?></small></p>
<?php endif; ?>
</div>
<?php endif; ?>
<div id="bnb-guest-search-container" class="bnb-guest-search" <?php echo $linked_guest ? 'style="display:none;"' : ''; ?>>
<table class="form-table">
<tr>
<th scope="row">
<label for="bnb_booking_guest_search"><?php esc_html_e( 'Search Guest', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="text" id="bnb_booking_guest_search" class="regular-text"
placeholder="<?php esc_attr_e( 'Search by email...', 'wp-bnb' ); ?>">
<p class="description"><?php esc_html_e( 'Search for existing guest or enter details below.', 'wp-bnb' ); ?></p>
<div id="bnb-guest-search-results" class="bnb-guest-search-results" style="display:none;"></div>
</td>
</tr>
</table>
</div>
<div id="bnb-guest-fields-container" <?php echo $linked_guest ? 'style="display:none;"' : ''; ?>>
<table class="form-table">
<tr>
<th scope="row">
@@ -294,7 +378,7 @@ final class Booking {
</th>
<td>
<input type="text" id="bnb_booking_guest_name" name="bnb_booking_guest_name"
value="<?php echo esc_attr( $guest_name ); ?>" class="regular-text" required>
value="<?php echo esc_attr( $guest_name ); ?>" class="regular-text" <?php echo $linked_guest ? '' : 'required'; ?>>
</td>
</tr>
<tr>
@@ -315,6 +399,10 @@ final class Booking {
value="<?php echo esc_attr( $guest_phone ); ?>" class="regular-text">
</td>
</tr>
</table>
</div>
<table class="form-table">
<tr>
<th scope="row">
<?php esc_html_e( 'Guests', 'wp-bnb' ); ?>
@@ -343,6 +431,111 @@ final class Booking {
<?php
}
/**
* Render services meta box.
*
* @param \WP_Post $post Current post object.
* @return void
*/
public static function render_services_meta_box( \WP_Post $post ): void {
$selected_services = get_post_meta( $post->ID, self::SERVICES_META_KEY, true ) ?: array();
$check_in = get_post_meta( $post->ID, self::META_PREFIX . 'check_in', true );
$check_out = get_post_meta( $post->ID, self::META_PREFIX . 'check_out', true );
$nights = ( $check_in && $check_out ) ? self::calculate_nights( $check_in, $check_out ) : 1;
// Get all active services.
$available_services = Service::get_services_for_booking();
if ( empty( $available_services ) ) {
?>
<p class="bnb-no-services-message">
<?php esc_html_e( 'No services available.', 'wp-bnb' ); ?>
<a href="<?php echo esc_url( admin_url( 'post-new.php?post_type=' . Service::POST_TYPE ) ); ?>">
<?php esc_html_e( 'Add a service', 'wp-bnb' ); ?>
</a>
</p>
<?php
return;
}
// Build a lookup map for selected services.
$selected_map = array();
if ( is_array( $selected_services ) ) {
foreach ( $selected_services as $service ) {
if ( isset( $service['service_id'] ) ) {
$selected_map[ $service['service_id'] ] = $service;
}
}
}
?>
<div class="bnb-services-selector" data-nights="<?php echo esc_attr( $nights ); ?>">
<p class="description">
<?php esc_html_e( 'Select additional services for this booking.', 'wp-bnb' ); ?>
</p>
<div class="bnb-services-list">
<?php foreach ( $available_services as $service ) : ?>
<?php
$is_selected = isset( $selected_map[ $service['id'] ] );
$quantity = $is_selected ? ( $selected_map[ $service['id'] ]['quantity'] ?? 1 ) : 1;
$service_total = $is_selected
? Service::calculate_service_price( $service['id'], $quantity, $nights )
: 0;
?>
<div class="bnb-service-item <?php echo $is_selected ? 'selected' : ''; ?>"
data-service-id="<?php echo esc_attr( $service['id'] ); ?>"
data-price="<?php echo esc_attr( $service['price'] ); ?>"
data-pricing-type="<?php echo esc_attr( $service['pricing_type'] ); ?>"
data-max-quantity="<?php echo esc_attr( $service['max_quantity'] ); ?>">
<label class="bnb-service-checkbox">
<input type="checkbox" name="bnb_booking_services[<?php echo esc_attr( $service['id'] ); ?>][selected]"
value="1" <?php checked( $is_selected ); ?>>
<span class="bnb-service-name"><?php echo esc_html( $service['name'] ); ?></span>
</label>
<div class="bnb-service-details">
<span class="bnb-service-price-label">
<?php
if ( 'included' === $service['pricing_type'] ) {
echo '<span class="bnb-service-included-badge">' . esc_html__( 'Included', 'wp-bnb' ) . '</span>';
} else {
echo esc_html( $service['formatted_price'] );
}
?>
</span>
<?php if ( $service['max_quantity'] > 1 && 'included' !== $service['pricing_type'] ) : ?>
<span class="bnb-service-quantity" <?php echo ! $is_selected ? 'style="display:none;"' : ''; ?>>
<label>
<?php esc_html_e( 'Qty:', 'wp-bnb' ); ?>
<input type="number" name="bnb_booking_services[<?php echo esc_attr( $service['id'] ); ?>][quantity]"
value="<?php echo esc_attr( $quantity ); ?>"
min="1" max="<?php echo esc_attr( $service['max_quantity'] ); ?>"
class="small-text bnb-service-qty-input">
</label>
</span>
<?php else : ?>
<input type="hidden" name="bnb_booking_services[<?php echo esc_attr( $service['id'] ); ?>][quantity]" value="1">
<?php endif; ?>
<span class="bnb-service-line-total" <?php echo ( ! $is_selected || $service_total <= 0 ) ? 'style="display:none;"' : ''; ?>>
= <strong class="bnb-service-total-value"><?php echo esc_html( Calculator::formatPrice( $service_total ) ); ?></strong>
</span>
</div>
</div>
<?php endforeach; ?>
</div>
<div class="bnb-services-total">
<strong><?php esc_html_e( 'Services Total:', 'wp-bnb' ); ?></strong>
<span id="bnb-services-total-amount">
<?php
$services_total = self::calculate_booking_services_total( $post->ID );
echo esc_html( Calculator::formatPrice( $services_total ) );
?>
</span>
</div>
</div>
<?php
}
/**
* Render pricing meta box.
*
@@ -391,6 +584,36 @@ final class Booking {
</td>
</tr>
<?php endif; ?>
<?php
$services_total = self::calculate_booking_services_total( $post->ID );
if ( $services_total > 0 ) :
?>
<tr>
<th scope="row">
<?php esc_html_e( 'Services', 'wp-bnb' ); ?>
</th>
<td>
<div class="bnb-booking-services-summary">
<?php echo esc_html( Calculator::formatPrice( $services_total ) ); ?>
</div>
</td>
</tr>
<tr>
<th scope="row">
<?php esc_html_e( 'Grand Total', 'wp-bnb' ); ?>
</th>
<td>
<div class="bnb-booking-grand-total">
<strong>
<?php
$room_price = (float) $calculated_price;
echo esc_html( Calculator::formatPrice( $room_price + $services_total ) );
?>
</strong>
</div>
</td>
</tr>
<?php endif; ?>
<tr>
<th scope="row">
<label for="bnb_booking_override_price"><?php esc_html_e( 'Override Price', 'wp-bnb' ); ?></label>
@@ -535,23 +758,53 @@ final class Booking {
}
}
// Guest text fields.
$guest_fields = array( 'guest_name', 'guest_email', 'guest_phone', 'guest_notes' );
foreach ( $guest_fields as $field ) {
$key = 'bnb_booking_' . $field;
if ( isset( $_POST[ $key ] ) ) {
$value = wp_unslash( $_POST[ $key ] );
if ( 'guest_email' === $field ) {
$value = sanitize_email( $value );
} elseif ( 'guest_notes' === $field ) {
$value = sanitize_textarea_field( $value );
// Guest ID (linked guest record).
$guest_id = isset( $_POST['bnb_booking_guest_id'] ) ? absint( $_POST['bnb_booking_guest_id'] ) : 0;
if ( $guest_id ) {
// Verify guest exists.
$guest = get_post( $guest_id );
if ( $guest && Guest::POST_TYPE === $guest->post_type ) {
update_post_meta( $post_id, self::META_PREFIX . 'guest_id', $guest_id );
// Sync guest data from Guest CPT for searching/display purposes.
update_post_meta( $post_id, self::META_PREFIX . 'guest_name', Guest::get_full_name( $guest_id ) );
update_post_meta( $post_id, self::META_PREFIX . 'guest_email', get_post_meta( $guest_id, '_bnb_guest_email', true ) );
update_post_meta( $post_id, self::META_PREFIX . 'guest_phone', get_post_meta( $guest_id, '_bnb_guest_phone', true ) );
} else {
$value = sanitize_text_field( $value );
delete_post_meta( $post_id, self::META_PREFIX . 'guest_id' );
}
update_post_meta( $post_id, self::META_PREFIX . $field, $value );
} else {
// No guest_id selected - get guest data from form fields.
$guest_name = isset( $_POST['bnb_booking_guest_name'] ) ? sanitize_text_field( wp_unslash( $_POST['bnb_booking_guest_name'] ) ) : '';
$guest_email = isset( $_POST['bnb_booking_guest_email'] ) ? sanitize_email( wp_unslash( $_POST['bnb_booking_guest_email'] ) ) : '';
$guest_phone = isset( $_POST['bnb_booking_guest_phone'] ) ? sanitize_text_field( wp_unslash( $_POST['bnb_booking_guest_phone'] ) ) : '';
// Try to find or create a Guest post.
$linked_guest_id = self::find_or_create_guest( $guest_name, $guest_email, $guest_phone );
if ( $linked_guest_id ) {
// Link to the guest and sync data.
update_post_meta( $post_id, self::META_PREFIX . 'guest_id', $linked_guest_id );
update_post_meta( $post_id, self::META_PREFIX . 'guest_name', Guest::get_full_name( $linked_guest_id ) );
update_post_meta( $post_id, self::META_PREFIX . 'guest_email', get_post_meta( $linked_guest_id, '_bnb_guest_email', true ) );
update_post_meta( $post_id, self::META_PREFIX . 'guest_phone', get_post_meta( $linked_guest_id, '_bnb_guest_phone', true ) );
} else {
// Fallback: save guest data directly to booking meta if guest creation failed.
delete_post_meta( $post_id, self::META_PREFIX . 'guest_id' );
update_post_meta( $post_id, self::META_PREFIX . 'guest_name', $guest_name );
update_post_meta( $post_id, self::META_PREFIX . 'guest_email', $guest_email );
update_post_meta( $post_id, self::META_PREFIX . 'guest_phone', $guest_phone );
}
}
// Guest notes are always saved (per-booking notes).
if ( isset( $_POST['bnb_booking_guest_notes'] ) ) {
update_post_meta(
$post_id,
self::META_PREFIX . 'guest_notes',
sanitize_textarea_field( wp_unslash( $_POST['bnb_booking_guest_notes'] ) )
);
}
// Guest counts.
if ( isset( $_POST['bnb_booking_adults'] ) ) {
update_post_meta( $post_id, self::META_PREFIX . 'adults', absint( $_POST['bnb_booking_adults'] ) );
@@ -595,6 +848,43 @@ final class Booking {
}
}
// Services.
$services_data = array();
if ( isset( $_POST['bnb_booking_services'] ) && is_array( $_POST['bnb_booking_services'] ) ) {
$nights = ( $check_in && $check_out ) ? self::calculate_nights( $check_in, $check_out ) : 1;
foreach ( $_POST['bnb_booking_services'] as $service_id => $service_input ) {
$service_id = absint( $service_id );
if ( ! $service_id ) {
continue;
}
// Only include if selected checkbox is checked.
if ( empty( $service_input['selected'] ) ) {
continue;
}
// Verify service exists and is active.
$service_data = Service::get_service_data( $service_id );
if ( ! $service_data || 'active' !== $service_data['status'] ) {
continue;
}
$quantity = isset( $service_input['quantity'] ) ? absint( $service_input['quantity'] ) : 1;
$quantity = max( 1, min( $quantity, $service_data['max_quantity'] ) );
$price = Service::calculate_service_price( $service_id, $quantity, $nights );
$services_data[] = array(
'service_id' => $service_id,
'quantity' => $quantity,
'price' => $price,
'pricing_type' => $service_data['pricing_type'],
);
}
}
update_post_meta( $post_id, self::SERVICES_META_KEY, $services_data );
// Trigger status change action.
if ( $old_status && $status !== $old_status ) {
/**
@@ -606,6 +896,130 @@ final class Booking {
*/
do_action( 'wp_bnb_booking_status_changed', $post_id, $old_status, $status );
}
// Generate comprehensive title with guest name, room, and dates.
self::generate_comprehensive_title( $post_id, $room_id, $check_in, $check_out );
}
/**
* Generate a comprehensive title for a booking.
*
* Format: "Guest Name (DD.MM - DD.MM.YYYY)"
*
* @param int $post_id Booking post ID.
* @param int $room_id Room post ID (unused, kept for signature compatibility).
* @param string $check_in Check-in date (Y-m-d).
* @param string $check_out Check-out date (Y-m-d).
* @return void
*/
private static function generate_comprehensive_title( int $post_id, int $room_id, string $check_in, string $check_out ): void {
// Get guest name.
$guest_name = get_post_meta( $post_id, self::META_PREFIX . 'guest_name', true );
if ( empty( $guest_name ) ) {
$guest_name = __( 'Unknown Guest', 'wp-bnb' );
}
// Format dates.
$date_part = '';
if ( $check_in && $check_out ) {
$check_in_date = \DateTime::createFromFormat( 'Y-m-d', $check_in );
$check_out_date = \DateTime::createFromFormat( 'Y-m-d', $check_out );
if ( $check_in_date && $check_out_date ) {
// Same year: "01.02 - 05.02.2026"
// Different year: "28.12.2025 - 02.01.2026"
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' )
);
}
}
}
// Build title: "Guest Name (dates)".
$title = $guest_name;
if ( $date_part ) {
$title .= sprintf( ' (%s)', $date_part );
}
// Update the post title directly in the database to avoid infinite loop.
global $wpdb;
$wpdb->update(
$wpdb->posts,
array( 'post_title' => $title ),
array( 'ID' => $post_id ),
array( '%s' ),
array( '%d' )
);
// Clear post cache.
clean_post_cache( $post_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 {
// Need at least a name to create a guest.
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;
}
/**
@@ -642,7 +1056,7 @@ final class Booking {
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 );
$room_id = (int) get_post_meta( $post_id, self::META_PREFIX . 'room_id', true );
if ( $room_id ) {
$room = get_post( $room_id );
if ( $room ) {
@@ -664,10 +1078,21 @@ final class Booking {
break;
case 'guest':
$guest_id = (int) get_post_meta( $post_id, self::META_PREFIX . 'guest_id', true );
$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 ) {
if ( $guest_id ) {
// Linked guest - show link to guest profile.
printf(
'<a href="%s">%s</a>',
esc_url( get_edit_post_link( $guest_id ) ),
esc_html( $guest_name )
);
echo ' <span class="dashicons dashicons-admin-users" style="font-size: 14px; vertical-align: middle;" title="' . esc_attr__( 'Linked Guest', 'wp-bnb' ) . '"></span>';
} else {
echo esc_html( $guest_name );
}
if ( $guest_email ) {
echo '<br><small><a href="mailto:' . esc_attr( $guest_email ) . '">' . esc_html( $guest_email ) . '</a></small>';
}
@@ -700,13 +1125,21 @@ final class Booking {
break;
case 'price':
$price = get_post_meta( $post_id, self::META_PREFIX . 'calculated_price', true );
if ( $price ) {
echo esc_html( Calculator::formatPrice( (float) $price ) );
$room_price = (float) get_post_meta( $post_id, self::META_PREFIX . 'calculated_price', true );
$services_total = self::calculate_booking_services_total( $post_id );
$total_price = $room_price + $services_total;
if ( $total_price > 0 ) {
echo esc_html( Calculator::formatPrice( $total_price ) );
$override = get_post_meta( $post_id, self::META_PREFIX . 'override_price', true );
if ( $override ) {
echo ' <span class="bnb-price-override" title="' . esc_attr__( 'Price manually overridden', 'wp-bnb' ) . '">*</span>';
}
if ( $services_total > 0 ) {
echo '<br><small style="color: #646970;">' . esc_html__( 'incl. services', 'wp-bnb' ) . '</small>';
}
} elseif ( $room_price > 0 ) {
echo esc_html( Calculator::formatPrice( $room_price ) );
} else {
echo '—';
}
@@ -806,6 +1239,13 @@ final class Booking {
return;
}
// Exclude auto-drafts from the list - they're not real bookings.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter query only.
$post_status = isset( $_GET['post_status'] ) ? sanitize_key( $_GET['post_status'] ) : '';
if ( empty( $post_status ) || 'all' === $post_status ) {
$query->set( 'post_status', array( 'publish', 'pending', 'draft', 'private' ) );
}
$meta_query = array();
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter query only.
@@ -852,31 +1292,11 @@ final class Booking {
*/
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 __( 'Title auto-generated from guest name and dates', '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.
*
@@ -1071,6 +1491,47 @@ final class Booking {
return Room::get_building( $room->ID );
}
/**
* Get guest for a booking.
*
* Returns the linked Guest post if guest_id exists, or a stdClass object
* with guest data from booking meta for backward compatibility.
*
* @param int $booking_id Booking post ID.
* @return \WP_Post|\stdClass|null Guest post, virtual guest object, or null.
*/
public static function get_guest( int $booking_id ) {
$guest_id = get_post_meta( $booking_id, self::META_PREFIX . 'guest_id', true );
// If linked to Guest CPT, return the guest post.
if ( $guest_id ) {
$guest = get_post( $guest_id );
if ( $guest && Guest::POST_TYPE === $guest->post_type ) {
return $guest;
}
}
// Otherwise, create a virtual guest object from booking meta.
$guest_name = get_post_meta( $booking_id, self::META_PREFIX . 'guest_name', true );
$guest_email = get_post_meta( $booking_id, self::META_PREFIX . 'guest_email', true );
$guest_phone = get_post_meta( $booking_id, self::META_PREFIX . 'guest_phone', true );
if ( ! $guest_name && ! $guest_email ) {
return null;
}
// Return virtual guest object for backward compatibility.
$virtual_guest = new \stdClass();
$virtual_guest->ID = 0;
$virtual_guest->post_type = 'virtual_guest';
$virtual_guest->post_title = $guest_name ?: '';
$virtual_guest->name = $guest_name ?: '';
$virtual_guest->email = $guest_email ?: '';
$virtual_guest->phone = $guest_phone ?: '';
return $virtual_guest;
}
/**
* Get all bookings for a room.
*
@@ -1097,6 +1558,56 @@ final class Booking {
return get_posts( array_merge( $defaults, $args ) );
}
/**
* Calculate total services cost for a booking.
*
* @param int $booking_id Booking post ID.
* @return float Total services cost.
*/
public static function calculate_booking_services_total( int $booking_id ): float {
$services = get_post_meta( $booking_id, self::SERVICES_META_KEY, true );
if ( ! is_array( $services ) || empty( $services ) ) {
return 0.0;
}
$total = 0.0;
foreach ( $services as $service ) {
if ( isset( $service['price'] ) ) {
$total += (float) $service['price'];
}
}
return $total;
}
/**
* Get selected services for a booking.
*
* @param int $booking_id Booking post ID.
* @return array Array of service data with names.
*/
public static function get_booking_services( int $booking_id ): array {
$services = get_post_meta( $booking_id, self::SERVICES_META_KEY, true );
if ( ! is_array( $services ) || empty( $services ) ) {
return array();
}
$result = array();
foreach ( $services as $service ) {
$service_post = get_post( $service['service_id'] ?? 0 );
if ( $service_post ) {
$result[] = array_merge(
$service,
array( 'name' => $service_post->post_title )
);
}
}
return $result;
}
/**
* Format price breakdown for display.
*

1192
src/PostTypes/Guest.php Normal file

File diff suppressed because it is too large Load Diff

640
src/PostTypes/Service.php Normal file
View File

@@ -0,0 +1,640 @@
<?php
/**
* Service post type.
*
* Custom post type for BnB additional services.
*
* @package Magdev\WpBnb\PostTypes
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\PostTypes;
use Magdev\WpBnb\Pricing\Calculator;
/**
* Service post type class.
*/
final class Service {
/**
* Post type slug.
*
* @var string
*/
public const POST_TYPE = 'bnb_service';
/**
* Meta key prefix.
*
* @var string
*/
private const META_PREFIX = '_bnb_service_';
/**
* Initialize the post type.
*
* @return void
*/
public static function init(): void {
add_action( 'init', array( self::class, 'register' ) );
add_action( 'add_meta_boxes', array( self::class, 'add_meta_boxes' ) );
add_action( 'save_post_' . self::POST_TYPE, array( self::class, 'save_meta' ), 10, 2 );
add_filter( 'manage_' . self::POST_TYPE . '_posts_columns', array( self::class, 'add_columns' ) );
add_action( 'manage_' . self::POST_TYPE . '_posts_custom_column', array( self::class, 'render_column' ), 10, 2 );
add_filter( 'manage_edit-' . self::POST_TYPE . '_sortable_columns', array( self::class, 'sortable_columns' ) );
add_action( 'restrict_manage_posts', array( self::class, 'add_filters' ) );
add_action( 'pre_get_posts', array( self::class, 'filter_query' ) );
add_filter( 'enter_title_here', array( self::class, 'change_title_placeholder' ), 10, 2 );
// Disable Gutenberg block editor for Services - use classic editor for simpler UI.
add_filter( 'use_block_editor_for_post_type', array( self::class, 'disable_block_editor' ), 10, 2 );
}
/**
* Disable block editor for Services post type.
*
* @param bool $use_block_editor Whether to use block editor.
* @param string $post_type Post type.
* @return bool
*/
public static function disable_block_editor( bool $use_block_editor, string $post_type ): bool {
if ( self::POST_TYPE === $post_type ) {
return false;
}
return $use_block_editor;
}
/**
* Register the post type.
*
* @return void
*/
public static function register(): void {
$labels = array(
'name' => _x( 'Services', 'post type general name', 'wp-bnb' ),
'singular_name' => _x( 'Service', 'post type singular name', 'wp-bnb' ),
'menu_name' => _x( 'Services', 'admin menu', 'wp-bnb' ),
'name_admin_bar' => _x( 'Service', 'add new on admin bar', 'wp-bnb' ),
'add_new' => _x( 'Add New', 'service', 'wp-bnb' ),
'add_new_item' => __( 'Add New Service', 'wp-bnb' ),
'new_item' => __( 'New Service', 'wp-bnb' ),
'edit_item' => __( 'Edit Service', 'wp-bnb' ),
'view_item' => __( 'View Service', 'wp-bnb' ),
'all_items' => __( 'Services', 'wp-bnb' ),
'search_items' => __( 'Search Services', 'wp-bnb' ),
'parent_item_colon' => __( 'Parent Services:', 'wp-bnb' ),
'not_found' => __( 'No services found.', 'wp-bnb' ),
'not_found_in_trash' => __( 'No services found in Trash.', 'wp-bnb' ),
'archives' => __( 'Service archives', 'wp-bnb' ),
'insert_into_item' => __( 'Insert into service', 'wp-bnb' ),
'uploaded_to_this_item' => __( 'Uploaded to this service', 'wp-bnb' ),
'filter_items_list' => __( 'Filter services list', 'wp-bnb' ),
'items_list_navigation' => __( 'Services list navigation', 'wp-bnb' ),
'items_list' => __( 'Services list', 'wp-bnb' ),
);
$args = array(
'labels' => $labels,
'public' => false,
'publicly_queryable' => false,
'show_ui' => true,
'show_in_menu' => 'wp-bnb',
'query_var' => false,
'capability_type' => 'post',
'has_archive' => false,
'hierarchical' => false,
'menu_position' => null,
'menu_icon' => 'dashicons-plus-alt',
'supports' => array( 'title', 'editor', 'thumbnail' ),
'show_in_rest' => true,
'rest_base' => 'services',
'rest_controller_class' => 'WP_REST_Posts_Controller',
);
register_post_type( self::POST_TYPE, $args );
}
/**
* Add meta boxes.
*
* @return void
*/
public static function add_meta_boxes(): void {
add_meta_box(
'bnb_service_pricing',
__( 'Pricing', 'wp-bnb' ),
array( self::class, 'render_pricing_meta_box' ),
self::POST_TYPE,
'normal',
'high'
);
add_meta_box(
'bnb_service_settings',
__( 'Service Settings', 'wp-bnb' ),
array( self::class, 'render_settings_meta_box' ),
self::POST_TYPE,
'side',
'default'
);
}
/**
* Render pricing meta box.
*
* @param \WP_Post $post Current post object.
* @return void
*/
public static function render_pricing_meta_box( \WP_Post $post ): void {
wp_nonce_field( 'bnb_service_meta', 'bnb_service_meta_nonce' );
$pricing_type = get_post_meta( $post->ID, self::META_PREFIX . 'pricing_type', true ) ?: 'per_booking';
$price = get_post_meta( $post->ID, self::META_PREFIX . 'price', true );
$currency = get_option( 'wp_bnb_currency', 'CHF' );
?>
<table class="form-table">
<tr>
<th scope="row">
<label for="bnb_service_pricing_type"><?php esc_html_e( 'Pricing Type', 'wp-bnb' ); ?></label>
</th>
<td>
<fieldset>
<label>
<input type="radio" name="bnb_service_pricing_type" value="included"
<?php checked( $pricing_type, 'included' ); ?>>
<?php esc_html_e( 'Included (Free)', 'wp-bnb' ); ?>
<p class="description"><?php esc_html_e( 'Service is included at no extra cost.', 'wp-bnb' ); ?></p>
</label>
<br><br>
<label>
<input type="radio" name="bnb_service_pricing_type" value="per_booking"
<?php checked( $pricing_type, 'per_booking' ); ?>>
<?php esc_html_e( 'Per Booking (One-time)', 'wp-bnb' ); ?>
<p class="description"><?php esc_html_e( 'Fixed price charged once per booking.', 'wp-bnb' ); ?></p>
</label>
<br><br>
<label>
<input type="radio" name="bnb_service_pricing_type" value="per_night"
<?php checked( $pricing_type, 'per_night' ); ?>>
<?php esc_html_e( 'Per Night', 'wp-bnb' ); ?>
<p class="description"><?php esc_html_e( 'Price multiplied by the number of nights.', 'wp-bnb' ); ?></p>
</label>
</fieldset>
</td>
</tr>
<tr id="bnb-service-price-row" <?php echo 'included' === $pricing_type ? 'style="display:none;"' : ''; ?>>
<th scope="row">
<label for="bnb_service_price"><?php esc_html_e( 'Price', 'wp-bnb' ); ?></label>
</th>
<td>
<div class="bnb-price-input-wrapper">
<input type="number" id="bnb_service_price" name="bnb_service_price"
value="<?php echo esc_attr( $price ); ?>" class="small-text"
min="0" step="0.01">
<span class="bnb-price-unit"><?php echo esc_html( $currency ); ?></span>
<span id="bnb-service-price-suffix"></span>
</div>
<p class="description" id="bnb-service-price-description">
<?php
if ( 'per_night' === $pricing_type ) {
esc_html_e( 'This price will be charged per night of the stay.', 'wp-bnb' );
} else {
esc_html_e( 'This price will be charged once for the booking.', 'wp-bnb' );
}
?>
</p>
</td>
</tr>
</table>
<?php
}
/**
* Render settings meta box.
*
* @param \WP_Post $post Current post object.
* @return void
*/
public static function render_settings_meta_box( \WP_Post $post ): void {
$status = get_post_meta( $post->ID, self::META_PREFIX . 'status', true ) ?: 'active';
$sort_order = get_post_meta( $post->ID, self::META_PREFIX . 'sort_order', true ) ?: 0;
$max_qty = get_post_meta( $post->ID, self::META_PREFIX . 'max_quantity', true ) ?: 1;
?>
<p>
<label for="bnb_service_status"><strong><?php esc_html_e( 'Status', 'wp-bnb' ); ?></strong></label>
</p>
<select id="bnb_service_status" name="bnb_service_status" class="widefat">
<option value="active" <?php selected( $status, 'active' ); ?>><?php esc_html_e( 'Active', 'wp-bnb' ); ?></option>
<option value="inactive" <?php selected( $status, 'inactive' ); ?>><?php esc_html_e( 'Inactive', 'wp-bnb' ); ?></option>
</select>
<p class="description"><?php esc_html_e( 'Inactive services cannot be added to bookings.', 'wp-bnb' ); ?></p>
<hr>
<p>
<label for="bnb_service_sort_order"><strong><?php esc_html_e( 'Sort Order', 'wp-bnb' ); ?></strong></label>
</p>
<input type="number" id="bnb_service_sort_order" name="bnb_service_sort_order"
value="<?php echo esc_attr( $sort_order ); ?>" class="small-text" min="0">
<p class="description"><?php esc_html_e( 'Lower numbers appear first.', 'wp-bnb' ); ?></p>
<hr>
<p>
<label for="bnb_service_max_quantity"><strong><?php esc_html_e( 'Maximum Quantity', 'wp-bnb' ); ?></strong></label>
</p>
<input type="number" id="bnb_service_max_quantity" name="bnb_service_max_quantity"
value="<?php echo esc_attr( $max_qty ); ?>" class="small-text" min="1" max="99">
<p class="description"><?php esc_html_e( 'Maximum times this service can be added to a booking.', 'wp-bnb' ); ?></p>
<?php
}
/**
* Save post meta.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @return void
*/
public static function save_meta( int $post_id, \WP_Post $post ): void {
// Verify nonce.
if ( ! isset( $_POST['bnb_service_meta_nonce'] ) ||
! wp_verify_nonce( sanitize_key( $_POST['bnb_service_meta_nonce'] ), 'bnb_service_meta' ) ) {
return;
}
// Check autosave.
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
// Check permissions.
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
// Pricing type.
$valid_pricing_types = array( 'included', 'per_booking', 'per_night' );
$pricing_type = isset( $_POST['bnb_service_pricing_type'] )
? sanitize_text_field( wp_unslash( $_POST['bnb_service_pricing_type'] ) )
: 'per_booking';
if ( in_array( $pricing_type, $valid_pricing_types, true ) ) {
update_post_meta( $post_id, self::META_PREFIX . 'pricing_type', $pricing_type );
}
// Price (not required for 'included').
if ( 'included' === $pricing_type ) {
update_post_meta( $post_id, self::META_PREFIX . 'price', 0 );
} elseif ( isset( $_POST['bnb_service_price'] ) ) {
update_post_meta( $post_id, self::META_PREFIX . 'price', floatval( $_POST['bnb_service_price'] ) );
}
// Status.
$valid_statuses = array( 'active', 'inactive' );
$status = isset( $_POST['bnb_service_status'] )
? sanitize_text_field( wp_unslash( $_POST['bnb_service_status'] ) )
: 'active';
if ( in_array( $status, $valid_statuses, true ) ) {
update_post_meta( $post_id, self::META_PREFIX . 'status', $status );
}
// Sort order.
if ( isset( $_POST['bnb_service_sort_order'] ) ) {
update_post_meta( $post_id, self::META_PREFIX . 'sort_order', absint( $_POST['bnb_service_sort_order'] ) );
}
// Max quantity.
if ( isset( $_POST['bnb_service_max_quantity'] ) ) {
$max_qty = absint( $_POST['bnb_service_max_quantity'] );
$max_qty = max( 1, min( 99, $max_qty ) );
update_post_meta( $post_id, self::META_PREFIX . 'max_quantity', $max_qty );
}
}
/**
* 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['pricing_type'] = __( 'Pricing Type', 'wp-bnb' );
$new_columns['price'] = __( 'Price', 'wp-bnb' );
$new_columns['service_status'] = __( 'Status', 'wp-bnb' );
}
}
// Remove date column.
unset( $new_columns['date'] );
return $new_columns;
}
/**
* Render custom column content.
*
* @param string $column Column name.
* @param int $post_id Post ID.
* @return void
*/
public static function render_column( string $column, int $post_id ): void {
switch ( $column ) {
case 'pricing_type':
$pricing_type = get_post_meta( $post_id, self::META_PREFIX . 'pricing_type', true ) ?: 'per_booking';
$labels = self::get_pricing_type_labels();
$icons = array(
'included' => 'yes-alt',
'per_booking' => 'tag',
'per_night' => 'calendar-alt',
);
$colors = array(
'included' => '#00a32a',
'per_booking' => '#135e96',
'per_night' => '#dba617',
);
echo '<span class="dashicons dashicons-' . esc_attr( $icons[ $pricing_type ] ?? 'admin-generic' ) . '" style="color: ' . esc_attr( $colors[ $pricing_type ] ?? '#646970' ) . '; vertical-align: middle; margin-right: 3px;"></span>';
echo esc_html( $labels[ $pricing_type ] ?? $pricing_type );
break;
case 'price':
$pricing_type = get_post_meta( $post_id, self::META_PREFIX . 'pricing_type', true ) ?: 'per_booking';
if ( 'included' === $pricing_type ) {
echo '<span class="bnb-service-included">' . esc_html__( 'Included', 'wp-bnb' ) . '</span>';
} else {
$price = get_post_meta( $post_id, self::META_PREFIX . 'price', true );
if ( $price ) {
echo esc_html( Calculator::formatPrice( (float) $price ) );
if ( 'per_night' === $pricing_type ) {
echo ' <small style="color: #646970;">' . esc_html__( '/ night', 'wp-bnb' ) . '</small>';
}
} else {
echo '<span class="bnb-no-price">' . esc_html__( 'Not set', 'wp-bnb' ) . '</span>';
}
}
break;
case 'service_status':
$status = get_post_meta( $post_id, self::META_PREFIX . 'status', true ) ?: 'active';
$classes = array(
'active' => 'bnb-service-status-active',
'inactive' => 'bnb-service-status-inactive',
);
$labels = array(
'active' => __( 'Active', 'wp-bnb' ),
'inactive' => __( 'Inactive', 'wp-bnb' ),
);
echo '<span class="bnb-service-status ' . esc_attr( $classes[ $status ] ?? '' ) . '">';
echo esc_html( $labels[ $status ] ?? $status );
echo '</span>';
break;
}
}
/**
* Add sortable columns.
*
* @param array $columns Existing sortable columns.
* @return array
*/
public static function sortable_columns( array $columns ): array {
$columns['price'] = 'price';
$columns['service_status'] = 'status';
return $columns;
}
/**
* Add filter dropdowns to admin list.
*
* @param string $post_type Current post type.
* @return void
*/
public static function add_filters( string $post_type ): void {
if ( self::POST_TYPE !== $post_type ) {
return;
}
// Status filter.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter display only.
$selected_status = isset( $_GET['service_status'] ) ? sanitize_text_field( wp_unslash( $_GET['service_status'] ) ) : '';
?>
<select name="service_status">
<option value=""><?php esc_html_e( 'All Statuses', 'wp-bnb' ); ?></option>
<option value="active" <?php selected( $selected_status, 'active' ); ?>><?php esc_html_e( 'Active', 'wp-bnb' ); ?></option>
<option value="inactive" <?php selected( $selected_status, 'inactive' ); ?>><?php esc_html_e( 'Inactive', 'wp-bnb' ); ?></option>
</select>
<?php
// Pricing type filter.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter display only.
$selected_pricing = isset( $_GET['pricing_type'] ) ? sanitize_text_field( wp_unslash( $_GET['pricing_type'] ) ) : '';
$labels = self::get_pricing_type_labels();
?>
<select name="pricing_type">
<option value=""><?php esc_html_e( 'All Pricing Types', 'wp-bnb' ); ?></option>
<?php foreach ( $labels as $value => $label ) : ?>
<option value="<?php echo esc_attr( $value ); ?>" <?php selected( $selected_pricing, $value ); ?>>
<?php echo esc_html( $label ); ?>
</option>
<?php endforeach; ?>
</select>
<?php
}
/**
* Filter services by status and pricing type in admin list.
*
* @param \WP_Query $query Current query.
* @return void
*/
public static function filter_query( \WP_Query $query ): void {
if ( ! is_admin() || ! $query->is_main_query() ) {
return;
}
if ( self::POST_TYPE !== $query->get( 'post_type' ) ) {
return;
}
$meta_query = array();
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter query only.
if ( ! empty( $_GET['service_status'] ) ) {
$meta_query[] = array(
'key' => self::META_PREFIX . 'status',
'value' => sanitize_text_field( wp_unslash( $_GET['service_status'] ) ),
);
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter query only.
if ( ! empty( $_GET['pricing_type'] ) ) {
$meta_query[] = array(
'key' => self::META_PREFIX . 'pricing_type',
'value' => sanitize_text_field( wp_unslash( $_GET['pricing_type'] ) ),
);
}
if ( ! empty( $meta_query ) ) {
$meta_query['relation'] = 'AND';
$query->set( 'meta_query', $meta_query );
}
// Handle sorting.
$orderby = $query->get( 'orderby' );
if ( 'price' === $orderby ) {
$query->set( 'meta_key', self::META_PREFIX . 'price' );
$query->set( 'orderby', 'meta_value_num' );
} elseif ( 'status' === $orderby ) {
$query->set( 'meta_key', self::META_PREFIX . 'status' );
$query->set( 'orderby', 'meta_value' );
}
}
/**
* Change title placeholder.
*
* @param string $placeholder Default placeholder.
* @param \WP_Post $post Current post.
* @return string
*/
public static function change_title_placeholder( string $placeholder, \WP_Post $post ): string {
if ( self::POST_TYPE === $post->post_type ) {
return __( 'Service name', 'wp-bnb' );
}
return $placeholder;
}
/**
* Get pricing type labels.
*
* @return array<string, string>
*/
public static function get_pricing_type_labels(): array {
return array(
'included' => __( 'Included (Free)', 'wp-bnb' ),
'per_booking' => __( 'Per Booking', 'wp-bnb' ),
'per_night' => __( 'Per Night', 'wp-bnb' ),
);
}
/**
* Get all active services.
*
* @param array $args Additional query args.
* @return array<\WP_Post>
*/
public static function get_active_services( array $args = array() ): array {
$defaults = array(
'post_type' => self::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'meta_query' => array(
array(
'key' => self::META_PREFIX . 'status',
'value' => 'active',
),
),
'meta_key' => self::META_PREFIX . 'sort_order',
'orderby' => 'meta_value_num',
'order' => 'ASC',
);
return get_posts( array_merge( $defaults, $args ) );
}
/**
* Get service data for a service.
*
* @param int $service_id Service post ID.
* @return array|null Service data or null if not found.
*/
public static function get_service_data( int $service_id ): ?array {
$service = get_post( $service_id );
if ( ! $service || self::POST_TYPE !== $service->post_type ) {
return null;
}
return array(
'id' => $service_id,
'name' => $service->post_title,
'description' => $service->post_content,
'pricing_type' => get_post_meta( $service_id, self::META_PREFIX . 'pricing_type', true ) ?: 'per_booking',
'price' => (float) get_post_meta( $service_id, self::META_PREFIX . 'price', true ),
'status' => get_post_meta( $service_id, self::META_PREFIX . 'status', true ) ?: 'active',
'sort_order' => (int) get_post_meta( $service_id, self::META_PREFIX . 'sort_order', true ),
'max_quantity' => (int) get_post_meta( $service_id, self::META_PREFIX . 'max_quantity', true ) ?: 1,
);
}
/**
* Calculate service price for a booking.
*
* @param int $service_id Service post ID.
* @param int $quantity Quantity of the service.
* @param int $nights Number of nights (for per-night pricing).
* @return float Calculated price.
*/
public static function calculate_service_price( int $service_id, int $quantity = 1, int $nights = 1 ): float {
$data = self::get_service_data( $service_id );
if ( ! $data ) {
return 0.0;
}
if ( 'included' === $data['pricing_type'] ) {
return 0.0;
}
$base_price = $data['price'];
if ( 'per_night' === $data['pricing_type'] ) {
return $base_price * $quantity * max( 1, $nights );
}
// per_booking.
return $base_price * $quantity;
}
/**
* Get services for booking display/selection.
*
* @return array Array of services with their data.
*/
public static function get_services_for_booking(): array {
$services = self::get_active_services();
$result = array();
foreach ( $services as $service ) {
$data = self::get_service_data( $service->ID );
if ( $data ) {
$data['formatted_price'] = self::format_service_price( $data );
$result[] = $data;
}
}
return $result;
}
/**
* Format service price for display.
*
* @param array $service_data Service data array.
* @return string Formatted price string.
*/
public static function format_service_price( array $service_data ): string {
if ( 'included' === $service_data['pricing_type'] ) {
return __( 'Included', 'wp-bnb' );
}
$formatted = Calculator::formatPrice( $service_data['price'] );
if ( 'per_night' === $service_data['pricing_type'] ) {
/* translators: %s: Formatted price */
return sprintf( __( '%s / night', 'wp-bnb' ), $formatted );
}
return $formatted;
}
}

800
src/Privacy/Manager.php Normal file
View File

@@ -0,0 +1,800 @@
<?php
/**
* Privacy Manager for GDPR compliance.
*
* Handles personal data export and erasure for WordPress privacy tools.
*
* @package Magdev\WpBnb\Privacy
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Privacy;
use Magdev\WpBnb\PostTypes\Booking;
use Magdev\WpBnb\PostTypes\Guest;
/**
* Privacy Manager class for GDPR compliance.
*/
final class Manager {
/**
* Manager instance.
*
* @var Manager|null
*/
private static ?Manager $instance = null;
/**
* Get manager instance.
*
* @return Manager
*/
public static function get_instance(): Manager {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Private constructor to enforce singleton.
*/
private function __construct() {
$this->init_hooks();
}
/**
* Initialize hooks.
*
* @return void
*/
public static function init(): void {
self::get_instance();
}
/**
* Initialize WordPress hooks.
*
* @return void
*/
private function init_hooks(): void {
// Register personal data exporters.
add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'register_exporters' ) );
// Register personal data erasers.
add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'register_erasers' ) );
// Add privacy policy content suggestion.
add_action( 'admin_init', array( $this, 'add_privacy_policy_content' ) );
}
/**
* Register personal data exporters.
*
* @param array $exporters Existing exporters.
* @return array
*/
public function register_exporters( array $exporters ): array {
$exporters['wp-bnb-guest'] = array(
'exporter_friendly_name' => __( 'WP BnB Guest Profile', 'wp-bnb' ),
'callback' => array( $this, 'export_guest_data' ),
);
$exporters['wp-bnb-bookings'] = array(
'exporter_friendly_name' => __( 'WP BnB Booking History', 'wp-bnb' ),
'callback' => array( $this, 'export_booking_data' ),
);
return $exporters;
}
/**
* Register personal data erasers.
*
* @param array $erasers Existing erasers.
* @return array
*/
public function register_erasers( array $erasers ): array {
$erasers['wp-bnb-guest'] = array(
'eraser_friendly_name' => __( 'WP BnB Guest Profile', 'wp-bnb' ),
'callback' => array( $this, 'erase_guest_data' ),
);
$erasers['wp-bnb-bookings'] = array(
'eraser_friendly_name' => __( 'WP BnB Booking History', 'wp-bnb' ),
'callback' => array( $this, 'erase_booking_data' ),
);
return $erasers;
}
/**
* Export guest profile data.
*
* @param string $email Email address to export data for.
* @param int $page Page number for pagination.
* @return array Export data array.
*/
public function export_guest_data( string $email, int $page = 1 ): array {
$export_items = array();
// Find guest by email.
$guest = Guest::get_by_email( $email );
if ( $guest ) {
$data = array();
// Basic information.
$first_name = get_post_meta( $guest->ID, '_bnb_guest_first_name', true );
$last_name = get_post_meta( $guest->ID, '_bnb_guest_last_name', true );
if ( $first_name ) {
$data[] = array(
'name' => __( 'First Name', 'wp-bnb' ),
'value' => $first_name,
);
}
if ( $last_name ) {
$data[] = array(
'name' => __( 'Last Name', 'wp-bnb' ),
'value' => $last_name,
);
}
$data[] = array(
'name' => __( 'Email', 'wp-bnb' ),
'value' => get_post_meta( $guest->ID, '_bnb_guest_email', true ),
);
$phone = get_post_meta( $guest->ID, '_bnb_guest_phone', true );
if ( $phone ) {
$data[] = array(
'name' => __( 'Phone', 'wp-bnb' ),
'value' => $phone,
);
}
// Address.
$street = get_post_meta( $guest->ID, '_bnb_guest_street', true );
if ( $street ) {
$data[] = array(
'name' => __( 'Street Address', 'wp-bnb' ),
'value' => $street,
);
}
$city = get_post_meta( $guest->ID, '_bnb_guest_city', true );
if ( $city ) {
$data[] = array(
'name' => __( 'City', 'wp-bnb' ),
'value' => $city,
);
}
$postal_code = get_post_meta( $guest->ID, '_bnb_guest_postal_code', true );
if ( $postal_code ) {
$data[] = array(
'name' => __( 'Postal Code', 'wp-bnb' ),
'value' => $postal_code,
);
}
$country = get_post_meta( $guest->ID, '_bnb_guest_country', true );
if ( $country ) {
$data[] = array(
'name' => __( 'Country', 'wp-bnb' ),
'value' => $country,
);
}
// Personal details.
$nationality = get_post_meta( $guest->ID, '_bnb_guest_nationality', true );
if ( $nationality ) {
$data[] = array(
'name' => __( 'Nationality', 'wp-bnb' ),
'value' => $nationality,
);
}
$date_of_birth = get_post_meta( $guest->ID, '_bnb_guest_date_of_birth', true );
if ( $date_of_birth ) {
$data[] = array(
'name' => __( 'Date of Birth', 'wp-bnb' ),
'value' => $date_of_birth,
);
}
// ID information (sensitive).
$id_type = get_post_meta( $guest->ID, '_bnb_guest_id_type', true );
if ( $id_type ) {
$data[] = array(
'name' => __( 'ID Type', 'wp-bnb' ),
'value' => $id_type,
);
}
$id_number = get_post_meta( $guest->ID, '_bnb_guest_id_number', true );
if ( $id_number ) {
$data[] = array(
'name' => __( 'ID Number', 'wp-bnb' ),
'value' => $id_number,
);
}
$id_expiry = get_post_meta( $guest->ID, '_bnb_guest_id_expiry', true );
if ( $id_expiry ) {
$data[] = array(
'name' => __( 'ID Expiry Date', 'wp-bnb' ),
'value' => $id_expiry,
);
}
// Consent information.
$consent_data = get_post_meta( $guest->ID, '_bnb_guest_consent_data', true );
$data[] = array(
'name' => __( 'Data Processing Consent', 'wp-bnb' ),
'value' => $consent_data ? __( 'Yes', 'wp-bnb' ) : __( 'No', 'wp-bnb' ),
);
$consent_marketing = get_post_meta( $guest->ID, '_bnb_guest_consent_marketing', true );
$data[] = array(
'name' => __( 'Marketing Consent', 'wp-bnb' ),
'value' => $consent_marketing ? __( 'Yes', 'wp-bnb' ) : __( 'No', 'wp-bnb' ),
);
$consent_date = get_post_meta( $guest->ID, '_bnb_guest_consent_date', true );
if ( $consent_date ) {
$data[] = array(
'name' => __( 'Consent Date', 'wp-bnb' ),
'value' => $consent_date,
);
}
// Notes and preferences.
$preferences = get_post_meta( $guest->ID, '_bnb_guest_preferences', true );
if ( $preferences ) {
$data[] = array(
'name' => __( 'Guest Preferences', 'wp-bnb' ),
'value' => $preferences,
);
}
if ( ! empty( $data ) ) {
$export_items[] = array(
'group_id' => 'wp-bnb-guest',
'group_label' => __( 'Guest Profile', 'wp-bnb' ),
'group_description' => __( 'Your guest profile information stored by WP BnB.', 'wp-bnb' ),
'item_id' => 'guest-' . $guest->ID,
'data' => $data,
);
}
}
return array(
'data' => $export_items,
'done' => true,
);
}
/**
* Export booking history data.
*
* @param string $email Email address to export data for.
* @param int $page Page number for pagination.
* @return array Export data array.
*/
public function export_booking_data( string $email, int $page = 1 ): array {
$export_items = array();
$per_page = 20;
$offset = ( $page - 1 ) * $per_page;
// Find bookings by email (both direct and through guest_id).
$bookings = get_posts(
array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'any',
'posts_per_page' => $per_page,
'offset' => $offset,
'meta_query' => array(
'relation' => 'OR',
array(
'key' => '_bnb_booking_guest_email',
'value' => $email,
),
),
)
);
// Also check via guest_id.
$guest = Guest::get_by_email( $email );
if ( $guest ) {
$bookings_by_id = get_posts(
array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'any',
'posts_per_page' => $per_page,
'meta_query' => array(
array(
'key' => '_bnb_booking_guest_id',
'value' => $guest->ID,
),
),
)
);
// Merge and dedupe.
$existing_ids = wp_list_pluck( $bookings, 'ID' );
foreach ( $bookings_by_id as $booking ) {
if ( ! in_array( $booking->ID, $existing_ids, true ) ) {
$bookings[] = $booking;
}
}
}
foreach ( $bookings as $booking ) {
$data = array();
$reference = get_post_meta( $booking->ID, '_bnb_booking_reference', true );
if ( ! $reference ) {
$reference = 'BNB-' . $booking->ID;
}
$data[] = array(
'name' => __( 'Booking Reference', 'wp-bnb' ),
'value' => $reference,
);
$room_id = get_post_meta( $booking->ID, '_bnb_booking_room_id', true );
if ( $room_id ) {
$room = get_post( $room_id );
if ( $room ) {
$data[] = array(
'name' => __( 'Room', 'wp-bnb' ),
'value' => $room->post_title,
);
}
}
$check_in = get_post_meta( $booking->ID, '_bnb_booking_check_in', true );
if ( $check_in ) {
$data[] = array(
'name' => __( 'Check-in Date', 'wp-bnb' ),
'value' => $check_in,
);
}
$check_out = get_post_meta( $booking->ID, '_bnb_booking_check_out', true );
if ( $check_out ) {
$data[] = array(
'name' => __( 'Check-out Date', 'wp-bnb' ),
'value' => $check_out,
);
}
$status = get_post_meta( $booking->ID, '_bnb_booking_status', true );
if ( $status ) {
$statuses = Booking::get_booking_statuses();
$data[] = array(
'name' => __( 'Status', 'wp-bnb' ),
'value' => $statuses[ $status ] ?? $status,
);
}
$adults = get_post_meta( $booking->ID, '_bnb_booking_adults', true );
if ( $adults ) {
$data[] = array(
'name' => __( 'Adults', 'wp-bnb' ),
'value' => $adults,
);
}
$children = get_post_meta( $booking->ID, '_bnb_booking_children', true );
if ( $children ) {
$data[] = array(
'name' => __( 'Children', 'wp-bnb' ),
'value' => $children,
);
}
$price = get_post_meta( $booking->ID, '_bnb_booking_calculated_price', true );
if ( $price ) {
$currency = get_option( 'wp_bnb_currency', 'CHF' );
$data[] = array(
'name' => __( 'Total Price', 'wp-bnb' ),
'value' => number_format( (float) $price, 2 ) . ' ' . $currency,
);
}
$guest_notes = get_post_meta( $booking->ID, '_bnb_booking_guest_notes', true );
if ( $guest_notes ) {
$data[] = array(
'name' => __( 'Guest Notes', 'wp-bnb' ),
'value' => $guest_notes,
);
}
if ( ! empty( $data ) ) {
$export_items[] = array(
'group_id' => 'wp-bnb-bookings',
'group_label' => __( 'Booking History', 'wp-bnb' ),
'group_description' => __( 'Your booking history with WP BnB.', 'wp-bnb' ),
'item_id' => 'booking-' . $booking->ID,
'data' => $data,
);
}
}
// Check if there are more bookings.
$total_bookings = $this->count_bookings_by_email( $email );
$done = ( $offset + $per_page ) >= $total_bookings;
return array(
'data' => $export_items,
'done' => $done,
);
}
/**
* Erase guest profile data.
*
* @param string $email Email address to erase data for.
* @param int $page Page number for pagination.
* @return array Erasure result array.
*/
public function erase_guest_data( string $email, int $page = 1 ): array {
$items_removed = 0;
$items_retained = 0;
$messages = array();
// Find guest by email.
$guest = Guest::get_by_email( $email );
if ( $guest ) {
// Check if guest has active bookings.
$active_bookings = get_posts(
array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => 1,
'meta_query' => array(
'relation' => 'AND',
array(
'relation' => 'OR',
array(
'key' => '_bnb_booking_guest_id',
'value' => $guest->ID,
),
array(
'key' => '_bnb_booking_guest_email',
'value' => $email,
),
),
array(
'key' => '_bnb_booking_status',
'value' => array( 'pending', 'confirmed', 'checked_in' ),
'compare' => 'IN',
),
),
)
);
if ( ! empty( $active_bookings ) ) {
// Cannot delete - has active bookings.
$messages[] = __( 'Guest profile retained due to active bookings.', 'wp-bnb' );
$items_retained = 1;
} else {
// Anonymize the guest profile instead of deleting.
$this->anonymize_guest( $guest->ID );
$items_removed = 1;
$messages[] = __( 'Guest profile anonymized.', 'wp-bnb' );
}
}
return array(
'items_removed' => $items_removed,
'items_retained' => $items_retained,
'messages' => $messages,
'done' => true,
);
}
/**
* Erase booking data.
*
* @param string $email Email address to erase data for.
* @param int $page Page number for pagination.
* @return array Erasure result array.
*/
public function erase_booking_data( string $email, int $page = 1 ): array {
$items_removed = 0;
$items_retained = 0;
$messages = array();
$per_page = 20;
// Find completed bookings (can be anonymized).
$bookings = get_posts(
array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'any',
'posts_per_page' => $per_page,
'meta_query' => array(
'relation' => 'AND',
array(
'key' => '_bnb_booking_guest_email',
'value' => $email,
),
array(
'key' => '_bnb_booking_status',
'value' => array( 'checked_out', 'cancelled' ),
'compare' => 'IN',
),
),
)
);
// Also find by guest_id.
$guest = Guest::get_by_email( $email );
if ( $guest ) {
$more_bookings = get_posts(
array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'any',
'posts_per_page' => $per_page,
'meta_query' => array(
'relation' => 'AND',
array(
'key' => '_bnb_booking_guest_id',
'value' => $guest->ID,
),
array(
'key' => '_bnb_booking_status',
'value' => array( 'checked_out', 'cancelled' ),
'compare' => 'IN',
),
),
)
);
$existing_ids = wp_list_pluck( $bookings, 'ID' );
foreach ( $more_bookings as $booking ) {
if ( ! in_array( $booking->ID, $existing_ids, true ) ) {
$bookings[] = $booking;
}
}
}
foreach ( $bookings as $booking ) {
$this->anonymize_booking( $booking->ID );
++$items_removed;
}
// Check for active bookings that can't be erased.
$active_bookings = get_posts(
array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
'meta_query' => array(
'relation' => 'AND',
array(
'key' => '_bnb_booking_guest_email',
'value' => $email,
),
array(
'key' => '_bnb_booking_status',
'value' => array( 'pending', 'confirmed', 'checked_in' ),
'compare' => 'IN',
),
),
)
);
$items_retained = count( $active_bookings );
if ( $items_retained > 0 ) {
$messages[] = sprintf(
/* translators: %d: Number of bookings */
_n(
'%d booking retained due to active status.',
'%d bookings retained due to active status.',
$items_retained,
'wp-bnb'
),
$items_retained
);
}
if ( $items_removed > 0 ) {
$messages[] = sprintf(
/* translators: %d: Number of bookings */
_n(
'%d booking anonymized.',
'%d bookings anonymized.',
$items_removed,
'wp-bnb'
),
$items_removed
);
}
return array(
'items_removed' => $items_removed,
'items_retained' => $items_retained,
'messages' => $messages,
'done' => true,
);
}
/**
* Anonymize a guest record.
*
* @param int $guest_id Guest post ID.
* @return bool True on success.
*/
public function anonymize_guest( int $guest_id ): bool {
$anonymized = __( '[Deleted]', 'wp-bnb' );
// Update post title.
wp_update_post(
array(
'ID' => $guest_id,
'post_title' => $anonymized,
)
);
// Anonymize personal data.
update_post_meta( $guest_id, '_bnb_guest_first_name', $anonymized );
update_post_meta( $guest_id, '_bnb_guest_last_name', '' );
update_post_meta( $guest_id, '_bnb_guest_email', 'deleted-' . $guest_id . '@anonymized.local' );
update_post_meta( $guest_id, '_bnb_guest_phone', '' );
update_post_meta( $guest_id, '_bnb_guest_street', '' );
update_post_meta( $guest_id, '_bnb_guest_city', '' );
update_post_meta( $guest_id, '_bnb_guest_postal_code', '' );
update_post_meta( $guest_id, '_bnb_guest_country', '' );
update_post_meta( $guest_id, '_bnb_guest_nationality', '' );
update_post_meta( $guest_id, '_bnb_guest_date_of_birth', '' );
update_post_meta( $guest_id, '_bnb_guest_id_type', '' );
update_post_meta( $guest_id, '_bnb_guest_id_number', '' );
update_post_meta( $guest_id, '_bnb_guest_id_expiry', '' );
update_post_meta( $guest_id, '_bnb_guest_preferences', '' );
update_post_meta( $guest_id, '_bnb_guest_notes', '' );
update_post_meta( $guest_id, '_bnb_guest_status', 'inactive' );
return true;
}
/**
* Anonymize a booking record.
*
* @param int $booking_id Booking post ID.
* @return bool True on success.
*/
public function anonymize_booking( int $booking_id ): bool {
$anonymized = __( '[Deleted]', 'wp-bnb' );
// Remove guest reference.
delete_post_meta( $booking_id, '_bnb_booking_guest_id' );
// Anonymize guest data stored in booking.
update_post_meta( $booking_id, '_bnb_booking_guest_name', $anonymized );
update_post_meta( $booking_id, '_bnb_booking_guest_email', '' );
update_post_meta( $booking_id, '_bnb_booking_guest_phone', '' );
update_post_meta( $booking_id, '_bnb_booking_guest_notes', '' );
return true;
}
/**
* Count bookings by email.
*
* @param string $email Email address.
* @return int Count of bookings.
*/
private function count_bookings_by_email( string $email ): int {
$count = 0;
// Direct email match.
$direct = get_posts(
array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'any',
'posts_per_page' => -1,
'fields' => 'ids',
'meta_query' => array(
array(
'key' => '_bnb_booking_guest_email',
'value' => $email,
),
),
)
);
$count += count( $direct );
// Guest ID match.
$guest = Guest::get_by_email( $email );
if ( $guest ) {
$by_guest_id = get_posts(
array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'any',
'posts_per_page' => -1,
'fields' => 'ids',
'post__not_in' => $direct,
'meta_query' => array(
array(
'key' => '_bnb_booking_guest_id',
'value' => $guest->ID,
),
),
)
);
$count += count( $by_guest_id );
}
return $count;
}
/**
* Add privacy policy content suggestion.
*
* @return void
*/
public function add_privacy_policy_content(): void {
if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) {
return;
}
$content = sprintf(
'<h2>%s</h2>
<p>%s</p>
<h3>%s</h3>
<p>%s</p>
<ul>
<li>%s</li>
<li>%s</li>
<li>%s</li>
<li>%s</li>
<li>%s</li>
</ul>
<h3>%s</h3>
<p>%s</p>
<ul>
<li>%s</li>
<li>%s</li>
<li>%s</li>
</ul>
<h3>%s</h3>
<p>%s</p>
<h3>%s</h3>
<p>%s</p>',
__( 'Accommodation Booking', 'wp-bnb' ),
__( 'When you make a booking with us, we collect and process the following personal data to fulfill your reservation and comply with legal requirements.', 'wp-bnb' ),
__( 'What personal data we collect', 'wp-bnb' ),
__( 'We collect the following information when you make a booking:', 'wp-bnb' ),
__( 'Name and contact information (email, phone)', 'wp-bnb' ),
__( 'Address for billing and guest registration', 'wp-bnb' ),
__( 'Identity document information (as required by local regulations)', 'wp-bnb' ),
__( 'Booking details (dates, room preferences, special requests)', 'wp-bnb' ),
__( 'Payment information (processed securely by payment providers)', 'wp-bnb' ),
__( 'Why we collect this data', 'wp-bnb' ),
__( 'We use your personal data for the following purposes:', 'wp-bnb' ),
__( 'Processing and managing your booking', 'wp-bnb' ),
__( 'Communicating with you about your reservation', 'wp-bnb' ),
__( 'Complying with legal guest registration requirements', 'wp-bnb' ),
__( 'How long we retain your data', 'wp-bnb' ),
__( 'We retain your booking data for the period required by law for guest registration and accounting purposes, typically 10 years. After this period, your data will be anonymized or deleted.', 'wp-bnb' ),
__( 'Your rights', 'wp-bnb' ),
__( 'You have the right to access, correct, or request deletion of your personal data. To exercise these rights, please contact us using the information provided on this website. Note that some data may need to be retained for legal compliance purposes.', 'wp-bnb' )
);
wp_add_privacy_policy_content( 'WP BnB', wp_kses_post( $content ) );
}
}

View File

@@ -0,0 +1,276 @@
<?php
/**
* Service Category taxonomy.
*
* Non-hierarchical taxonomy for categorizing additional services.
*
* @package Magdev\WpBnb\Taxonomies
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Taxonomies;
/**
* Service Category taxonomy class.
*/
final class ServiceCategory {
/**
* Taxonomy slug.
*
* @var string
*/
public const TAXONOMY = 'bnb_service_category';
/**
* Initialize the taxonomy.
*
* @return void
*/
public static function init(): void {
add_action( 'init', array( self::class, 'register' ) );
add_action( 'bnb_service_category_add_form_fields', array( self::class, 'add_form_fields' ) );
add_action( 'bnb_service_category_edit_form_fields', array( self::class, 'edit_form_fields' ), 10, 2 );
add_action( 'created_bnb_service_category', array( self::class, 'save_term_meta' ), 10, 2 );
add_action( 'edited_bnb_service_category', array( self::class, 'save_term_meta' ), 10, 2 );
add_filter( 'manage_edit-bnb_service_category_columns', array( self::class, 'add_columns' ) );
add_filter( 'manage_bnb_service_category_custom_column', array( self::class, 'render_column' ), 10, 3 );
}
/**
* Register the taxonomy.
*
* @return void
*/
public static function register(): void {
$labels = array(
'name' => _x( 'Service Categories', 'taxonomy general name', 'wp-bnb' ),
'singular_name' => _x( 'Service Category', 'taxonomy singular name', 'wp-bnb' ),
'search_items' => __( 'Search Service Categories', 'wp-bnb' ),
'popular_items' => __( 'Popular Service Categories', 'wp-bnb' ),
'all_items' => __( 'All Service Categories', 'wp-bnb' ),
'parent_item' => null,
'parent_item_colon' => null,
'edit_item' => __( 'Edit Service Category', 'wp-bnb' ),
'update_item' => __( 'Update Service Category', 'wp-bnb' ),
'add_new_item' => __( 'Add New Service Category', 'wp-bnb' ),
'new_item_name' => __( 'New Service Category Name', 'wp-bnb' ),
'separate_items_with_commas' => __( 'Separate categories with commas', 'wp-bnb' ),
'add_or_remove_items' => __( 'Add or remove categories', 'wp-bnb' ),
'choose_from_most_used' => __( 'Choose from the most used categories', 'wp-bnb' ),
'not_found' => __( 'No service categories found.', 'wp-bnb' ),
'menu_name' => __( 'Categories', 'wp-bnb' ),
'back_to_items' => __( '&larr; Back to Categories', 'wp-bnb' ),
);
$args = array(
'labels' => $labels,
'hierarchical' => false, // Non-hierarchical (like tags).
'public' => true,
'publicly_queryable' => true,
'show_ui' => true,
'show_in_menu' => true,
'show_in_nav_menus' => true,
'show_in_rest' => true,
'show_tagcloud' => false,
'show_in_quick_edit' => true,
'show_admin_column' => true,
'rewrite' => array(
'slug' => 'service-category',
'with_front' => false,
),
'query_var' => true,
'capabilities' => array(
'manage_terms' => 'manage_options',
'edit_terms' => 'manage_options',
'delete_terms' => 'manage_options',
'assign_terms' => 'edit_posts',
),
);
register_taxonomy( self::TAXONOMY, array( 'bnb_service' ), $args );
}
/**
* Add custom fields to the add term form.
*
* @return void
*/
public static function add_form_fields(): void {
?>
<div class="form-field term-icon-wrap">
<label for="service-category-icon"><?php esc_html_e( 'Icon', 'wp-bnb' ); ?></label>
<select name="service_category_icon" id="service-category-icon">
<?php foreach ( self::get_icon_options() as $value => $label ) : ?>
<option value="<?php echo esc_attr( $value ); ?>"><?php echo esc_html( $label ); ?></option>
<?php endforeach; ?>
</select>
<p><?php esc_html_e( 'Select an icon to represent this category.', 'wp-bnb' ); ?></p>
</div>
<div class="form-field term-sort-order-wrap">
<label for="service-category-sort-order"><?php esc_html_e( 'Sort Order', 'wp-bnb' ); ?></label>
<input type="number" name="service_category_sort_order" id="service-category-sort-order" value="0" min="0">
<p><?php esc_html_e( 'Lower numbers appear first.', 'wp-bnb' ); ?></p>
</div>
<?php
}
/**
* Add custom fields to the edit term form.
*
* @param \WP_Term $term Current term object.
* @param string $taxonomy Current taxonomy slug.
* @return void
*/
public static function edit_form_fields( \WP_Term $term, string $taxonomy ): void {
$icon = get_term_meta( $term->term_id, 'service_category_icon', true );
$sort_order = get_term_meta( $term->term_id, 'service_category_sort_order', true );
?>
<tr class="form-field term-icon-wrap">
<th scope="row">
<label for="service-category-icon"><?php esc_html_e( 'Icon', 'wp-bnb' ); ?></label>
</th>
<td>
<select name="service_category_icon" id="service-category-icon">
<?php foreach ( self::get_icon_options() as $value => $label ) : ?>
<option value="<?php echo esc_attr( $value ); ?>" <?php selected( $icon, $value ); ?>>
<?php echo esc_html( $label ); ?>
</option>
<?php endforeach; ?>
</select>
<p class="description"><?php esc_html_e( 'Select an icon to represent this category.', 'wp-bnb' ); ?></p>
</td>
</tr>
<tr class="form-field term-sort-order-wrap">
<th scope="row">
<label for="service-category-sort-order"><?php esc_html_e( 'Sort Order', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="number" name="service_category_sort_order" id="service-category-sort-order"
value="<?php echo esc_attr( $sort_order ?: '0' ); ?>" min="0">
<p class="description"><?php esc_html_e( 'Lower numbers appear first.', 'wp-bnb' ); ?></p>
</td>
</tr>
<?php
}
/**
* Save term meta data.
*
* @param int $term_id Term ID.
* @param int $tt_id Term taxonomy ID.
* @return void
*/
public static function save_term_meta( int $term_id, int $tt_id ): void {
if ( isset( $_POST['service_category_icon'] ) ) {
update_term_meta(
$term_id,
'service_category_icon',
sanitize_text_field( wp_unslash( $_POST['service_category_icon'] ) )
);
}
if ( isset( $_POST['service_category_sort_order'] ) ) {
update_term_meta(
$term_id,
'service_category_sort_order',
absint( $_POST['service_category_sort_order'] )
);
}
}
/**
* Add custom columns to the taxonomy 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 ( 'name' === $key ) {
$new_columns['icon'] = __( 'Icon', 'wp-bnb' );
$new_columns['sort_order'] = __( 'Sort Order', 'wp-bnb' );
}
}
return $new_columns;
}
/**
* Render custom column content.
*
* @param string $content Column content.
* @param string $column_name Column name.
* @param int $term_id Term ID.
* @return string
*/
public static function render_column( string $content, string $column_name, int $term_id ): string {
if ( 'icon' === $column_name ) {
$icon = get_term_meta( $term_id, 'service_category_icon', true );
if ( $icon ) {
return '<span class="dashicons dashicons-' . esc_attr( $icon ) . '"></span>';
}
return '—';
}
if ( 'sort_order' === $column_name ) {
$sort_order = get_term_meta( $term_id, 'service_category_sort_order', true );
return esc_html( $sort_order ?: '0' );
}
return $content;
}
/**
* Get available icon options.
*
* @return array<string, string>
*/
public static function get_icon_options(): array {
return array(
'' => __( '— Select Icon —', 'wp-bnb' ),
'food' => __( 'Food & Dining', 'wp-bnb' ),
'car' => __( 'Transportation', 'wp-bnb' ),
'heart' => __( 'Wellness & Spa', 'wp-bnb' ),
'tickets-alt' => __( 'Activities', 'wp-bnb' ),
'admin-home' => __( 'Housekeeping', 'wp-bnb' ),
'admin-appearance' => __( 'Room Service', 'wp-bnb' ),
'store' => __( 'Shopping', 'wp-bnb' ),
'groups' => __( 'Childcare', 'wp-bnb' ),
'pets' => __( 'Pet Services', 'wp-bnb' ),
'businessman' => __( 'Business', 'wp-bnb' ),
'calendar' => __( 'Events', 'wp-bnb' ),
'camera' => __( 'Photography', 'wp-bnb' ),
'admin-generic' => __( 'Other', 'wp-bnb' ),
);
}
/**
* Get default service categories to seed on activation.
*
* @return array<string, array{icon: string, sort_order: int}>
*/
public static function get_default_terms(): array {
return array(
__( 'Food & Dining', 'wp-bnb' ) => array(
'icon' => 'food',
'sort_order' => 10,
),
__( 'Transportation', 'wp-bnb' ) => array(
'icon' => 'car',
'sort_order' => 20,
),
__( 'Wellness & Spa', 'wp-bnb' ) => array(
'icon' => 'heart',
'sort_order' => 30,
),
__( 'Activities', 'wp-bnb' ) => array(
'icon' => 'tickets-alt',
'sort_order' => 40,
),
__( 'Housekeeping', 'wp-bnb' ) => array(
'icon' => 'admin-home',
'sort_order' => 50,
),
);
}
}

View File

@@ -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.3.0
* Version: 0.11.3
* 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.3.0' );
define( 'WP_BNB_VERSION', '0.11.3' );
// Plugin path constants.
define( 'WP_BNB_PATH', plugin_dir_path( __FILE__ ) );
@@ -165,6 +165,8 @@ function wp_bnb_activate(): void {
\Magdev\WpBnb\Taxonomies\RoomType::register();
\Magdev\WpBnb\PostTypes\Building::register();
\Magdev\WpBnb\PostTypes\Room::register();
\Magdev\WpBnb\PostTypes\Booking::register();
\Magdev\WpBnb\PostTypes\Guest::register();
}
// Set default options.