18 Commits

Author SHA1 Message Date
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
0c601df568 Add booking system with calendar and email notifications (v0.3.0)
All checks were successful
Create Release Package / build-release (push) Successful in 1m5s
- Booking Custom Post Type with full management features
- Room and guest relationship tracking
- Check-in/check-out date management with validation
- Booking status workflow (pending, confirmed, checked_in, checked_out, cancelled)
- Automatic price calculation using existing Calculator
- Availability system with real-time conflict detection
- AJAX endpoint for instant availability validation
- Calendar admin page with monthly view and room/building filters
- Color-coded booking status display with legend
- Email notifications for new bookings, confirmations, and cancellations
- HTML email templates with placeholder-based system
- Auto-generated booking references (BNB-YYYY-NNNNN)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 14:37:48 +01:00
dabfe1e826 Add pricing system with tiers, seasons, and calculator (v0.2.0)
All checks were successful
Create Release Package / build-release (push) Successful in 1m19s
- Create PricingTier enum for short/mid/long-term pricing
- Add Season class for seasonal pricing with date ranges
- Implement Calculator for price calculations with breakdown
- Add pricing meta box to Room post type
- Create Seasons admin page for managing seasonal pricing
- Add Pricing settings tab with tier thresholds
- Support weekend surcharges and configurable weekend days
- Add price column to room list admin

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 14:10:30 +01:00
f24a347bb1 Add core data structures for Buildings and Rooms (v0.1.0)
All checks were successful
Create Release Package / build-release (push) Successful in 1m6s
Phase 1 implementation includes:
- Custom Post Type: Buildings with address, contact, and details meta
- Custom Post Type: Rooms with building relationship and gallery
- Custom Taxonomy: Room Types (hierarchical)
- Custom Taxonomy: Amenities (non-hierarchical with icons)
- Admin columns, filters, and status badges
- Gallery meta box with media library integration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 13:45:06 +01:00
39 changed files with 21917 additions and 124 deletions

View File

@@ -5,6 +5,463 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [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
- Booking System with full management features:
- Custom Post Type: Bookings (`bnb_booking`)
- Room and guest relationship tracking
- Check-in/check-out date management
- Booking status workflow (pending, confirmed, checked_in, checked_out, cancelled)
- Status transitions with validation
- Automatic price calculation using existing Calculator
- Price override option for manual adjustments
- Guest information storage (name, email, phone, notes)
- Guest count tracking (adults, children)
- Internal notes field for staff
- Auto-generated booking references (BNB-YYYY-NNNNN)
- Availability System (`src/Booking/Availability.php`)
- Real-time availability checking
- Conflict detection for overlapping bookings
- AJAX endpoint for instant availability validation
- Calendar data generation for rooms and buildings
- Support for excluding booking from conflict check (for editing)
- Utility methods: get upcoming bookings, current bookings, today's check-ins/outs
- Calendar Admin Page (WP BnB > Calendar)
- Monthly calendar view with availability visualization
- Room and building filter dropdowns
- Color-coded booking status display
- Month navigation (previous/next/today)
- Click-to-edit booking functionality
- Hover tooltips with booking details
- Legend for status colors
- Single room and multi-room views
- Email Notifications (`src/Booking/EmailNotifier.php`)
- Admin notification for new bookings
- Guest confirmation email on booking confirmation
- Admin notification on booking confirmation
- Cancellation emails to guest and admin
- HTML email templates with styling
- Placeholder-based template system
- Filter hooks for customizing recipients, subject, and content
- Booking Admin List Enhancements
- Custom columns: room, guest, dates, nights, price, status
- Status badges with color coding
- Filter by room and status
- Sortable columns for dates, guest, status
- Price override indicator
- Booking Meta Boxes
- Room & Dates: room selection, date pickers, nights display, availability check
- Guest Information: contact details, guest count, notes
- Pricing: calculated price, breakdown display, recalculate button, override
- Status & Notes: status dropdown with preview, internal notes
### Changed
- Plugin.php enhanced with AJAX handlers and component initialization
- Admin JavaScript updated with booking form functionality
- Admin CSS updated with booking and calendar styles
- Asset enqueuing now includes Booking post type screens
### Security
- Conflict detection prevents double-booking
- Date validation ensures check-out is after check-in
- Status transition validation prevents invalid state changes
- Nonce verification on availability AJAX requests
- Capability checks on all booking operations
## [0.2.0] - 2026-01-31
### Added
- Pricing System with three tiers:
- Short-term (nightly) pricing for stays up to 6 nights
- Mid-term (weekly) pricing for stays 7-27 nights
- Long-term (monthly) pricing for stays 28+ nights
- PricingTier enum class with automatic tier detection
- Season class for seasonal pricing management
- Date range support (MM-DD format)
- Year-spanning seasons (e.g., winter holidays Dec-Jan)
- Price modifier (multiplier) per season
- Priority system for overlapping seasons
- Active/inactive status toggle
- Calculator class for price calculations
- Automatic tier detection based on stay duration
- Seasonal price adjustments
- Weekend surcharge support
- Price breakdown for detailed invoicing
- Currency formatting with symbol/suffix support
- Pricing meta box on Room edit screen
- Base prices for each tier (nightly, weekly, monthly)
- Weekend surcharge field
- Link to pricing settings
- Pricing Settings tab in plugin settings
- Configurable tier thresholds
- Weekend days selection
- Quick view of configured seasons
- Seasons admin page (WP BnB > Seasons)
- List view with all seasons
- Add/Edit season form
- Delete confirmation
- Create default seasons option
- Price column in room list admin
- Admin CSS for pricing UI
- Admin JavaScript for pricing interactions
### Changed
- Room post type now includes pricing fields
- Plugin settings page has new Pricing tab
- Enhanced asset localization with pricing i18n strings
## [0.1.0] - 2026-01-31
### Added
- Custom Post Type: Buildings (`bnb_building`)
- Address fields (street, city, state, ZIP, country)
- Contact information (phone, email, website)
- Building details (total rooms, floors, year built)
- Check-in/check-out time configuration
- Featured image support
- Custom admin columns (city, country, room count)
- Sortable columns
- Custom Post Type: Rooms (`bnb_room`)
- Building relationship (parent building selection)
- Room details (number, floor, size, capacity)
- Guest capacity (total, max adults, max children)
- Beds description and bathroom count
- Room status (available, occupied, maintenance, blocked)
- Image gallery with drag-and-drop sorting
- Featured image support
- Custom admin columns (building, room number, type, capacity, status)
- Building filter dropdown in admin list
- Custom Taxonomy: Room Types (`bnb_room_type`)
- Hierarchical (category-like) structure
- Default types: Standard, Superior, Suite, Family, Accessible, Apartment
- Subtypes: Single, Double, Twin, Junior Suite, Executive Suite
- Base capacity meta field
- Sort order meta field
- Custom Taxonomy: Amenities (`bnb_amenity`)
- Non-hierarchical (tag-like) structure
- Default amenities: WiFi, Parking, Breakfast, TV, A/C, Pet Friendly, etc.
- Dashicon selection for visual display
- Custom column showing icon
- Admin enhancements
- Gallery meta box with media library integration
- Status badges with color coding
- Custom title placeholders for each post type
- Post type edit screens with proper asset loading
### Changed
- Updated admin assets to handle post type edit screens
- Enhanced asset enqueuing to include jQuery UI Sortable for galleries
- Improved localization with additional i18n strings
## [0.0.1] - 2026-01-31 ## [0.0.1] - 2026-01-31
### Added ### Added
@@ -35,4 +492,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Input sanitization and output escaping - Input sanitization and output escaping
- Server secret masking in license settings - Server secret masking in license settings
[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
[0.0.1]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.0.1 [0.0.1]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.0.1

572
CLAUDE.md
View File

@@ -1,4 +1,4 @@
# WordPress BnB Management # WordPress BnB Manager
**Author:** Marco Graetsch **Author:** Marco Graetsch
**Author URL:** <https://src.bundespruefstelle.ch/magdev> **Author URL:** <https://src.bundespruefstelle.ch/magdev>
@@ -38,14 +38,9 @@ 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. **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.
### Version 0.1.0 - Core Data Structures ### Known Bugs
- Custom Post Type: Buildings (address, contact, description, images) (none)
- Custom Post Type: Rooms (building reference, capacity, amenities)
- Custom Taxonomy: Room Types (Standard, Suite, Family, etc.)
- Custom Taxonomy: Amenities (WiFi, Parking, Breakfast, etc.)
- Gutenberg blocks for Buildings and Rooms
- See `PLAN.md` for full implementation roadmap
## Technical Stack ## Technical Stack
@@ -137,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 - Commit messages should follow the established format with Claude Code attribution
- `.claude/settings.local.json` changes are typically local-only (stash before rebasing) - `.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 #### What Gets Released
- All plugin source files - All plugin source files
@@ -215,18 +235,50 @@ wp-bnb/
│ └── release.yml # CI/CD release pipeline │ └── release.yml # CI/CD release pipeline
├── src/ # PHP source code (PSR-4: Magdev\WpBnb) ├── src/ # PHP source code (PSR-4: Magdev\WpBnb)
│ ├── Plugin.php # Main plugin singleton │ ├── Plugin.php # Main plugin singleton
── License/ ── Admin/ # Admin pages
── Manager.php # License management ── Calendar.php # Availability calendar page
│ │ └── Seasons.php # Seasons management page
│ ├── Blocks/ # Gutenberg blocks
│ │ └── BlockRegistrar.php # Block registration and rendering
│ ├── Booking/ # Booking system
│ │ ├── 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
│ ├── License/
│ │ └── Manager.php # License management
│ ├── PostTypes/ # Custom post types
│ │ ├── Booking.php # Booking post type
│ │ ├── Building.php # Building post type
│ │ └── Room.php # Room post type
│ ├── Pricing/ # Pricing system
│ │ ├── Calculator.php # Price calculation
│ │ ├── PricingTier.php # Pricing tier enum
│ │ └── Season.php # Seasonal pricing
│ ├── Integration/ # Third-party integrations
│ │ └── CF7.php # Contact Form 7 integration
│ └── Taxonomies/ # Custom taxonomies
│ ├── Amenity.php # Amenity taxonomy (tags)
│ └── RoomType.php # Room type taxonomy (categories)
├── lib/ # Git submodules ├── lib/ # Git submodules
│ └── wc-licensed-product-client/ # License client library │ └── wc-licensed-product-client/ # License client library
├── vendor/ # Composer dependencies (auto-generated) ├── vendor/ # Composer dependencies (auto-generated)
├── assets/ ├── assets/
│ ├── css/ │ ├── css/
│ │ ├── admin.css # Admin styles │ │ ├── admin.css # Admin styles
│ │ ── frontend.css # Frontend styles │ │ ── blocks-editor.css # Gutenberg editor styles
│ │ ├── cf7-integration.css # CF7 form styles
│ │ └── frontend.css # Frontend styles (~1250 lines)
│ └── js/ │ └── js/
│ ├── admin.js # Admin scripts │ ├── admin.js # Admin scripts
── frontend.js # Frontend scripts ── blocks-editor.js # Gutenberg editor scripts
│ ├── cf7-integration.js # CF7 form scripts
│ └── frontend.js # Frontend scripts (~825 lines)
├── templates/ # Twig templates (future) ├── templates/ # Twig templates (future)
├── languages/ # Translation files (future) ├── languages/ # Translation files (future)
└── releases/ # Release packages (git-ignored) └── releases/ # Release packages (git-ignored)
@@ -290,7 +342,7 @@ Admin features always work; frontend requires valid license.
- Implemented license settings page with validation/activation buttons - Implemented license settings page with validation/activation buttons
- Created admin CSS and JavaScript for license management - Created admin CSS and JavaScript for license management
- Created Gitea CI/CD pipeline at `.gitea/workflows/release.yml` - 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 `README.md` with user documentation
- Created `CHANGELOG.md` following Keep a Changelog format - Created `CHANGELOG.md` following Keep a Changelog format
- Updated `CLAUDE.md` with architecture details - Updated `CLAUDE.md` with architecture details
@@ -303,3 +355,497 @@ Admin features always work; frontend requires valid license.
- Settings page uses tabs with nonce-protected form submission - Settings page uses tabs with nonce-protected form submission
- AJAX handlers require `check_ajax_referer()` and `current_user_can()` checks - AJAX handlers require `check_ajax_referer()` and `current_user_can()` checks
- CI/CD workflow excludes `lib/` directory but includes `vendor/` in releases - CI/CD workflow excludes `lib/` directory but includes `vendor/` in releases
### 2026-01-31 - Version 0.1.0 (Core Data Structures)
**Completed:**
- Created Custom Post Type: Buildings (`bnb_building`)
- Address meta box with full address fields
- Contact meta box with phone, email, website
- Details meta box with rooms count, floors, year built, check-in/out times
- Custom admin columns (city, country, room count)
- Sortable columns and country dropdown
- Created Custom Post Type: Rooms (`bnb_room`)
- Building relationship via meta field
- Room details: number, floor, size, capacity, beds, bathrooms
- Room status with color-coded badges
- Image gallery with media library and drag-and-drop sorting
- Building filter dropdown in admin list
- Custom admin columns with building link
- Created Custom Taxonomy: Room Types (`bnb_room_type`)
- Hierarchical structure with parent/child support
- Base capacity and sort order meta fields
- Default terms with subtypes (Standard > Single/Double/Twin, etc.)
- Created Custom Taxonomy: Amenities (`bnb_amenity`)
- Non-hierarchical (tag-like) structure
- Dashicon selection for visual display
- Icon column in taxonomy list
- Default amenities: WiFi, Parking, Breakfast, etc.
- Updated Plugin class to register post types and taxonomies
- Enhanced admin assets for post type edit screens
- Added gallery JavaScript with media library integration
- Updated activation hook to register CPTs before flushing rewrites
- Updated version to 0.1.0
**Learnings:**
- Taxonomies must be registered before post types that use them
- `show_in_menu => 'wp-bnb'` adds post types under the plugin's main menu
- Room-building relationship uses post meta, not hierarchical post types
- Gallery implementation uses `wp.media` frame with multiple selection
- Admin assets need conditional loading based on both hook suffix and post type
- Status badges use inline styles for color coding (avoiding extra CSS complexity)
### 2026-01-31 - Version 0.2.0 (Pricing System)
**Completed:**
- Created `src/Pricing/PricingTier.php` enum class
- SHORT_TERM, MID_TERM, LONG_TERM cases
- Labels, units, default thresholds
- `fromNights()` for automatic tier detection
- Created `src/Pricing/Season.php` class
- Seasonal pricing with date ranges (MM-DD format)
- Year-spanning seasons support (e.g., Dec 20 to Jan 6)
- Price modifier (multiplier) per season
- Priority system for overlapping seasons
- CRUD operations stored in wp_options
- Default seasons: High Season, Winter Holidays, Low Season
- Created `src/Pricing/Calculator.php` class
- Price calculation with tier detection
- Seasonal modifier application
- Weekend surcharge support
- Detailed price breakdown
- Currency formatting with symbol/suffix
- Added pricing meta box to Room post type
- Base prices for each tier
- Weekend surcharge field
- Link to pricing settings
- Price column in admin list
- Added Pricing tab to settings page
- Configurable tier thresholds
- Weekend days selection (checkboxes)
- Quick view of configured seasons
- Created `src/Admin/Seasons.php` admin page
- List view with all seasons
- Add/Edit season forms
- Delete with confirmation
- Create default seasons option
- Date formatting for display
- Updated admin.css with pricing styles
- Updated admin.js with pricing interactions
- Updated Plugin.php to register Seasons admin page
- Updated version to 0.2.0
**Learnings:**
- PHP 8.1+ enums work well for pricing tiers with methods
- Season date ranges use MM-DD format for annual recurrence
- Year-spanning seasons require special comparison logic
- Price modifiers as multipliers are more flexible than percentages
- Calculator class separates concerns from post type class
- Weekend days stored as comma-separated string in options
### 2026-01-31 - Version 0.3.0 (Booking System)
**Completed:**
- Created `src/PostTypes/Booking.php` custom post type
- Room and guest relationship tracking
- Check-in/check-out date management with validation
- Status workflow (pending, confirmed, checked_in, checked_out, cancelled)
- Auto-generated booking references (BNB-YYYY-NNNNN)
- Four meta boxes: Room & Dates, Guest Info, Pricing, Status & Notes
- Conflict detection prevents double-booking
- Price calculation using existing Calculator class
- Admin columns with room, guest, dates, nights, price, status
- Filters by room and status
- Status badges with color coding
- Created `src/Booking/Availability.php` class
- Real-time availability checking via AJAX
- Conflict detection algorithm
- Calendar data generation for rooms and buildings
- Utility methods for upcoming bookings, today's check-ins/outs
- Created `src/Admin/Calendar.php` admin page
- Monthly calendar view with room/building filters
- Color-coded booking status display
- Month navigation (previous/next/today)
- Click-to-edit booking functionality
- Hover tooltips with booking details
- Legend for status colors
- Created `src/Booking/EmailNotifier.php` class
- Admin notification for new bookings
- Guest confirmation email on booking confirmation
- Cancellation emails to guest and admin
- HTML email templates with inline styles
- Placeholder-based template system
- Filter hooks for customizing emails
- Updated `src/Plugin.php`
- Registered Booking post type
- Initialized Calendar admin page
- Initialized EmailNotifier
- Added AJAX handler for availability checking
- Updated asset enqueuing for Booking screens
- Updated `assets/js/admin.js`
- Booking form with AJAX availability checking
- Real-time nights display
- Price calculation and display
- Status preview update
- Date validation (check-out after check-in)
- Calendar page interactivity
- Updated `assets/css/admin.css`
- Booking info display styles
- Availability status indicators
- Price breakdown styles
- Calendar grid and cell styles
- Legend and filter styles
- Responsive design for calendar
- Updated version to 0.3.0
**Learnings:**
- Booking conflicts use overlap detection: `A.check_in < B.check_out AND A.check_out > B.check_in`
- Excluding cancelled bookings from conflict checks allows rebooking same dates
- Guest info stored in booking meta (Phase 4 will add separate Guest CPT)
- AJAX availability check returns price calculation for immediate feedback
- Calendar displays bookings color-coded by status for quick visual overview
- HTML email templates with inline CSS for better email client compatibility
- Status transitions can trigger different email notifications via hooks
- **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

174
PLAN.md
View File

@@ -17,110 +17,110 @@ This document outlines the implementation plan for the WP BnB Management plugin.
- [x] Basic CSS and JS assets - [x] Basic CSS and JS assets
- [x] Documentation (README, PLAN, CLAUDE) - [x] Documentation (README, PLAN, CLAUDE)
### v0.1.0 - Core Data Structures ### v0.1.0 - Core Data Structures (Current)
- [ ] Custom Post Type: Buildings - [x] Custom Post Type: Buildings
- Meta fields: address, contact, description, images - Meta fields: address, contact, description, images
- Admin columns and filtering - Admin columns and filtering
- Gutenberg block for display - Gutenberg block for display (planned for Phase 6)
- [ ] Custom Post Type: Rooms - [x] Custom Post Type: Rooms
- Meta fields: building reference, capacity, amenities, images - Meta fields: building reference, capacity, amenities, images
- Relationship to Buildings (parent) - Relationship to Buildings (parent)
- Admin columns with building filter - Admin columns with building filter
- Gutenberg block for display - Gutenberg block for display (planned for Phase 6)
- [ ] Custom Taxonomy: Room Types - [x] Custom Taxonomy: Room Types
- Standard, Suite, Family, Accessible, etc. - Standard, Suite, Family, Accessible, etc.
- Hierarchical structure - Hierarchical structure
- [ ] Custom Taxonomy: Amenities - [x] Custom Taxonomy: Amenities
- WiFi, Parking, Breakfast, etc. - WiFi, Parking, Breakfast, etc.
- Non-hierarchical (tags) - Non-hierarchical (tags)
## Phase 2: Pricing System (v0.2.0) ## Phase 2: Pricing System (v0.2.0) - Complete
### Pricing Classes ### Pricing Classes
- [ ] Short-term pricing (per night, 1-6 nights) - [x] Short-term pricing (per night, 1-6 nights)
- [ ] Mid-term pricing (per week, 1-4 weeks) - [x] Mid-term pricing (per week, 1-4 weeks)
- [ ] Long-term pricing (per month, 1+ months) - [x] Long-term pricing (per month, 1+ months)
### Price Configuration ### Price Configuration
- [ ] Room-level price settings - [x] Room-level price settings
- [ ] Seasonal pricing periods - [x] Seasonal pricing periods
- [ ] Weekend/weekday differentiation - [x] Weekend/weekday differentiation
- [ ] Currency formatting and display - [x] Currency formatting and display
### Price Calculation ### Price Calculation
- [ ] Automatic tier detection based on duration - [x] Automatic tier detection based on duration
- [ ] Price breakdown display - [x] Price breakdown display
- [ ] Discount handling - [x] Discount handling (via seasonal modifiers)
## Phase 3: Booking System (v0.3.0) ## Phase 3: Booking System (v0.3.0) - Complete
### Custom Post Type: Bookings ### Custom Post Type: Bookings
- [ ] Guest reference - [x] Guest reference
- [ ] Room reference - [x] Room reference
- [ ] Check-in/check-out dates - [x] Check-in/check-out dates
- [ ] Status (pending, confirmed, checked-in, checked-out, cancelled) - [x] Status (pending, confirmed, checked-in, checked-out, cancelled)
- [ ] Price calculation and storage - [x] Price calculation and storage
- [ ] Notes field - [x] Notes field
### Calendar Integration ### Calendar Integration
- [ ] Availability calendar per room - [x] Availability calendar per room
- [ ] Availability calendar per building - [x] Availability calendar per building
- [ ] Date range picker for bookings - [x] Date range picker for bookings
- [ ] Conflict detection - [x] Conflict detection
### Booking Workflow ### Booking Workflow
- [ ] Booking creation (admin) - [x] Booking creation (admin)
- [ ] Status transitions - [x] Status transitions
- [ ] Email notifications - [x] Email notifications
- [ ] Booking confirmation - [x] Booking confirmation
## Phase 4: Guest Management (v0.4.0) ## Phase 4: Guest Management (v0.4.0) - Complete
### Custom Post Type: Guests ### Custom Post Type: Guests
- [ ] Personal information (name, email, phone) - [x] Personal information (name, email, phone)
- [ ] Address fields - [x] Address fields
- [ ] ID/Passport information - [x] ID/Passport information
- [ ] Booking history reference - [x] Booking history reference
- [ ] Notes and preferences - [x] Notes and preferences
### Privacy & Compliance ### Privacy & Compliance
- [ ] GDPR compliance features - [x] GDPR compliance features
- [ ] Data export functionality - [x] Data export functionality
- [ ] Data deletion on request - [x] Data deletion on request
- [ ] Consent tracking - [x] Consent tracking
## Phase 5: Additional Services (v0.5.0) ## Phase 5: Additional Services (v0.5.0) - Complete
### Service Options ### Service Options
- [ ] Custom Post Type: Services - [x] Custom Post Type: Services
- [ ] Price per service (or included) - [x] Price per service (or included)
- [ ] Per-booking or per-night pricing - [x] Per-booking or per-night pricing
- [ ] Service categories - [x] Service categories
### Booking Services ### Booking Services
- [ ] Service selection during booking - [x] Service selection during booking
- [ ] Automatic price calculation - [x] Automatic price calculation
- [ ] Service summary display - [x] Service summary display
## Phase 6: Frontend Features (v0.6.0) ## Phase 6: Frontend Features (v0.6.0) - Complete
### Search & Filtering ### Search & Filtering
- [ ] Room search with filters - [x] Room search with filters
- Date range (availability) - Date range (availability)
- Capacity - Capacity
- Room type - Room type
@@ -130,38 +130,39 @@ This document outlines the implementation plan for the WP BnB Management plugin.
### Display Components ### Display Components
- [ ] Building list/grid shortcode - [x] Building list/grid shortcode
- [ ] Room list/grid shortcode - [x] Room list/grid shortcode
- [ ] Room detail template - [x] Room detail template
- [ ] Availability widget - [x] Availability widget
### Gutenberg Blocks ### Gutenberg Blocks
- [ ] Building block - [x] Building block
- [ ] Room block - [x] Room block
- [ ] Room search block - [x] Room search block
- [ ] Booking form block - [x] Buildings list block
- [x] Rooms list block
### Widgets ### Widgets
- [ ] Similar rooms widget - [x] Similar rooms widget
- [ ] Building rooms widget - [x] Building rooms widget
- [ ] Availability calendar 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 ### Booking Request Form
- [ ] Custom CF7 tags for rooms/dates - [x] Custom CF7 tags for rooms/dates
- [ ] Form validation - [x] Form validation
- [ ] Booking creation on submission - [x] Booking creation on submission
- [ ] Email notifications - [x] Email notifications
### Inquiry Form ### Inquiry Form
- [ ] General inquiry handling - [x] General inquiry handling
- [ ] Room-specific inquiries - [x] Room-specific inquiries
- [ ] Auto-response templates - [x] Auto-response templates (uses default CF7 mail templates)
## Phase 8: Dashboard & Reports (v0.8.0) ## Phase 8: Dashboard & Reports (v0.8.0)
@@ -179,6 +180,17 @@ This document outlines the implementation plan for the WP BnB Management plugin.
- [ ] Guest statistics - [ ] Guest statistics
- [ ] Export functionality (CSV, PDF) - [ ] Export functionality (CSV, PDF)
## Phase 9: Prometheus Metrics (v0.9.0)
- [ ] Meanigful Metrics for this Plugin, see <https://src.bundespruefstelle.ch/magdev/wp-prometheus/raw/branch/main/README.md> for implementation details
- [ ] Example Grafana-Dashboard, see <https://src.bundespruefstelle.ch/magdev/wp-prometheus/raw/branch/main/README.md> for implementation details
- [ ] Update settings page to enable/disable metrics
## Phase 10: Security Audit (v0.10.0)
- [ ] Check for Wordpress best-practises
- [ ] Review the code for OWASP Top 10, including XSS, XSRF, SQLi and other critical threads
## Future Considerations (v1.0.0+) ## Future Considerations (v1.0.0+)
### WooCommerce Integration (Optional) ### WooCommerce Integration (Optional)
@@ -286,14 +298,16 @@ The plugin will provide extensive hooks for customization:
## Version Milestones ## Version Milestones
| Version | Focus | Target | | Version | Focus | Target |
|---------|-------|--------| | ------- | ------------------ | -------- |
| 0.0.1 | Initial setup | Complete | | 0.0.1 | Initial setup | Complete |
| 0.1.0 | Data structures | TBD | | 0.1.0 | Data structures | Complete |
| 0.2.0 | Pricing | TBD | | 0.2.0 | Pricing | Complete |
| 0.3.0 | Bookings | TBD | | 0.3.0 | Bookings | Complete |
| 0.4.0 | Guests | TBD | | 0.4.0 | Guests | Complete |
| 0.5.0 | Services | TBD | | 0.5.0 | Services | Complete |
| 0.6.0 | Frontend | TBD | | 0.6.0 | Frontend | Complete |
| 0.7.0 | CF7 Integration | TBD | | 0.7.0 | CF7 Integration | Complete |
| 0.8.0 | Dashboard | TBD | | 0.8.0 | Dashboard | TBD |
| 0.9.0 | Prometheus Metrics | TBD |
| 0.10.0 | Security Audit | TBD |
| 1.0.0 | Stable Release | TBD | | 1.0.0 | Stable Release | TBD |

212
README.md
View File

@@ -10,17 +10,22 @@ 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 - **Multi-Property Support**: Manage multiple buildings, each with multiple rooms
- **Flexible Pricing**: Configure short-term (nights), mid-term (weeks), and long-term (months) pricing - **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 - **Booking Management**: Track reservations from inquiry to checkout
- **Guest Management**: Store guest information securely with GDPR compliance - **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 - **Additional Services**: Offer extras like breakfast, parking, or tours
- **Frontend Integration**: Gutenberg blocks, widgets, and shortcodes - **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
### Requirements ### Requirements
- WordPress 6.0 or higher - WordPress 6.0 or higher
- PHP 8.3 or higher - PHP 8.3 or higher
- Valid license key - Valid license key
- Contact Form 7 (optional, for booking forms)
## Installation ## Installation
@@ -44,6 +49,23 @@ WP BnB Management enables WordPress to act as a full management system for B&B h
- **Business Name**: Your B&B business name - **Business Name**: Your B&B business name
- **Currency**: Select your preferred currency (CHF, EUR, USD, GBP) - **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 ## Usage
@@ -81,26 +103,192 @@ WP BnB Management enables WordPress to act as a full management system for B&B h
Display buildings and rooms on your site using shortcodes: Display buildings and rooms on your site using shortcodes:
```txt ```txt
[wp_bnb_buildings] [bnb_buildings] - List all buildings (grid/list layout)
[wp_bnb_rooms building="123"] [bnb_rooms building="123"] - List rooms, optionally filtered by building
[wp_bnb_room_search] [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 ## Gutenberg Blocks
The following blocks are available in the block editor: The following blocks are available in the block editor:
- **Building** - Display a single building - **Building** - Display a single building with details
- **Room** - Display a single room - **Room** - Display a single room with availability form
- **Room Search** - Search and filter rooms - **Room Search** - Interactive search form with filters
- **Booking Form** - Accept booking requests - **Buildings List** - Display buildings grid/list
- **Rooms List** - Display rooms grid/list with filters
## Widgets ## Widgets
Available sidebar 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 - **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 ## Hooks and Filters
@@ -123,7 +311,7 @@ add_action( 'wp_bnb_before_booking_create', function( $booking_data ) {
### Do I need a license to use this plugin? ### 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? ### Can I manage multiple properties?
@@ -137,6 +325,10 @@ Yes, guest data can be exported and deleted on request, and consent is tracked a
WooCommerce integration for payments is planned for a future release. WooCommerce integration for payments is planned for a future release.
### 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 ## Changelog
See [CHANGELOG.md](CHANGELOG.md) for a detailed list of changes. See [CHANGELOG.md](CHANGELOG.md) for a detailed list of changes.

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

@@ -91,9 +91,952 @@
} }
} }
/**
* 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.
*/
function initRoomGallery() {
var $container = $('#bnb-room-gallery');
var $addButton = $('#bnb-add-gallery-images');
var $input = $('#bnb_room_gallery');
var $imagesContainer = $container.find('.bnb-gallery-images');
if (!$addButton.length) {
return;
}
// Media frame for selecting images.
var mediaFrame;
// Add images button click.
$addButton.on('click', function(e) {
e.preventDefault();
// If frame exists, reopen it.
if (mediaFrame) {
mediaFrame.open();
return;
}
// Create media frame.
mediaFrame = wp.media({
title: wpBnbAdmin.i18n.selectImages,
button: {
text: wpBnbAdmin.i18n.addToGallery
},
multiple: true,
library: {
type: 'image'
}
});
// Handle selection.
mediaFrame.on('select', function() {
var selection = mediaFrame.state().get('selection');
selection.each(function(attachment) {
var data = attachment.toJSON();
var thumbnail = data.sizes.thumbnail ? data.sizes.thumbnail.url : data.url;
// Check if already in gallery.
if ($imagesContainer.find('[data-id="' + data.id + '"]').length) {
return;
}
// Add image to gallery.
var $image = $('<div class="bnb-gallery-image" data-id="' + data.id + '">' +
'<img src="' + thumbnail + '" alt="">' +
'<button type="button" class="bnb-remove-image">&times;</button>' +
'</div>');
$imagesContainer.append($image);
});
updateGalleryInput();
});
mediaFrame.open();
});
// Remove image button click.
$imagesContainer.on('click', '.bnb-remove-image', function(e) {
e.preventDefault();
$(this).closest('.bnb-gallery-image').remove();
updateGalleryInput();
});
// Make gallery sortable.
$imagesContainer.sortable({
items: '.bnb-gallery-image',
cursor: 'move',
update: function() {
updateGalleryInput();
}
});
/**
* Update the hidden input with gallery image IDs.
*/
function updateGalleryInput() {
var ids = [];
$imagesContainer.find('.bnb-gallery-image').each(function() {
ids.push($(this).data('id'));
});
$input.val(ids.join(','));
}
}
/**
* Initialize pricing settings functionality.
*/
function initPricingSettings() {
var $midTermInput = $('#wp_bnb_mid_term_max_nights');
var $longTermMin = $('#wp-bnb-long-term-min');
if (!$midTermInput.length || !$longTermMin.length) {
return;
}
// Update long-term minimum display when mid-term max changes.
$midTermInput.on('input', function() {
$longTermMin.text($(this).val());
});
}
/**
* Initialize season form validation.
*/
function initSeasonForm() {
var $form = $('.bnb-season-form');
if (!$form.length) {
return;
}
// Validate date format on input.
$form.find('#season_start_date, #season_end_date').on('input', function() {
var value = $(this).val();
var isValid = /^\d{2}-\d{2}$/.test(value);
if (value.length > 0 && !isValid) {
$(this).css('border-color', '#d63638');
} else {
$(this).css('border-color', '');
}
});
// Validate modifier range.
$form.find('#season_modifier').on('input', function() {
var value = parseFloat($(this).val());
var $preview = $(this).siblings('.bnb-modifier-preview');
if ($preview.length === 0) {
$preview = $('<span class="bnb-modifier-preview"></span>');
$(this).after($preview);
}
if (!isNaN(value) && value > 0) {
var percentage = ((value - 1) * 100).toFixed(0);
var text = '';
if (percentage > 0) {
text = '+' + percentage + '% ' + (wpBnbAdmin.i18n.increase || 'increase');
$preview.css('color', '#d63638');
} else if (percentage < 0) {
text = percentage + '% ' + (wpBnbAdmin.i18n.discount || 'discount');
$preview.css('color', '#00a32a');
} else {
text = wpBnbAdmin.i18n.normalPrice || 'Normal price';
$preview.css('color', '#646970');
}
$preview.text(' = ' + text);
} else {
$preview.text('');
}
}).trigger('input');
}
/**
* Initialize pricing meta box interactions.
*/
function initPricingMetaBox() {
var $pricingTable = $('.bnb-pricing-table');
if (!$pricingTable.length) {
return;
}
// Highlight empty required prices.
$pricingTable.find('input[type="number"]').on('blur', function() {
var $input = $(this);
var value = $input.val();
var isShortTerm = $input.attr('id').indexOf('short_term') !== -1;
// Short-term price is recommended.
if (isShortTerm && (value === '' || parseFloat(value) <= 0)) {
$input.css('background-color', '#fcf0f1');
} else {
$input.css('background-color', '');
}
});
}
/**
* Initialize booking form functionality.
*/
function initBookingForm() {
var $roomSelect = $('#bnb_booking_room_id');
var $checkInInput = $('#bnb_booking_check_in');
var $checkOutInput = $('#bnb_booking_check_out');
var $nightsDisplay = $('#bnb-booking-nights-display');
var $availabilityDisplay = $('#bnb-booking-availability-display');
var $priceDisplay = $('#bnb-booking-price-display');
var $calculatedPriceInput = $('#bnb_booking_calculated_price');
var $priceBreakdownInput = $('#bnb_booking_price_breakdown');
var $breakdownDisplay = $('#bnb-booking-breakdown-display');
var $recalculateBtn = $('#bnb-recalculate-price');
var $statusSelect = $('#bnb_booking_status');
var $statusPreview = $('#bnb-status-preview .bnb-status-badge');
// Check if we're on a booking edit page.
if (!$roomSelect.length || !$checkInInput.length) {
return;
}
// Get current booking ID if editing.
var bookingId = null;
var $postId = $('input[name="post_ID"]');
if ($postId.length) {
bookingId = parseInt($postId.val(), 10);
}
// Debounce timer for availability check.
var availabilityTimer = null;
/**
* Update nights display based on selected dates.
*/
function updateNightsDisplay() {
var checkIn = $checkInInput.val();
var checkOut = $checkOutInput.val();
if (checkIn && checkOut) {
var startDate = new Date(checkIn);
var endDate = new Date(checkOut);
var nights = Math.ceil((endDate - startDate) / (1000 * 60 * 60 * 24));
if (nights > 0) {
var nightText = nights === 1 ? wpBnbAdmin.i18n.night : wpBnbAdmin.i18n.nights;
$nightsDisplay.text(nights + ' ' + nightText);
} else {
$nightsDisplay.text(wpBnbAdmin.i18n.error || 'Invalid date range');
$nightsDisplay.css('color', '#d63638');
}
} else {
$nightsDisplay.text(wpBnbAdmin.i18n.selectRoomAndDates);
$nightsDisplay.css('color', '');
}
}
/**
* Check availability via AJAX.
*/
function checkAvailability() {
var roomId = $roomSelect.val();
var checkIn = $checkInInput.val();
var checkOut = $checkOutInput.val();
if (!roomId || !checkIn || !checkOut) {
$availabilityDisplay
.text(wpBnbAdmin.i18n.selectRoomAndDates)
.removeClass('bnb-available bnb-not-available')
.addClass('bnb-checking');
return;
}
// Validate dates.
var startDate = new Date(checkIn);
var endDate = new Date(checkOut);
if (endDate <= startDate) {
$availabilityDisplay
.text(wpBnbAdmin.i18n.error || 'Check-out must be after check-in')
.removeClass('bnb-available bnb-checking')
.addClass('bnb-not-available');
return;
}
// Show checking status.
$availabilityDisplay
.text(wpBnbAdmin.i18n.checking)
.removeClass('bnb-available bnb-not-available')
.addClass('bnb-checking');
// Make AJAX request.
$.ajax({
url: wpBnbAdmin.ajaxUrl,
type: 'POST',
data: {
action: 'wp_bnb_check_availability',
nonce: wpBnbAdmin.nonce,
room_id: roomId,
check_in: checkIn,
check_out: checkOut,
exclude_booking: bookingId
},
success: function(response) {
if (response.success) {
var data = response.data;
if (data.available) {
$availabilityDisplay
.html('<span class="dashicons dashicons-yes-alt"></span> ' + wpBnbAdmin.i18n.available)
.removeClass('bnb-not-available bnb-checking')
.addClass('bnb-available');
// Update price display.
if (data.price_formatted) {
$priceDisplay.html('<strong>' + data.price_formatted + '</strong>');
$calculatedPriceInput.val(data.price);
if (data.breakdown) {
$priceBreakdownInput.val(JSON.stringify(data.breakdown));
updateBreakdownDisplay(data.breakdown);
}
}
} else {
var conflictText = wpBnbAdmin.i18n.notAvailable;
if (data.conflicts && data.conflicts.length > 0) {
conflictText += ' (' + data.conflicts[0].reference + ')';
}
$availabilityDisplay
.html('<span class="dashicons dashicons-dismiss"></span> ' + conflictText)
.removeClass('bnb-available bnb-checking')
.addClass('bnb-not-available');
}
// Update nights display with response data.
if (data.nights) {
var nightText = data.nights === 1 ? wpBnbAdmin.i18n.night : wpBnbAdmin.i18n.nights;
$nightsDisplay.text(data.nights + ' ' + nightText);
}
} else {
$availabilityDisplay
.text(response.data.message || wpBnbAdmin.i18n.error)
.removeClass('bnb-available bnb-checking')
.addClass('bnb-not-available');
}
},
error: function() {
$availabilityDisplay
.text(wpBnbAdmin.i18n.error)
.removeClass('bnb-available bnb-checking')
.addClass('bnb-not-available');
}
});
}
/**
* Update breakdown display with formatted data.
*
* @param {Object} breakdown Price breakdown data.
*/
function updateBreakdownDisplay(breakdown) {
if (!$breakdownDisplay.length) {
return;
}
var html = '<ul class="bnb-breakdown-list">';
if (breakdown.tier) {
html += '<li><strong>Pricing Tier:</strong> ' + breakdown.tier.replace('_', ' ') + '</li>';
}
if (breakdown.nights && Array.isArray(breakdown.nights)) {
html += '<li><strong>Nights:</strong> ' + breakdown.nights.length + '</li>';
if (breakdown.nightly_rate) {
html += '<li><strong>Nightly Rate:</strong> ' + formatPrice(breakdown.nightly_rate) + '</li>';
}
} else if (breakdown.weeks) {
html += '<li><strong>Weeks:</strong> ' + breakdown.weeks + '</li>';
if (breakdown.weekly_rate) {
html += '<li><strong>Weekly Rate:</strong> ' + formatPrice(breakdown.weekly_rate) + '</li>';
}
} else if (breakdown.months) {
html += '<li><strong>Months:</strong> ' + breakdown.months + '</li>';
if (breakdown.monthly_rate) {
html += '<li><strong>Monthly Rate:</strong> ' + formatPrice(breakdown.monthly_rate) + '</li>';
}
}
if (breakdown.total) {
html += '<li><strong>Total:</strong> ' + formatPrice(breakdown.total) + '</li>';
}
html += '</ul>';
$breakdownDisplay.html(html);
}
/**
* Format price for display.
*
* @param {number} price Price value.
* @return {string} Formatted price.
*/
function formatPrice(price) {
// Simple formatting - server-side Calculator::formatPrice is more complete.
return parseFloat(price).toFixed(2);
}
/**
* Debounced availability check.
*/
function debouncedAvailabilityCheck() {
updateNightsDisplay();
// Clear existing timer.
if (availabilityTimer) {
clearTimeout(availabilityTimer);
}
// Set new timer to check availability after 500ms.
availabilityTimer = setTimeout(checkAvailability, 500);
}
// Bind change events to trigger availability check.
$roomSelect.on('change', debouncedAvailabilityCheck);
$checkInInput.on('change', debouncedAvailabilityCheck);
$checkOutInput.on('change', debouncedAvailabilityCheck);
// Recalculate price button.
if ($recalculateBtn.length) {
$recalculateBtn.on('click', function(e) {
e.preventDefault();
checkAvailability();
});
}
// Status preview update.
if ($statusSelect.length && $statusPreview.length) {
$statusSelect.on('change', function() {
var $selected = $(this).find('option:selected');
var color = $selected.data('color') || '#ccc';
var text = $selected.text();
$statusPreview
.css('background-color', color)
.text(text);
});
}
// Set min date for check-in to today.
var today = new Date().toISOString().split('T')[0];
$checkInInput.attr('min', today);
// Update check-out min date when check-in changes.
$checkInInput.on('change', function() {
var checkIn = $(this).val();
if (checkIn) {
var nextDay = new Date(checkIn);
nextDay.setDate(nextDay.getDate() + 1);
var minCheckOut = nextDay.toISOString().split('T')[0];
$checkOutInput.attr('min', minCheckOut);
// If check-out is before new min, update it.
if ($checkOutInput.val() && $checkOutInput.val() <= checkIn) {
$checkOutInput.val(minCheckOut);
}
}
});
}
/**
* Initialize calendar page functionality.
*/
function initCalendarPage() {
var $calendar = $('.bnb-calendar-grid');
if (!$calendar.length) {
return;
}
// Add hover effect for booking cells.
$calendar.on('mouseenter', '.bnb-calendar-day.booked', function() {
var bookingId = $(this).data('booking-id');
if (bookingId) {
$calendar.find('.bnb-calendar-day[data-booking-id="' + bookingId + '"]')
.addClass('booking-hover');
}
}).on('mouseleave', '.bnb-calendar-day.booked', function() {
$calendar.find('.bnb-calendar-day.booking-hover')
.removeClass('booking-hover');
});
// Click to edit booking.
$calendar.on('click', '.bnb-calendar-day.booked', function() {
var bookingId = $(this).data('booking-id');
if (bookingId) {
window.location.href = wpBnbAdmin.ajaxUrl.replace('admin-ajax.php', 'post.php?post=' + bookingId + '&action=edit');
}
});
}
/**
* Initialize 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 on document ready. // Initialize on document ready.
$(document).ready(function() { $(document).ready(function() {
initLicenseManagement(); initLicenseManagement();
initUpdateCheck();
initRoomGallery();
initPricingSettings();
initSeasonForm();
initPricingMetaBox();
initBookingForm();
initCalendarPage();
initGuestSearch();
initServicePricing();
initBookingServices();
}); });
})(jQuery); })(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 * WP BnB Frontend JavaScript
* *
* Handles search forms, calendar widgets, and interactive elements.
*
* @package Magdev\WpBnb * @package Magdev\WpBnb
*/ */
(function() { (function() {
'use strict'; '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;
})(); })();

359
src/Admin/Calendar.php Normal file
View File

@@ -0,0 +1,359 @@
<?php
/**
* Calendar admin page.
*
* Displays availability calendar for rooms and buildings.
*
* @package Magdev\WpBnb\Admin
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Admin;
use Magdev\WpBnb\Booking\Availability;
use Magdev\WpBnb\PostTypes\Booking;
use Magdev\WpBnb\PostTypes\Building;
use Magdev\WpBnb\PostTypes\Room;
/**
* Calendar admin page class.
*/
final class Calendar {
/**
* Initialize the calendar page.
*
* @return void
*/
public static function init(): void {
add_action( 'admin_menu', array( self::class, 'register_menu' ) );
}
/**
* Register the admin menu item.
*
* @return void
*/
public static function register_menu(): void {
add_submenu_page(
'wp-bnb',
__( 'Calendar', 'wp-bnb' ),
__( 'Calendar', 'wp-bnb' ),
'edit_posts',
'wp-bnb-calendar',
array( self::class, 'render_page' )
);
}
/**
* Render the calendar page.
*
* @return void
*/
public static function render_page(): void {
// Get filter parameters.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only page.
$building_id = isset( $_GET['building_id'] ) ? absint( $_GET['building_id'] ) : 0;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only page.
$room_id = isset( $_GET['room_id'] ) ? absint( $_GET['room_id'] ) : 0;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only page.
$year = isset( $_GET['year'] ) ? absint( $_GET['year'] ) : (int) gmdate( 'Y' );
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only page.
$month = isset( $_GET['month'] ) ? absint( $_GET['month'] ) : (int) gmdate( 'n' );
// Validate month.
if ( $month < 1 || $month > 12 ) {
$month = (int) gmdate( 'n' );
}
// Get buildings and rooms for filters.
$buildings = get_posts(
array(
'post_type' => Building::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'orderby' => 'title',
'order' => 'ASC',
)
);
$rooms = get_posts(
array(
'post_type' => Room::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'orderby' => 'title',
'order' => 'ASC',
)
);
// If room is selected, get its building.
if ( $room_id && ! $building_id ) {
$building = Room::get_building( $room_id );
if ( $building ) {
$building_id = $building->ID;
}
}
// Get rooms to display.
$display_rooms = array();
if ( $room_id ) {
$room = get_post( $room_id );
if ( $room ) {
$display_rooms[] = $room;
}
} elseif ( $building_id ) {
$display_rooms = Room::get_rooms_for_building( $building_id );
} else {
$display_rooms = $rooms;
}
// Calculate navigation dates.
$prev_month = $month === 1 ? 12 : $month - 1;
$prev_year = $month === 1 ? $year - 1 : $year;
$next_month = $month === 12 ? 1 : $month + 1;
$next_year = $month === 12 ? $year + 1 : $year;
$month_name = gmdate( 'F', mktime( 0, 0, 0, $month, 1, $year ) );
?>
<div class="wrap">
<h1><?php esc_html_e( 'Availability Calendar', 'wp-bnb' ); ?></h1>
<div class="bnb-calendar-container">
<!-- Calendar Header -->
<div class="bnb-calendar-header">
<div class="bnb-calendar-nav">
<a href="<?php echo esc_url( self::get_calendar_url( $prev_year, $prev_month, $building_id, $room_id ) ); ?>"
class="button">
&laquo; <?php esc_html_e( 'Previous', 'wp-bnb' ); ?>
</a>
<a href="<?php echo esc_url( self::get_calendar_url( (int) gmdate( 'Y' ), (int) gmdate( 'n' ), $building_id, $room_id ) ); ?>"
class="button">
<?php esc_html_e( 'Today', 'wp-bnb' ); ?>
</a>
<a href="<?php echo esc_url( self::get_calendar_url( $next_year, $next_month, $building_id, $room_id ) ); ?>"
class="button">
<?php esc_html_e( 'Next', 'wp-bnb' ); ?> &raquo;
</a>
</div>
<h2><?php echo esc_html( $month_name . ' ' . $year ); ?></h2>
<div></div>
</div>
<!-- Filters -->
<div class="bnb-calendar-filters">
<form method="get" action="">
<input type="hidden" name="page" value="wp-bnb-calendar">
<input type="hidden" name="year" value="<?php echo esc_attr( $year ); ?>">
<input type="hidden" name="month" value="<?php echo esc_attr( $month ); ?>">
<label>
<?php esc_html_e( 'Building:', 'wp-bnb' ); ?>
<select name="building_id" onchange="this.form.submit()">
<option value=""><?php esc_html_e( 'All Buildings', '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>
</label>
<label>
<?php esc_html_e( 'Room:', 'wp-bnb' ); ?>
<select name="room_id" onchange="this.form.submit()">
<option value=""><?php esc_html_e( 'All Rooms', 'wp-bnb' ); ?></option>
<?php foreach ( $rooms as $room ) : ?>
<?php
$room_building = Room::get_building( $room->ID );
$room_label = $room->post_title;
if ( $room_building ) {
$room_label .= ' (' . $room_building->post_title . ')';
}
?>
<option value="<?php echo esc_attr( $room->ID ); ?>" <?php selected( $room_id, $room->ID ); ?>>
<?php echo esc_html( $room_label ); ?>
</option>
<?php endforeach; ?>
</select>
</label>
</form>
</div>
<!-- Calendar Grid -->
<div class="bnb-calendar-grid">
<?php if ( empty( $display_rooms ) ) : ?>
<div class="bnb-no-rooms">
<span class="dashicons dashicons-calendar-alt"></span>
<p><?php esc_html_e( 'No rooms found. Please add rooms first.', 'wp-bnb' ); ?></p>
<a href="<?php echo esc_url( admin_url( 'post-new.php?post_type=' . Room::POST_TYPE ) ); ?>" class="button button-primary">
<?php esc_html_e( 'Add Room', 'wp-bnb' ); ?>
</a>
</div>
<?php else : ?>
<?php self::render_calendar_table( $display_rooms, $year, $month, (bool) $room_id ); ?>
<?php endif; ?>
</div>
<!-- Legend -->
<div class="bnb-calendar-legend">
<div class="bnb-calendar-legend-item">
<span class="bnb-calendar-legend-color available"></span>
<?php esc_html_e( 'Available', 'wp-bnb' ); ?>
</div>
<div class="bnb-calendar-legend-item">
<span class="bnb-calendar-legend-color pending"></span>
<?php esc_html_e( 'Pending', 'wp-bnb' ); ?>
</div>
<div class="bnb-calendar-legend-item">
<span class="bnb-calendar-legend-color confirmed"></span>
<?php esc_html_e( 'Confirmed', 'wp-bnb' ); ?>
</div>
<div class="bnb-calendar-legend-item">
<span class="bnb-calendar-legend-color checked-in"></span>
<?php esc_html_e( 'Checked In', 'wp-bnb' ); ?>
</div>
</div>
</div>
</div>
<?php
}
/**
* Render the calendar table.
*
* @param array $rooms Rooms to display.
* @param int $year Year.
* @param int $month Month.
* @param bool $single_room Whether showing single room (more detail).
* @return void
*/
private static function render_calendar_table( array $rooms, int $year, int $month, bool $single_room = false ): void {
$days_in_month = (int) gmdate( 't', mktime( 0, 0, 0, $month, 1, $year ) );
$today = gmdate( 'Y-m-d' );
$class = $single_room ? 'bnb-calendar-table bnb-calendar-single-room' : 'bnb-calendar-table';
?>
<table class="<?php echo esc_attr( $class ); ?>">
<thead>
<tr>
<th class="room-header"><?php esc_html_e( 'Room', 'wp-bnb' ); ?></th>
<?php for ( $day = 1; $day <= $days_in_month; $day++ ) : ?>
<?php
$date_str = sprintf( '%04d-%02d-%02d', $year, $month, $day );
$day_of_week = gmdate( 'D', strtotime( $date_str ) );
$is_weekend = in_array( gmdate( 'N', strtotime( $date_str ) ), array( 6, 7 ), true );
?>
<th class="<?php echo $is_weekend ? 'weekend' : ''; ?>">
<?php echo esc_html( $day ); ?><br>
<small><?php echo esc_html( $day_of_week ); ?></small>
</th>
<?php endfor; ?>
</tr>
</thead>
<tbody>
<?php foreach ( $rooms as $room ) : ?>
<?php
$room_number = get_post_meta( $room->ID, '_bnb_room_room_number', true );
$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>
<?php endif; ?>
</td>
<?php for ( $day = 1; $day <= $days_in_month; $day++ ) : ?>
<?php
$date_str = sprintf( '%04d-%02d-%02d', $year, $month, $day );
$is_past = $date_str < $today;
$is_today = $date_str === $today;
$is_booked = isset( $booked_dates[ $date_str ] );
$classes = array( 'bnb-calendar-day' );
$title = '';
$booking_id = 0;
if ( $is_past ) {
$classes[] = 'past';
}
if ( $is_today ) {
$classes[] = 'today';
}
if ( $is_booked ) {
$booking_data = $booked_dates[ $date_str ];
$booking_id = $booking_data['booking_id'];
$classes[] = 'booked';
$classes[] = 'status-' . $booking_data['status'];
if ( $booking_data['is_start'] ) {
$classes[] = 'booked-start';
}
if ( $booking_data['is_end'] ) {
$classes[] = 'booked-end';
}
$title = sprintf(
/* translators: 1: Booking reference, 2: Guest name, 3: Check-in date, 4: Check-out date */
__( '%1$s - %2$s (%3$s to %4$s)', 'wp-bnb' ),
$booking_data['reference'],
$booking_data['guest'],
wp_date( get_option( 'date_format' ), strtotime( $booking_data['check_in'] ) ),
wp_date( get_option( 'date_format' ), strtotime( $booking_data['check_out'] ) )
);
} elseif ( ! $is_past ) {
$classes[] = 'available';
}
?>
<td class="<?php echo esc_attr( implode( ' ', $classes ) ); ?>"
<?php if ( $booking_id ) : ?>
data-booking-id="<?php echo esc_attr( $booking_id ); ?>"
<?php endif; ?>
<?php if ( $title ) : ?>
title="<?php echo esc_attr( $title ); ?>"
<?php endif; ?>>
<?php if ( $single_room && $is_booked && $booking_data['is_start'] ) : ?>
<span class="guest-name"><?php echo esc_html( $booking_data['guest'] ); ?></span>
<?php endif; ?>
</td>
<?php endfor; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php
}
/**
* Get calendar URL with parameters.
*
* @param int $year Year.
* @param int $month Month.
* @param int $building_id Building ID (optional).
* @param int $room_id Room ID (optional).
* @return string URL.
*/
private static function get_calendar_url( int $year, int $month, int $building_id = 0, int $room_id = 0 ): string {
$args = array(
'page' => 'wp-bnb-calendar',
'year' => $year,
'month' => $month,
);
if ( $building_id ) {
$args['building_id'] = $building_id;
}
if ( $room_id ) {
$args['room_id'] = $room_id;
}
return add_query_arg( $args, admin_url( 'admin.php' ) );
}
}

419
src/Admin/Seasons.php Normal file
View File

@@ -0,0 +1,419 @@
<?php
/**
* Seasons admin page.
*
* Handles the admin interface for managing seasonal pricing.
*
* @package Magdev\WpBnb\Admin
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Admin;
use Magdev\WpBnb\Pricing\Season;
/**
* Seasons admin page class.
*/
final class Seasons {
/**
* Initialize the admin page.
*
* @return void
*/
public static function init(): void {
add_action( 'admin_menu', array( self::class, 'register_menu' ) );
add_action( 'admin_init', array( self::class, 'handle_actions' ) );
}
/**
* Register the submenu page.
*
* @return void
*/
public static function register_menu(): void {
add_submenu_page(
'wp-bnb',
__( 'Seasons', 'wp-bnb' ),
__( 'Seasons', 'wp-bnb' ),
'manage_options',
'wp-bnb-seasons',
array( self::class, 'render_page' )
);
}
/**
* Handle form actions.
*
* @return void
*/
public static function handle_actions(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Just checking page.
if ( ! isset( $_GET['page'] ) || 'wp-bnb-seasons' !== $_GET['page'] ) {
return;
}
// Handle delete action.
if ( isset( $_GET['action'], $_GET['season_id'], $_GET['_wpnonce'] )
&& 'delete' === $_GET['action']
&& wp_verify_nonce( sanitize_key( $_GET['_wpnonce'] ), 'delete_season' )
) {
$season_id = sanitize_text_field( wp_unslash( $_GET['season_id'] ) );
Season::delete( $season_id );
wp_safe_redirect( admin_url( 'admin.php?page=wp-bnb-seasons&deleted=1' ) );
exit;
}
// Handle create default seasons.
if ( isset( $_GET['action'], $_GET['_wpnonce'] )
&& 'create_defaults' === $_GET['action']
&& wp_verify_nonce( sanitize_key( $_GET['_wpnonce'] ), 'create_default_seasons' )
) {
Season::createDefaults();
wp_safe_redirect( admin_url( 'admin.php?page=wp-bnb-seasons&defaults_created=1' ) );
exit;
}
// Handle form submission.
if ( isset( $_POST['wp_bnb_season_nonce'] )
&& wp_verify_nonce( sanitize_key( $_POST['wp_bnb_season_nonce'] ), 'save_season' )
) {
self::save_season();
}
}
/**
* Save a season from form data.
*
* @return void
*/
private static function save_season(): void {
$data = array(
'id' => isset( $_POST['season_id'] ) && '' !== $_POST['season_id']
? sanitize_text_field( wp_unslash( $_POST['season_id'] ) )
: wp_generate_uuid4(),
'name' => isset( $_POST['season_name'] )
? sanitize_text_field( wp_unslash( $_POST['season_name'] ) )
: '',
'start_date' => isset( $_POST['season_start_date'] )
? sanitize_text_field( wp_unslash( $_POST['season_start_date'] ) )
: '',
'end_date' => isset( $_POST['season_end_date'] )
? sanitize_text_field( wp_unslash( $_POST['season_end_date'] ) )
: '',
'modifier' => isset( $_POST['season_modifier'] )
? floatval( $_POST['season_modifier'] )
: 1.0,
'priority' => isset( $_POST['season_priority'] )
? absint( $_POST['season_priority'] )
: 0,
'active' => isset( $_POST['season_active'] ),
);
$season = new Season( $data );
Season::save( $season );
$is_edit = isset( $_POST['season_id'] ) && '' !== $_POST['season_id'];
$message = $is_edit ? 'updated' : 'created';
wp_safe_redirect( admin_url( 'admin.php?page=wp-bnb-seasons&' . $message . '=1' ) );
exit;
}
/**
* Render the admin page.
*
* @return void
*/
public static function render_page(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display only.
$action = isset( $_GET['action'] ) ? sanitize_key( $_GET['action'] ) : 'list';
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display only.
$season_id = isset( $_GET['season_id'] ) ? sanitize_text_field( wp_unslash( $_GET['season_id'] ) ) : '';
?>
<div class="wrap">
<h1 class="wp-heading-inline"><?php esc_html_e( 'Seasonal Pricing', 'wp-bnb' ); ?></h1>
<?php if ( 'list' === $action || '' === $action ) : ?>
<a href="<?php echo esc_url( admin_url( 'admin.php?page=wp-bnb-seasons&action=add' ) ); ?>" class="page-title-action">
<?php esc_html_e( 'Add Season', 'wp-bnb' ); ?>
</a>
<?php endif; ?>
<hr class="wp-header-end">
<?php self::render_notices(); ?>
<?php
switch ( $action ) {
case 'add':
self::render_form();
break;
case 'edit':
self::render_form( $season_id );
break;
default:
self::render_list();
break;
}
?>
</div>
<?php
}
/**
* Render admin notices.
*
* @return void
*/
private static function render_notices(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display only.
if ( isset( $_GET['deleted'] ) ) {
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Season deleted successfully.', 'wp-bnb' ) . '</p></div>';
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display only.
if ( isset( $_GET['created'] ) ) {
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Season created successfully.', 'wp-bnb' ) . '</p></div>';
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display only.
if ( isset( $_GET['updated'] ) ) {
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Season updated successfully.', 'wp-bnb' ) . '</p></div>';
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display only.
if ( isset( $_GET['defaults_created'] ) ) {
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Default seasons created successfully.', 'wp-bnb' ) . '</p></div>';
}
}
/**
* Render the seasons list.
*
* @return void
*/
private static function render_list(): void {
$seasons = Season::all();
?>
<div class="bnb-seasons-description">
<p><?php esc_html_e( 'Seasonal pricing allows you to automatically adjust room rates based on the time of year. Define seasons with date ranges and price modifiers.', 'wp-bnb' ); ?></p>
<p><?php esc_html_e( 'Seasons with higher priority take precedence when dates overlap (e.g., Winter Holidays overrides Low Season).', 'wp-bnb' ); ?></p>
</div>
<?php if ( empty( $seasons ) ) : ?>
<div class="bnb-no-seasons">
<p><?php esc_html_e( 'No seasons have been configured yet.', 'wp-bnb' ); ?></p>
<p>
<a href="<?php echo esc_url( admin_url( 'admin.php?page=wp-bnb-seasons&action=add' ) ); ?>" class="button button-primary">
<?php esc_html_e( 'Add Your First Season', 'wp-bnb' ); ?>
</a>
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin.php?page=wp-bnb-seasons&action=create_defaults' ), 'create_default_seasons' ) ); ?>" class="button">
<?php esc_html_e( 'Create Default Seasons', 'wp-bnb' ); ?>
</a>
</p>
</div>
<?php else : ?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th scope="col" class="column-name"><?php esc_html_e( 'Season Name', 'wp-bnb' ); ?></th>
<th scope="col" class="column-dates"><?php esc_html_e( 'Period', 'wp-bnb' ); ?></th>
<th scope="col" class="column-modifier"><?php esc_html_e( 'Price Modifier', 'wp-bnb' ); ?></th>
<th scope="col" class="column-priority"><?php esc_html_e( 'Priority', 'wp-bnb' ); ?></th>
<th scope="col" class="column-status"><?php esc_html_e( 'Status', 'wp-bnb' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $seasons as $season ) : ?>
<tr>
<td class="column-name">
<strong>
<a href="<?php echo esc_url( admin_url( 'admin.php?page=wp-bnb-seasons&action=edit&season_id=' . $season->id ) ); ?>">
<?php echo esc_html( $season->name ); ?>
</a>
</strong>
<div class="row-actions">
<span class="edit">
<a href="<?php echo esc_url( admin_url( 'admin.php?page=wp-bnb-seasons&action=edit&season_id=' . $season->id ) ); ?>">
<?php esc_html_e( 'Edit', 'wp-bnb' ); ?>
</a> |
</span>
<span class="delete">
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin.php?page=wp-bnb-seasons&action=delete&season_id=' . $season->id ), 'delete_season' ) ); ?>"
class="submitdelete"
onclick="return confirm('<?php esc_attr_e( 'Are you sure you want to delete this season?', 'wp-bnb' ); ?>');">
<?php esc_html_e( 'Delete', 'wp-bnb' ); ?>
</a>
</span>
</div>
</td>
<td class="column-dates">
<?php echo esc_html( self::format_date_range( $season->start_date, $season->end_date ) ); ?>
</td>
<td class="column-modifier">
<?php echo esc_html( $season->getModifierLabel() ); ?>
<?php if ( $season->modifier !== 1.0 ) : ?>
<span class="bnb-modifier-visual" style="color: <?php echo $season->modifier > 1 ? '#d63638' : '#00a32a'; ?>;">
(<?php echo esc_html( number_format( $season->modifier, 2 ) ); ?>x)
</span>
<?php endif; ?>
</td>
<td class="column-priority">
<?php echo esc_html( $season->priority ); ?>
</td>
<td class="column-status">
<?php if ( $season->active ) : ?>
<span class="bnb-status-active"><?php esc_html_e( 'Active', 'wp-bnb' ); ?></span>
<?php else : ?>
<span class="bnb-status-inactive"><?php esc_html_e( 'Inactive', 'wp-bnb' ); ?></span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php
}
/**
* Render the season form.
*
* @param string $season_id Season ID for editing, empty for new.
* @return void
*/
private static function render_form( string $season_id = '' ): void {
$season = $season_id ? Season::find( $season_id ) : null;
$is_edit = null !== $season;
if ( $season_id && ! $season ) {
echo '<div class="notice notice-error"><p>' . esc_html__( 'Season not found.', 'wp-bnb' ) . '</p></div>';
return;
}
?>
<form method="post" action="" class="bnb-season-form">
<?php wp_nonce_field( 'save_season', 'wp_bnb_season_nonce' ); ?>
<input type="hidden" name="season_id" value="<?php echo esc_attr( $season ? $season->id : '' ); ?>">
<table class="form-table" role="presentation">
<tr>
<th scope="row">
<label for="season_name"><?php esc_html_e( 'Season Name', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="text" name="season_name" id="season_name"
value="<?php echo esc_attr( $season ? $season->name : '' ); ?>"
class="regular-text" required>
<p class="description"><?php esc_html_e( 'A descriptive name for this season (e.g., "High Season", "Winter Holidays").', 'wp-bnb' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="season_start_date"><?php esc_html_e( 'Start Date', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="text" name="season_start_date" id="season_start_date"
value="<?php echo esc_attr( $season ? $season->start_date : '' ); ?>"
class="small-text" placeholder="MM-DD" pattern="\d{2}-\d{2}" required>
<p class="description"><?php esc_html_e( 'Format: MM-DD (e.g., 06-15 for June 15th). Seasons repeat annually.', 'wp-bnb' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="season_end_date"><?php esc_html_e( 'End Date', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="text" name="season_end_date" id="season_end_date"
value="<?php echo esc_attr( $season ? $season->end_date : '' ); ?>"
class="small-text" placeholder="MM-DD" pattern="\d{2}-\d{2}" required>
<p class="description"><?php esc_html_e( 'Format: MM-DD. Seasons can span year boundaries (e.g., 12-20 to 01-06).', 'wp-bnb' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="season_modifier"><?php esc_html_e( 'Price Modifier', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="number" name="season_modifier" id="season_modifier"
value="<?php echo esc_attr( $season ? $season->modifier : '1.00' ); ?>"
class="small-text" min="0.1" max="3" step="0.01" required>
<p class="description">
<?php esc_html_e( 'Multiplier for base prices. Examples:', 'wp-bnb' ); ?><br>
<?php esc_html_e( '1.00 = Normal price | 1.25 = 25% increase | 0.85 = 15% discount', 'wp-bnb' ); ?>
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="season_priority"><?php esc_html_e( 'Priority', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="number" name="season_priority" id="season_priority"
value="<?php echo esc_attr( $season ? $season->priority : '10' ); ?>"
class="small-text" min="0" max="100">
<p class="description"><?php esc_html_e( 'Higher priority seasons take precedence when dates overlap.', 'wp-bnb' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Status', 'wp-bnb' ); ?></th>
<td>
<label>
<input type="checkbox" name="season_active" value="1"
<?php checked( $season ? $season->active : true ); ?>>
<?php esc_html_e( 'Active', 'wp-bnb' ); ?>
</label>
<p class="description"><?php esc_html_e( 'Inactive seasons are ignored in price calculations.', 'wp-bnb' ); ?></p>
</td>
</tr>
</table>
<p class="submit">
<input type="submit" name="submit" class="button button-primary"
value="<?php echo $is_edit ? esc_attr__( 'Update Season', 'wp-bnb' ) : esc_attr__( 'Add Season', 'wp-bnb' ); ?>">
<a href="<?php echo esc_url( admin_url( 'admin.php?page=wp-bnb-seasons' ) ); ?>" class="button">
<?php esc_html_e( 'Cancel', 'wp-bnb' ); ?>
</a>
</p>
</form>
<?php
}
/**
* Format a date range for display.
*
* @param string $start Start date (MM-DD).
* @param string $end End date (MM-DD).
* @return string
*/
private static function format_date_range( string $start, string $end ): string {
$months = array(
'01' => __( 'Jan', 'wp-bnb' ),
'02' => __( 'Feb', 'wp-bnb' ),
'03' => __( 'Mar', 'wp-bnb' ),
'04' => __( 'Apr', 'wp-bnb' ),
'05' => __( 'May', 'wp-bnb' ),
'06' => __( 'Jun', 'wp-bnb' ),
'07' => __( 'Jul', 'wp-bnb' ),
'08' => __( 'Aug', 'wp-bnb' ),
'09' => __( 'Sep', 'wp-bnb' ),
'10' => __( 'Oct', 'wp-bnb' ),
'11' => __( 'Nov', 'wp-bnb' ),
'12' => __( 'Dec', 'wp-bnb' ),
);
$format_date = function ( string $date ) use ( $months ): string {
$parts = explode( '-', $date );
if ( count( $parts ) !== 2 ) {
return $date;
}
$month = $months[ $parts[0] ] ?? $parts[0];
$day = ltrim( $parts[1], '0' );
return $month . ' ' . $day;
};
return $format_date( $start ) . ' - ' . $format_date( $end );
}
}

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

@@ -0,0 +1,444 @@
<?php
/**
* Availability checker.
*
* Handles availability checks and calendar data for rooms and bookings.
*
* @package Magdev\WpBnb\Booking
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Booking;
use Magdev\WpBnb\PostTypes\Booking;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\Pricing\Calculator;
/**
* Availability class.
*/
final class Availability {
/**
* Check if a room is available for a date range.
*
* @param int $room_id Room post ID.
* @param string $check_in Check-in date (Y-m-d).
* @param string $check_out Check-out date (Y-m-d).
* @param int|null $exclude_booking Booking ID to exclude (for editing).
* @return bool True if available, false if conflicts exist.
*/
public static function is_available( int $room_id, string $check_in, string $check_out, ?int $exclude_booking = null ): bool {
return ! Booking::has_conflict( $room_id, $check_in, $check_out, $exclude_booking );
}
/**
* Get all booked dates for a room in a specific month.
*
* @param int $room_id Room post ID.
* @param int $year Year (e.g., 2024).
* @param int $month Month (1-12).
* @return array<string, array> Array of dates (Y-m-d) with booking info.
*/
public static function get_booked_dates( int $room_id, int $year, int $month ): array {
$month_start = sprintf( '%04d-%02d-01', $year, $month );
$month_end = gmdate( 'Y-m-t', strtotime( $month_start ) );
$bookings = get_posts(
array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'meta_query' => array(
'relation' => 'AND',
array(
'key' => '_bnb_booking_room_id',
'value' => $room_id,
),
array(
'key' => '_bnb_booking_status',
'value' => 'cancelled',
'compare' => '!=',
),
// Booking overlaps with month.
array(
'key' => '_bnb_booking_check_in',
'value' => $month_end,
'compare' => '<=',
'type' => 'DATE',
),
array(
'key' => '_bnb_booking_check_out',
'value' => $month_start,
'compare' => '>=',
'type' => 'DATE',
),
),
)
);
$booked_dates = array();
foreach ( $bookings as $booking ) {
$check_in = get_post_meta( $booking->ID, '_bnb_booking_check_in', true );
$check_out = get_post_meta( $booking->ID, '_bnb_booking_check_out', true );
$status = get_post_meta( $booking->ID, '_bnb_booking_status', true );
$guest = get_post_meta( $booking->ID, '_bnb_booking_guest_name', true );
// Iterate through each night of the booking.
$current = new \DateTimeImmutable( $check_in );
$end = new \DateTimeImmutable( $check_out );
while ( $current < $end ) {
$date_str = $current->format( 'Y-m-d' );
// Only include dates within the requested month.
if ( $current->format( 'Y-m' ) === sprintf( '%04d-%02d', $year, $month ) ) {
$booked_dates[ $date_str ] = array(
'booking_id' => $booking->ID,
'reference' => $booking->post_title,
'guest' => $guest,
'status' => $status,
'check_in' => $check_in,
'check_out' => $check_out,
'is_start' => $date_str === $check_in,
'is_end' => $current->modify( '+1 day' )->format( 'Y-m-d' ) === $check_out,
);
}
$current = $current->modify( '+1 day' );
}
}
return $booked_dates;
}
/**
* Get calendar data for a room for a specific month.
*
* @param int $room_id Room post ID.
* @param int $year Year.
* @param int $month Month (1-12).
* @return array Calendar data including days and bookings.
*/
public static function get_calendar_data( int $room_id, int $year, int $month ): array {
$month_start = new \DateTimeImmutable( sprintf( '%04d-%02d-01', $year, $month ) );
$days_in_month = (int) $month_start->format( 't' );
$first_day_of_week = (int) $month_start->format( 'w' ); // 0 = Sunday.
$booked_dates = self::get_booked_dates( $room_id, $year, $month );
$today = gmdate( 'Y-m-d' );
$days = array();
for ( $day = 1; $day <= $days_in_month; $day++ ) {
$date_str = sprintf( '%04d-%02d-%02d', $year, $month, $day );
$is_booked = isset( $booked_dates[ $date_str ] );
$days[ $day ] = array(
'date' => $date_str,
'day' => $day,
'is_booked' => $is_booked,
'is_past' => $date_str < $today,
'is_today' => $date_str === $today,
'booking' => $is_booked ? $booked_dates[ $date_str ] : null,
);
}
return array(
'room_id' => $room_id,
'year' => $year,
'month' => $month,
'month_name' => $month_start->format( 'F' ),
'days_in_month' => $days_in_month,
'first_day_of_week' => $first_day_of_week,
'days' => $days,
'prev_month' => array(
'year' => $month === 1 ? $year - 1 : $year,
'month' => $month === 1 ? 12 : $month - 1,
),
'next_month' => array(
'year' => $month === 12 ? $year + 1 : $year,
'month' => $month === 12 ? 1 : $month + 1,
),
);
}
/**
* Get availability summary for a building (all rooms).
*
* @param int $building_id Building post ID.
* @param int $year Year.
* @param int $month Month (1-12).
* @return array Availability data for all rooms in the building.
*/
public static function get_building_availability( int $building_id, int $year, int $month ): array {
$rooms = Room::get_rooms_for_building( $building_id );
$data = array();
foreach ( $rooms as $room ) {
$room_number = get_post_meta( $room->ID, '_bnb_room_room_number', true );
$data[ $room->ID ] = array(
'room_id' => $room->ID,
'room_name' => $room->post_title,
'room_number' => $room_number,
'calendar' => self::get_calendar_data( $room->ID, $year, $month ),
);
}
return $data;
}
/**
* Get conflicts for a proposed booking.
*
* @param int $room_id Room post ID.
* @param string $check_in Check-in date (Y-m-d).
* @param string $check_out Check-out date (Y-m-d).
* @param int|null $exclude_booking Booking ID to exclude.
* @return array<\WP_Post> Array of conflicting booking posts.
*/
public static function get_conflicts( int $room_id, string $check_in, string $check_out, ?int $exclude_booking = null ): array {
$args = array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'meta_query' => array(
'relation' => 'AND',
array(
'key' => '_bnb_booking_room_id',
'value' => $room_id,
),
array(
'key' => '_bnb_booking_status',
'value' => 'cancelled',
'compare' => '!=',
),
array(
'key' => '_bnb_booking_check_in',
'value' => $check_out,
'compare' => '<',
'type' => 'DATE',
),
array(
'key' => '_bnb_booking_check_out',
'value' => $check_in,
'compare' => '>',
'type' => 'DATE',
),
),
);
if ( $exclude_booking ) {
$args['post__not_in'] = array( $exclude_booking );
}
return get_posts( $args );
}
/**
* Get availability check result with pricing.
*
* @param int $room_id Room post ID.
* @param string $check_in Check-in date (Y-m-d).
* @param string $check_out Check-out date (Y-m-d).
* @param int|null $exclude_booking Booking ID to exclude.
* @return array Result with availability, pricing, and conflicts.
*/
public static function check_availability_with_price( int $room_id, string $check_in, string $check_out, ?int $exclude_booking = null ): array {
$conflicts = self::get_conflicts( $room_id, $check_in, $check_out, $exclude_booking );
$available = empty( $conflicts );
$result = array(
'available' => $available,
'room_id' => $room_id,
'check_in' => $check_in,
'check_out' => $check_out,
'nights' => Booking::calculate_nights( $check_in, $check_out ),
'conflicts' => array(),
);
if ( ! $available ) {
foreach ( $conflicts as $conflict ) {
$result['conflicts'][] = array(
'booking_id' => $conflict->ID,
'reference' => $conflict->post_title,
'check_in' => get_post_meta( $conflict->ID, '_bnb_booking_check_in', true ),
'check_out' => get_post_meta( $conflict->ID, '_bnb_booking_check_out', true ),
);
}
}
// Calculate price if available.
if ( $available ) {
try {
$calculator = new Calculator( $room_id, $check_in, $check_out );
$price = $calculator->calculate();
$breakdown = $calculator->getBreakdown();
$result['price'] = $price;
$result['price_formatted'] = Calculator::formatPrice( $price );
$result['breakdown'] = $breakdown;
} catch ( \Exception $e ) {
$result['price'] = null;
$result['price_formatted'] = null;
$result['price_error'] = $e->getMessage();
}
}
return $result;
}
/**
* Get upcoming bookings for a room.
*
* @param int $room_id Room post ID.
* @param int $limit Maximum number of bookings to return.
* @return array<\WP_Post> Array of upcoming bookings.
*/
public static function get_upcoming_bookings( int $room_id, int $limit = 5 ): array {
$today = gmdate( 'Y-m-d' );
return get_posts(
array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => $limit,
'meta_query' => array(
'relation' => 'AND',
array(
'key' => '_bnb_booking_room_id',
'value' => $room_id,
),
array(
'key' => '_bnb_booking_status',
'value' => array( 'pending', 'confirmed' ),
'compare' => 'IN',
),
array(
'key' => '_bnb_booking_check_in',
'value' => $today,
'compare' => '>=',
'type' => 'DATE',
),
),
'orderby' => 'meta_value',
'meta_key' => '_bnb_booking_check_in',
'order' => 'ASC',
)
);
}
/**
* Get current bookings (guests currently checked in).
*
* @param int|null $room_id Optional room ID to filter by.
* @return array<\WP_Post> Array of current bookings.
*/
public static function get_current_bookings( ?int $room_id = null ): array {
$today = gmdate( 'Y-m-d' );
$meta_query = array(
'relation' => 'AND',
array(
'key' => '_bnb_booking_status',
'value' => 'checked_in',
),
array(
'key' => '_bnb_booking_check_in',
'value' => $today,
'compare' => '<=',
'type' => 'DATE',
),
array(
'key' => '_bnb_booking_check_out',
'value' => $today,
'compare' => '>',
'type' => 'DATE',
),
);
if ( $room_id ) {
$meta_query[] = array(
'key' => '_bnb_booking_room_id',
'value' => $room_id,
);
}
return get_posts(
array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'meta_query' => $meta_query,
'orderby' => 'meta_value',
'meta_key' => '_bnb_booking_check_out',
'order' => 'ASC',
)
);
}
/**
* Get today's check-ins.
*
* @return array<\WP_Post> Array of bookings with check-in today.
*/
public static function get_todays_checkins(): array {
$today = gmdate( 'Y-m-d' );
return get_posts(
array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'meta_query' => array(
'relation' => 'AND',
array(
'key' => '_bnb_booking_status',
'value' => 'confirmed',
),
array(
'key' => '_bnb_booking_check_in',
'value' => $today,
'type' => 'DATE',
),
),
'orderby' => 'meta_value',
'meta_key' => '_bnb_booking_guest_name',
'order' => 'ASC',
)
);
}
/**
* Get today's check-outs.
*
* @return array<\WP_Post> Array of bookings with check-out today.
*/
public static function get_todays_checkouts(): array {
$today = gmdate( 'Y-m-d' );
return get_posts(
array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'meta_query' => array(
'relation' => 'AND',
array(
'key' => '_bnb_booking_status',
'value' => 'checked_in',
),
array(
'key' => '_bnb_booking_check_out',
'value' => $today,
'type' => 'DATE',
),
),
'orderby' => 'meta_value',
'meta_key' => '_bnb_booking_guest_name',
'order' => 'ASC',
)
);
}
}

View File

@@ -0,0 +1,641 @@
<?php
/**
* Email notifier for bookings.
*
* Handles sending email notifications for booking events.
*
* @package Magdev\WpBnb\Booking
*/
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;
/**
* EmailNotifier class.
*/
final class EmailNotifier {
/**
* Initialize the email notifier.
*
* @return void
*/
public static function init(): void {
add_action( 'wp_bnb_booking_status_changed', array( self::class, 'on_status_change' ), 10, 3 );
add_action( 'save_post_' . Booking::POST_TYPE, array( self::class, 'on_booking_created' ), 20, 2 );
}
/**
* Handle status change event.
*
* @param int $booking_id Booking post ID.
* @param string $old_status Previous status.
* @param string $new_status New status.
* @return void
*/
public static function on_status_change( int $booking_id, string $old_status, string $new_status ): void {
switch ( $new_status ) {
case 'confirmed':
self::send_guest_confirmation( $booking_id );
self::send_admin_confirmation( $booking_id );
break;
case 'cancelled':
self::send_cancellation( $booking_id );
break;
}
}
/**
* Handle booking created event.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @return void
*/
public static function on_booking_created( int $post_id, \WP_Post $post ): void {
// Skip if autosave or revision.
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
if ( wp_is_post_revision( $post_id ) ) {
return;
}
// Check if this is a new booking (created in the last 30 seconds).
$created = get_post_time( 'U', true, $post_id );
if ( time() - $created > 30 ) {
return;
}
// Check if we've already sent this notification.
$sent = get_post_meta( $post_id, '_bnb_booking_new_email_sent', true );
if ( $sent ) {
return;
}
// Mark as sent before sending to prevent duplicates.
update_post_meta( $post_id, '_bnb_booking_new_email_sent', '1' );
// Send admin notification for new booking.
self::send_admin_new_booking( $post_id );
}
/**
* Send new booking notification to admin.
*
* @param int $booking_id Booking post ID.
* @return bool Whether email was sent.
*/
public static function send_admin_new_booking( int $booking_id ): bool {
$booking = get_post( $booking_id );
if ( ! $booking ) {
return false;
}
$data = self::get_booking_data( $booking_id );
$to = get_option( 'admin_email' );
$subject = sprintf(
/* translators: 1: Site name, 2: Booking reference */
__( '[%1$s] New Booking: %2$s', 'wp-bnb' ),
get_bloginfo( 'name' ),
$data['booking_reference']
);
$message = self::get_email_template( 'admin-new-booking', $data );
return self::send_email( $to, $subject, $message );
}
/**
* Send confirmation email to guest.
*
* @param int $booking_id Booking post ID.
* @return bool Whether email was sent.
*/
public static function send_guest_confirmation( int $booking_id ): bool {
$data = self::get_booking_data( $booking_id );
if ( empty( $data['guest_email'] ) ) {
return false;
}
$subject = sprintf(
/* translators: 1: Site name, 2: Booking reference */
__( '[%1$s] Booking Confirmed: %2$s', 'wp-bnb' ),
get_bloginfo( 'name' ),
$data['booking_reference']
);
$message = self::get_email_template( 'booking-confirmed', $data );
return self::send_email( $data['guest_email'], $subject, $message );
}
/**
* Send confirmation notification to admin.
*
* @param int $booking_id Booking post ID.
* @return bool Whether email was sent.
*/
public static function send_admin_confirmation( int $booking_id ): bool {
$data = self::get_booking_data( $booking_id );
$to = get_option( 'admin_email' );
$subject = sprintf(
/* translators: 1: Site name, 2: Booking reference */
__( '[%1$s] Booking Confirmed: %2$s', 'wp-bnb' ),
get_bloginfo( 'name' ),
$data['booking_reference']
);
$message = self::get_email_template( 'admin-booking-confirmed', $data );
return self::send_email( $to, $subject, $message );
}
/**
* Send cancellation email.
*
* @param int $booking_id Booking post ID.
* @return bool Whether emails were sent.
*/
public static function send_cancellation( int $booking_id ): bool {
$data = self::get_booking_data( $booking_id );
$result = true;
// Send to admin.
$admin_subject = sprintf(
/* translators: 1: Site name, 2: Booking reference */
__( '[%1$s] Booking Cancelled: %2$s', 'wp-bnb' ),
get_bloginfo( 'name' ),
$data['booking_reference']
);
$admin_message = self::get_email_template( 'admin-booking-cancelled', $data );
$result = self::send_email( get_option( 'admin_email' ), $admin_subject, $admin_message ) && $result;
// Send to guest if email exists.
if ( ! empty( $data['guest_email'] ) ) {
$guest_subject = sprintf(
/* translators: 1: Site name, 2: Booking reference */
__( '[%1$s] Booking Cancelled: %2$s', 'wp-bnb' ),
get_bloginfo( 'name' ),
$data['booking_reference']
);
$guest_message = self::get_email_template( 'booking-cancelled', $data );
$result = self::send_email( $data['guest_email'], $guest_subject, $guest_message ) && $result;
}
return $result;
}
/**
* Get booking data for email templates.
*
* @param int $booking_id Booking post ID.
* @return array Booking data.
*/
private static function get_booking_data( int $booking_id ): array {
$booking = get_post( $booking_id );
$room = Booking::get_room( $booking_id );
$building = Booking::get_building( $booking_id );
$check_in = get_post_meta( $booking_id, '_bnb_booking_check_in', true );
$check_out = get_post_meta( $booking_id, '_bnb_booking_check_out', true );
$status = get_post_meta( $booking_id, '_bnb_booking_status', true );
$price = get_post_meta( $booking_id, '_bnb_booking_calculated_price', true );
$adults = get_post_meta( $booking_id, '_bnb_booking_adults', true );
$children = get_post_meta( $booking_id, '_bnb_booking_children', true );
$nights = 0;
if ( $check_in && $check_out ) {
$nights = Booking::calculate_nights( $check_in, $check_out );
}
$statuses = Booking::get_booking_statuses();
// 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' => $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 : '',
'room_id' => $room ? $room->ID : 0,
'building_name' => $building ? $building->post_title : '',
'building_id' => $building ? $building->ID : 0,
'check_in_date' => $check_in ? wp_date( get_option( 'date_format' ), strtotime( $check_in ) ) : '',
'check_out_date' => $check_out ? wp_date( get_option( 'date_format' ), strtotime( $check_out ) ) : '',
'check_in_raw' => $check_in,
'check_out_raw' => $check_out,
'nights' => $nights,
'total_price' => $price ? Calculator::formatPrice( (float) $price ) : '',
'status' => $statuses[ $status ] ?? $status,
'status_raw' => $status,
'site_name' => get_bloginfo( 'name' ),
'site_url' => home_url(),
'admin_email' => get_option( 'admin_email' ),
'booking_url' => admin_url( 'post.php?post=' . $booking_id . '&action=edit' ),
);
}
/**
* Get 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.
*
* @param string $template_name Template name.
* @param array $data Template data.
* @return string HTML email content.
*/
private static function get_email_template( string $template_name, array $data ): string {
$template = self::get_template_content( $template_name );
// Replace placeholders.
foreach ( $data as $key => $value ) {
$template = str_replace( '{' . $key . '}', (string) $value, $template );
}
return $template;
}
/**
* Get template content.
*
* @param string $template_name Template name.
* @return string Template HTML.
*/
private static function get_template_content( string $template_name ): string {
// Built-in templates. Could be extended to load from files.
$templates = array(
'admin-new-booking' => self::template_admin_new_booking(),
'booking-confirmed' => self::template_booking_confirmed(),
'admin-booking-confirmed' => self::template_admin_booking_confirmed(),
'booking-cancelled' => self::template_booking_cancelled(),
'admin-booking-cancelled' => self::template_admin_booking_cancelled(),
);
return $templates[ $template_name ] ?? '';
}
/**
* Send HTML email.
*
* @param string $to Recipient email.
* @param string $subject Email subject.
* @param string $message HTML message.
* @return bool Whether email was sent.
*/
private static function send_email( string $to, string $subject, string $message ): bool {
$headers = array(
'Content-Type: text/html; charset=UTF-8',
'From: ' . get_bloginfo( 'name' ) . ' <' . get_option( 'admin_email' ) . '>',
);
/**
* Filter email recipients.
*
* @param string $to Recipient email.
* @param string $subject Email subject.
* @param string $message Email message.
*/
$to = apply_filters( 'wp_bnb_booking_email_recipients', $to, $subject, $message );
/**
* Filter email subject.
*
* @param string $subject Email subject.
*/
$subject = apply_filters( 'wp_bnb_booking_email_subject', $subject );
/**
* Filter email content.
*
* @param string $message Email message.
*/
$message = apply_filters( 'wp_bnb_booking_email_content', $message );
return wp_mail( $to, $subject, $message, $headers );
}
/**
* Get base email styles.
*
* @return string CSS styles.
*/
private static function get_email_styles(): string {
return '
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; font-size: 14px; line-height: 1.6; color: #333; }
.email-container { max-width: 600px; margin: 0 auto; padding: 20px; }
.email-header { background: #135e96; color: #fff; padding: 20px; text-align: center; }
.email-header h1 { margin: 0; font-size: 24px; }
.email-body { background: #fff; padding: 30px; border: 1px solid #ddd; }
.email-footer { padding: 20px; text-align: center; font-size: 12px; color: #666; }
.booking-details { background: #f9f9f9; padding: 15px; margin: 20px 0; border-left: 4px solid #135e96; }
.booking-details h3 { margin-top: 0; color: #135e96; }
.detail-row { margin: 8px 0; }
.detail-label { font-weight: 600; display: inline-block; min-width: 120px; }
.btn { display: inline-block; padding: 10px 20px; background: #135e96; color: #fff; text-decoration: none; border-radius: 4px; margin-top: 15px; }
.status-badge { display: inline-block; padding: 4px 10px; border-radius: 3px; font-size: 12px; font-weight: 600; text-transform: uppercase; }
.status-pending { background: #dba617; color: #fff; }
.status-confirmed { background: #00a32a; color: #fff; }
.status-cancelled { background: #d63638; color: #fff; }
';
}
/**
* Template: Admin new booking notification.
*
* @return string Template HTML.
*/
private static function template_admin_new_booking(): string {
$styles = self::get_email_styles();
return <<<HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>{$styles}</style>
</head>
<body>
<div class="email-container">
<div class="email-header">
<h1>New Booking Received</h1>
</div>
<div class="email-body">
<p>A new booking has been created and is awaiting confirmation.</p>
<div class="booking-details">
<h3>Booking Details</h3>
<div class="detail-row"><span class="detail-label">Reference:</span> {booking_reference}</div>
<div class="detail-row"><span class="detail-label">Status:</span> <span class="status-badge status-{status_raw}">{status}</span></div>
<div class="detail-row"><span class="detail-label">Room:</span> {room_name}</div>
<div class="detail-row"><span class="detail-label">Building:</span> {building_name}</div>
<div class="detail-row"><span class="detail-label">Check-in:</span> {check_in_date}</div>
<div class="detail-row"><span class="detail-label">Check-out:</span> {check_out_date}</div>
<div class="detail-row"><span class="detail-label">Nights:</span> {nights}</div>
<div class="detail-row"><span class="detail-label">Total:</span> {total_price}</div>
</div>
<div class="booking-details">
<h3>Guest Information</h3>
<div class="detail-row"><span class="detail-label">Name:</span> {guest_name}</div>
<div class="detail-row"><span class="detail-label">Email:</span> {guest_email}</div>
<div class="detail-row"><span class="detail-label">Phone:</span> {guest_phone}</div>
<div class="detail-row"><span class="detail-label">Adults:</span> {adults}</div>
<div class="detail-row"><span class="detail-label">Children:</span> {children}</div>
</div>
<a href="{booking_url}" class="btn">View Booking</a>
</div>
<div class="email-footer">
<p>This email was sent from {site_name}</p>
</div>
</div>
</body>
</html>
HTML;
}
/**
* Template: Guest booking confirmed.
*
* @return string Template HTML.
*/
private static function template_booking_confirmed(): string {
$styles = self::get_email_styles();
return <<<HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>{$styles}</style>
</head>
<body>
<div class="email-container">
<div class="email-header">
<h1>Booking Confirmed</h1>
</div>
<div class="email-body">
<p>Dear {guest_name},</p>
<p>Great news! Your booking has been confirmed. We look forward to welcoming you.</p>
<div class="booking-details">
<h3>Your Booking Details</h3>
<div class="detail-row"><span class="detail-label">Confirmation:</span> {booking_reference}</div>
<div class="detail-row"><span class="detail-label">Room:</span> {room_name}</div>
<div class="detail-row"><span class="detail-label">Location:</span> {building_name}</div>
<div class="detail-row"><span class="detail-label">Check-in:</span> {check_in_date}</div>
<div class="detail-row"><span class="detail-label">Check-out:</span> {check_out_date}</div>
<div class="detail-row"><span class="detail-label">Duration:</span> {nights} nights</div>
<div class="detail-row"><span class="detail-label">Guests:</span> {adults} adults, {children} children</div>
<div class="detail-row"><span class="detail-label">Total:</span> <strong>{total_price}</strong></div>
</div>
<p>If you have any questions or need to make changes to your reservation, please contact us at {admin_email}.</p>
<p>Thank you for choosing us!</p>
</div>
<div class="email-footer">
<p>{site_name}<br>{site_url}</p>
</div>
</div>
</body>
</html>
HTML;
}
/**
* Template: Admin booking confirmed.
*
* @return string Template HTML.
*/
private static function template_admin_booking_confirmed(): string {
$styles = self::get_email_styles();
return <<<HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>{$styles}</style>
</head>
<body>
<div class="email-container">
<div class="email-header">
<h1>Booking Confirmed</h1>
</div>
<div class="email-body">
<p>Booking {booking_reference} has been confirmed.</p>
<div class="booking-details">
<h3>Booking Summary</h3>
<div class="detail-row"><span class="detail-label">Guest:</span> {guest_name}</div>
<div class="detail-row"><span class="detail-label">Room:</span> {room_name}</div>
<div class="detail-row"><span class="detail-label">Dates:</span> {check_in_date} - {check_out_date}</div>
<div class="detail-row"><span class="detail-label">Total:</span> {total_price}</div>
</div>
<a href="{booking_url}" class="btn">View Booking</a>
</div>
<div class="email-footer">
<p>This email was sent from {site_name}</p>
</div>
</div>
</body>
</html>
HTML;
}
/**
* Template: Guest booking cancelled.
*
* @return string Template HTML.
*/
private static function template_booking_cancelled(): string {
$styles = self::get_email_styles();
return <<<HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>{$styles}</style>
</head>
<body>
<div class="email-container">
<div class="email-header" style="background: #d63638;">
<h1>Booking Cancelled</h1>
</div>
<div class="email-body">
<p>Dear {guest_name},</p>
<p>We're writing to confirm that your booking has been cancelled.</p>
<div class="booking-details">
<h3>Cancelled Booking</h3>
<div class="detail-row"><span class="detail-label">Reference:</span> {booking_reference}</div>
<div class="detail-row"><span class="detail-label">Room:</span> {room_name}</div>
<div class="detail-row"><span class="detail-label">Dates:</span> {check_in_date} - {check_out_date}</div>
</div>
<p>If you have any questions or would like to make a new reservation, please contact us at {admin_email}.</p>
<p>We hope to welcome you in the future.</p>
</div>
<div class="email-footer">
<p>{site_name}<br>{site_url}</p>
</div>
</div>
</body>
</html>
HTML;
}
/**
* Template: Admin booking cancelled.
*
* @return string Template HTML.
*/
private static function template_admin_booking_cancelled(): string {
$styles = self::get_email_styles();
return <<<HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>{$styles}</style>
</head>
<body>
<div class="email-container">
<div class="email-header" style="background: #d63638;">
<h1>Booking Cancelled</h1>
</div>
<div class="email-body">
<p>Booking {booking_reference} has been cancelled.</p>
<div class="booking-details">
<h3>Cancelled Booking</h3>
<div class="detail-row"><span class="detail-label">Guest:</span> {guest_name}</div>
<div class="detail-row"><span class="detail-label">Email:</span> {guest_email}</div>
<div class="detail-row"><span class="detail-label">Room:</span> {room_name}</div>
<div class="detail-row"><span class="detail-label">Dates:</span> {check_in_date} - {check_out_date}</div>
</div>
<a href="{booking_url}" class="btn">View Booking</a>
</div>
<div class="email-footer">
<p>This email was sent from {site_name}</p>
</div>
</div>
</body>
</html>
HTML;
}
}

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

@@ -126,13 +126,60 @@ final class Manager {
/** /**
* Check if license is valid. * Check if license is valid.
* *
* Localhost environments bypass the license check to allow
* full functionality during development.
*
* @return bool * @return bool
*/ */
public static function is_license_valid(): 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' ); $status = get_option( self::OPTION_LICENSE_STATUS, 'unchecked' );
return 'valid' === $status; 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. * 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

1648
src/PostTypes/Booking.php Normal file

File diff suppressed because it is too large Load Diff

562
src/PostTypes/Building.php Normal file
View File

@@ -0,0 +1,562 @@
<?php
/**
* Building post type.
*
* Custom post type for BnB buildings/properties.
*
* @package Magdev\WpBnb\PostTypes
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\PostTypes;
/**
* Building post type class.
*/
final class Building {
/**
* Post type slug.
*
* @var string
*/
public const POST_TYPE = 'bnb_building';
/**
* Meta key prefix.
*
* @var string
*/
private const META_PREFIX = '_bnb_building_';
/**
* 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_filter( 'enter_title_here', array( self::class, 'change_title_placeholder' ), 10, 2 );
}
/**
* Register the post type.
*
* @return void
*/
public static function register(): void {
$labels = array(
'name' => _x( 'Buildings', 'post type general name', 'wp-bnb' ),
'singular_name' => _x( 'Building', 'post type singular name', 'wp-bnb' ),
'menu_name' => _x( 'Buildings', 'admin menu', 'wp-bnb' ),
'name_admin_bar' => _x( 'Building', 'add new on admin bar', 'wp-bnb' ),
'add_new' => _x( 'Add New', 'building', 'wp-bnb' ),
'add_new_item' => __( 'Add New Building', 'wp-bnb' ),
'new_item' => __( 'New Building', 'wp-bnb' ),
'edit_item' => __( 'Edit Building', 'wp-bnb' ),
'view_item' => __( 'View Building', 'wp-bnb' ),
'all_items' => __( 'Buildings', 'wp-bnb' ),
'search_items' => __( 'Search Buildings', 'wp-bnb' ),
'parent_item_colon' => __( 'Parent Buildings:', 'wp-bnb' ),
'not_found' => __( 'No buildings found.', 'wp-bnb' ),
'not_found_in_trash' => __( 'No buildings found in Trash.', 'wp-bnb' ),
'featured_image' => __( 'Building Image', 'wp-bnb' ),
'set_featured_image' => __( 'Set building image', 'wp-bnb' ),
'remove_featured_image' => __( 'Remove building image', 'wp-bnb' ),
'use_featured_image' => __( 'Use as building image', 'wp-bnb' ),
'archives' => __( 'Building archives', 'wp-bnb' ),
'insert_into_item' => __( 'Insert into building', 'wp-bnb' ),
'uploaded_to_this_item' => __( 'Uploaded to this building', 'wp-bnb' ),
'filter_items_list' => __( 'Filter buildings list', 'wp-bnb' ),
'items_list_navigation' => __( 'Buildings list navigation', 'wp-bnb' ),
'items_list' => __( 'Buildings list', 'wp-bnb' ),
);
$args = array(
'labels' => $labels,
'public' => true,
'publicly_queryable' => true,
'show_ui' => true,
'show_in_menu' => 'wp-bnb',
'query_var' => true,
'rewrite' => array(
'slug' => 'building',
'with_front' => false,
),
'capability_type' => 'post',
'has_archive' => true,
'hierarchical' => false,
'menu_position' => null,
'menu_icon' => 'dashicons-building',
'supports' => array(
'title',
'editor',
'thumbnail',
'excerpt',
'revisions',
),
'show_in_rest' => true,
'rest_base' => 'buildings',
'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_building_address',
__( 'Address', 'wp-bnb' ),
array( self::class, 'render_address_meta_box' ),
self::POST_TYPE,
'normal',
'high'
);
add_meta_box(
'bnb_building_contact',
__( 'Contact Information', 'wp-bnb' ),
array( self::class, 'render_contact_meta_box' ),
self::POST_TYPE,
'normal',
'high'
);
add_meta_box(
'bnb_building_details',
__( 'Building Details', 'wp-bnb' ),
array( self::class, 'render_details_meta_box' ),
self::POST_TYPE,
'side',
'default'
);
}
/**
* Render address meta box.
*
* @param \WP_Post $post Current post object.
* @return void
*/
public static function render_address_meta_box( \WP_Post $post ): void {
wp_nonce_field( 'bnb_building_meta', 'bnb_building_meta_nonce' );
$street = get_post_meta( $post->ID, self::META_PREFIX . 'street', true );
$street2 = get_post_meta( $post->ID, self::META_PREFIX . 'street2', true );
$city = get_post_meta( $post->ID, self::META_PREFIX . 'city', true );
$state = get_post_meta( $post->ID, self::META_PREFIX . 'state', true );
$zip = get_post_meta( $post->ID, self::META_PREFIX . 'zip', true );
$country = get_post_meta( $post->ID, self::META_PREFIX . 'country', true );
?>
<table class="form-table">
<tr>
<th scope="row">
<label for="bnb_building_street"><?php esc_html_e( 'Street Address', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="text" id="bnb_building_street" name="bnb_building_street"
value="<?php echo esc_attr( $street ); ?>" class="large-text">
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_building_street2"><?php esc_html_e( 'Street Address 2', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="text" id="bnb_building_street2" name="bnb_building_street2"
value="<?php echo esc_attr( $street2 ); ?>" class="large-text">
<p class="description"><?php esc_html_e( 'Apartment, suite, unit, etc. (optional)', 'wp-bnb' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_building_city"><?php esc_html_e( 'City', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="text" id="bnb_building_city" name="bnb_building_city"
value="<?php echo esc_attr( $city ); ?>" class="regular-text">
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_building_state"><?php esc_html_e( 'State / Canton', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="text" id="bnb_building_state" name="bnb_building_state"
value="<?php echo esc_attr( $state ); ?>" class="regular-text">
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_building_zip"><?php esc_html_e( 'ZIP / Postal Code', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="text" id="bnb_building_zip" name="bnb_building_zip"
value="<?php echo esc_attr( $zip ); ?>" class="regular-text">
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_building_country"><?php esc_html_e( 'Country', 'wp-bnb' ); ?></label>
</th>
<td>
<select id="bnb_building_country" name="bnb_building_country">
<option value=""><?php esc_html_e( '— Select Country —', 'wp-bnb' ); ?></option>
<?php foreach ( self::get_countries() as $code => $name ) : ?>
<option value="<?php echo esc_attr( $code ); ?>" <?php selected( $country, $code ); ?>>
<?php echo esc_html( $name ); ?>
</option>
<?php endforeach; ?>
</select>
</td>
</tr>
</table>
<?php
}
/**
* Render contact meta box.
*
* @param \WP_Post $post Current post object.
* @return void
*/
public static function render_contact_meta_box( \WP_Post $post ): void {
$phone = get_post_meta( $post->ID, self::META_PREFIX . 'phone', true );
$email = get_post_meta( $post->ID, self::META_PREFIX . 'email', true );
$website = get_post_meta( $post->ID, self::META_PREFIX . 'website', true );
?>
<table class="form-table">
<tr>
<th scope="row">
<label for="bnb_building_phone"><?php esc_html_e( 'Phone', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="tel" id="bnb_building_phone" name="bnb_building_phone"
value="<?php echo esc_attr( $phone ); ?>" class="regular-text">
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_building_email"><?php esc_html_e( 'Email', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="email" id="bnb_building_email" name="bnb_building_email"
value="<?php echo esc_attr( $email ); ?>" class="regular-text">
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_building_website"><?php esc_html_e( 'Website', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="url" id="bnb_building_website" name="bnb_building_website"
value="<?php echo esc_attr( $website ); ?>" class="regular-text"
placeholder="https://">
</td>
</tr>
</table>
<?php
}
/**
* Render details meta box.
*
* @param \WP_Post $post Current post object.
* @return void
*/
public static function render_details_meta_box( \WP_Post $post ): void {
$total_rooms = get_post_meta( $post->ID, self::META_PREFIX . 'total_rooms', true );
$floors = get_post_meta( $post->ID, self::META_PREFIX . 'floors', true );
$year_built = get_post_meta( $post->ID, self::META_PREFIX . 'year_built', true );
$check_in = get_post_meta( $post->ID, self::META_PREFIX . 'check_in_time', true );
$check_out = get_post_meta( $post->ID, self::META_PREFIX . 'check_out_time', true );
?>
<p>
<label for="bnb_building_total_rooms"><?php esc_html_e( 'Total Rooms', 'wp-bnb' ); ?></label>
<input type="number" id="bnb_building_total_rooms" name="bnb_building_total_rooms"
value="<?php echo esc_attr( $total_rooms ); ?>" class="small-text" min="1">
</p>
<p>
<label for="bnb_building_floors"><?php esc_html_e( 'Number of Floors', 'wp-bnb' ); ?></label>
<input type="number" id="bnb_building_floors" name="bnb_building_floors"
value="<?php echo esc_attr( $floors ); ?>" class="small-text" min="1">
</p>
<p>
<label for="bnb_building_year_built"><?php esc_html_e( 'Year Built', 'wp-bnb' ); ?></label>
<input type="number" id="bnb_building_year_built" name="bnb_building_year_built"
value="<?php echo esc_attr( $year_built ); ?>" class="small-text"
min="1800" max="<?php echo esc_attr( gmdate( 'Y' ) ); ?>">
</p>
<p>
<label for="bnb_building_check_in_time"><?php esc_html_e( 'Check-in Time', 'wp-bnb' ); ?></label>
<input type="time" id="bnb_building_check_in_time" name="bnb_building_check_in_time"
value="<?php echo esc_attr( $check_in ?: '14:00' ); ?>">
</p>
<p>
<label for="bnb_building_check_out_time"><?php esc_html_e( 'Check-out Time', 'wp-bnb' ); ?></label>
<input type="time" id="bnb_building_check_out_time" name="bnb_building_check_out_time"
value="<?php echo esc_attr( $check_out ?: '11:00' ); ?>">
</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_building_meta_nonce'] ) ||
! wp_verify_nonce( sanitize_key( $_POST['bnb_building_meta_nonce'] ), 'bnb_building_meta' ) ) {
return;
}
// Check autosave.
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
// Check permissions.
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
// Address fields.
$text_fields = array(
'street',
'street2',
'city',
'state',
'zip',
'country',
'phone',
'website',
);
foreach ( $text_fields as $field ) {
$key = 'bnb_building_' . $field;
if ( isset( $_POST[ $key ] ) ) {
update_post_meta(
$post_id,
self::META_PREFIX . $field,
sanitize_text_field( wp_unslash( $_POST[ $key ] ) )
);
}
}
// Email field (special sanitization).
if ( isset( $_POST['bnb_building_email'] ) ) {
update_post_meta(
$post_id,
self::META_PREFIX . 'email',
sanitize_email( wp_unslash( $_POST['bnb_building_email'] ) )
);
}
// Numeric fields.
$numeric_fields = array( 'total_rooms', 'floors', 'year_built' );
foreach ( $numeric_fields as $field ) {
$key = 'bnb_building_' . $field;
if ( isset( $_POST[ $key ] ) ) {
update_post_meta(
$post_id,
self::META_PREFIX . $field,
absint( $_POST[ $key ] )
);
}
}
// Time fields.
$time_fields = array( 'check_in_time', 'check_out_time' );
foreach ( $time_fields as $field ) {
$key = 'bnb_building_' . $field;
if ( isset( $_POST[ $key ] ) ) {
$time = sanitize_text_field( wp_unslash( $_POST[ $key ] ) );
if ( preg_match( '/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/', $time ) ) {
update_post_meta( $post_id, self::META_PREFIX . $field, $time );
}
}
}
}
/**
* 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['city'] = __( 'City', 'wp-bnb' );
$new_columns['country'] = __( 'Country', 'wp-bnb' );
$new_columns['rooms'] = __( 'Rooms', 'wp-bnb' );
}
}
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 'city':
$city = get_post_meta( $post_id, self::META_PREFIX . 'city', true );
echo esc_html( $city ?: '—' );
break;
case 'country':
$country = get_post_meta( $post_id, self::META_PREFIX . 'country', true );
if ( $country ) {
$countries = self::get_countries();
echo esc_html( $countries[ $country ] ?? $country );
} else {
echo '—';
}
break;
case 'rooms':
$rooms = get_posts(
array(
'post_type' => 'bnb_room',
'post_status' => 'publish',
'posts_per_page' => -1,
'meta_query' => array(
array(
'key' => '_bnb_room_building_id',
'value' => $post_id,
),
),
)
);
$count = count( $rooms );
if ( $count > 0 ) {
printf(
'<a href="%s">%s</a>',
esc_url(
admin_url(
'edit.php?post_type=bnb_room&building_id=' . $post_id
)
),
esc_html(
sprintf(
/* translators: %d: Number of rooms */
_n( '%d room', '%d rooms', $count, 'wp-bnb' ),
$count
)
)
);
} else {
echo '—';
}
break;
}
}
/**
* Add sortable columns.
*
* @param array $columns Existing sortable columns.
* @return array
*/
public static function sortable_columns( array $columns ): array {
$columns['city'] = 'city';
$columns['country'] = 'country';
return $columns;
}
/**
* 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 __( 'Enter building name', 'wp-bnb' );
}
return $placeholder;
}
/**
* Get list of countries.
*
* @return array<string, string>
*/
public static function get_countries(): array {
return array(
'CH' => __( 'Switzerland', 'wp-bnb' ),
'DE' => __( 'Germany', 'wp-bnb' ),
'AT' => __( 'Austria', 'wp-bnb' ),
'FR' => __( 'France', 'wp-bnb' ),
'IT' => __( 'Italy', 'wp-bnb' ),
'LI' => __( 'Liechtenstein', 'wp-bnb' ),
'NL' => __( 'Netherlands', 'wp-bnb' ),
'BE' => __( 'Belgium', 'wp-bnb' ),
'LU' => __( 'Luxembourg', 'wp-bnb' ),
'GB' => __( 'United Kingdom', 'wp-bnb' ),
'US' => __( 'United States', 'wp-bnb' ),
'CA' => __( 'Canada', 'wp-bnb' ),
'ES' => __( 'Spain', 'wp-bnb' ),
'PT' => __( 'Portugal', 'wp-bnb' ),
);
}
/**
* Get formatted address.
*
* @param int $post_id Post ID.
* @return string
*/
public static function get_formatted_address( int $post_id ): string {
$street = get_post_meta( $post_id, self::META_PREFIX . 'street', true );
$street2 = get_post_meta( $post_id, self::META_PREFIX . 'street2', true );
$city = get_post_meta( $post_id, self::META_PREFIX . 'city', true );
$state = get_post_meta( $post_id, self::META_PREFIX . 'state', true );
$zip = get_post_meta( $post_id, self::META_PREFIX . 'zip', true );
$country = get_post_meta( $post_id, self::META_PREFIX . 'country', true );
$parts = array();
if ( $street ) {
$parts[] = $street;
}
if ( $street2 ) {
$parts[] = $street2;
}
if ( $zip || $city ) {
$parts[] = trim( $zip . ' ' . $city );
}
if ( $state ) {
$parts[] = $state;
}
if ( $country ) {
$countries = self::get_countries();
$parts[] = $countries[ $country ] ?? $country;
}
return implode( "\n", $parts );
}
}

1192
src/PostTypes/Guest.php Normal file

File diff suppressed because it is too large Load Diff

780
src/PostTypes/Room.php Normal file
View File

@@ -0,0 +1,780 @@
<?php
/**
* Room post type.
*
* Custom post type for BnB rooms.
*
* @package Magdev\WpBnb\PostTypes
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\PostTypes;
use Magdev\WpBnb\Pricing\Calculator;
use Magdev\WpBnb\Pricing\PricingTier;
use Magdev\WpBnb\Taxonomies\Amenity;
use Magdev\WpBnb\Taxonomies\RoomType;
/**
* Room post type class.
*/
final class Room {
/**
* Post type slug.
*
* @var string
*/
public const POST_TYPE = 'bnb_room';
/**
* Meta key prefix.
*
* @var string
*/
private const META_PREFIX = '_bnb_room_';
/**
* 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_building_filter' ) );
add_action( 'pre_get_posts', array( self::class, 'filter_by_building' ) );
add_filter( 'enter_title_here', array( self::class, 'change_title_placeholder' ), 10, 2 );
}
/**
* Register the post type.
*
* @return void
*/
public static function register(): void {
$labels = array(
'name' => _x( 'Rooms', 'post type general name', 'wp-bnb' ),
'singular_name' => _x( 'Room', 'post type singular name', 'wp-bnb' ),
'menu_name' => _x( 'Rooms', 'admin menu', 'wp-bnb' ),
'name_admin_bar' => _x( 'Room', 'add new on admin bar', 'wp-bnb' ),
'add_new' => _x( 'Add New', 'room', 'wp-bnb' ),
'add_new_item' => __( 'Add New Room', 'wp-bnb' ),
'new_item' => __( 'New Room', 'wp-bnb' ),
'edit_item' => __( 'Edit Room', 'wp-bnb' ),
'view_item' => __( 'View Room', 'wp-bnb' ),
'all_items' => __( 'Rooms', 'wp-bnb' ),
'search_items' => __( 'Search Rooms', 'wp-bnb' ),
'parent_item_colon' => __( 'Parent Rooms:', 'wp-bnb' ),
'not_found' => __( 'No rooms found.', 'wp-bnb' ),
'not_found_in_trash' => __( 'No rooms found in Trash.', 'wp-bnb' ),
'featured_image' => __( 'Room Image', 'wp-bnb' ),
'set_featured_image' => __( 'Set room image', 'wp-bnb' ),
'remove_featured_image' => __( 'Remove room image', 'wp-bnb' ),
'use_featured_image' => __( 'Use as room image', 'wp-bnb' ),
'archives' => __( 'Room archives', 'wp-bnb' ),
'insert_into_item' => __( 'Insert into room', 'wp-bnb' ),
'uploaded_to_this_item' => __( 'Uploaded to this room', 'wp-bnb' ),
'filter_items_list' => __( 'Filter rooms list', 'wp-bnb' ),
'items_list_navigation' => __( 'Rooms list navigation', 'wp-bnb' ),
'items_list' => __( 'Rooms list', 'wp-bnb' ),
);
$args = array(
'labels' => $labels,
'public' => true,
'publicly_queryable' => true,
'show_ui' => true,
'show_in_menu' => 'wp-bnb',
'query_var' => true,
'rewrite' => array(
'slug' => 'room',
'with_front' => false,
),
'capability_type' => 'post',
'has_archive' => true,
'hierarchical' => false,
'menu_position' => null,
'menu_icon' => 'dashicons-admin-home',
'supports' => array(
'title',
'editor',
'thumbnail',
'excerpt',
'revisions',
),
'show_in_rest' => true,
'rest_base' => 'rooms',
'rest_controller_class' => 'WP_REST_Posts_Controller',
'taxonomies' => array( RoomType::TAXONOMY, Amenity::TAXONOMY ),
);
register_post_type( self::POST_TYPE, $args );
}
/**
* Add meta boxes.
*
* @return void
*/
public static function add_meta_boxes(): void {
add_meta_box(
'bnb_room_building',
__( 'Building', 'wp-bnb' ),
array( self::class, 'render_building_meta_box' ),
self::POST_TYPE,
'side',
'high'
);
add_meta_box(
'bnb_room_details',
__( 'Room Details', 'wp-bnb' ),
array( self::class, 'render_details_meta_box' ),
self::POST_TYPE,
'normal',
'high'
);
add_meta_box(
'bnb_room_gallery',
__( 'Room Gallery', 'wp-bnb' ),
array( self::class, 'render_gallery_meta_box' ),
self::POST_TYPE,
'normal',
'default'
);
add_meta_box(
'bnb_room_pricing',
__( 'Room Pricing', 'wp-bnb' ),
array( self::class, 'render_pricing_meta_box' ),
self::POST_TYPE,
'normal',
'high'
);
}
/**
* Render building selection meta box.
*
* @param \WP_Post $post Current post object.
* @return void
*/
public static function render_building_meta_box( \WP_Post $post ): void {
wp_nonce_field( 'bnb_room_meta', 'bnb_room_meta_nonce' );
$building_id = get_post_meta( $post->ID, self::META_PREFIX . 'building_id', true );
$buildings = get_posts(
array(
'post_type' => Building::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'orderby' => 'title',
'order' => 'ASC',
)
);
?>
<p>
<label for="bnb_room_building_id"><?php esc_html_e( 'Select Building', 'wp-bnb' ); ?></label>
</p>
<select id="bnb_room_building_id" name="bnb_room_building_id" class="widefat" required>
<option value=""><?php esc_html_e( '— Select Building —', '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>
<?php if ( empty( $buildings ) ) : ?>
<p class="description">
<?php
printf(
/* translators: %s: Link to add new building */
esc_html__( 'No buildings found. %s first.', 'wp-bnb' ),
'<a href="' . esc_url( admin_url( 'post-new.php?post_type=' . Building::POST_TYPE ) ) . '">' . esc_html__( 'Add a building', 'wp-bnb' ) . '</a>'
);
?>
</p>
<?php endif; ?>
<?php
}
/**
* Render room details meta box.
*
* @param \WP_Post $post Current post object.
* @return void
*/
public static function render_details_meta_box( \WP_Post $post ): void {
$room_number = get_post_meta( $post->ID, self::META_PREFIX . 'room_number', true );
$floor = get_post_meta( $post->ID, self::META_PREFIX . 'floor', true );
$capacity = get_post_meta( $post->ID, self::META_PREFIX . 'capacity', true );
$max_adults = get_post_meta( $post->ID, self::META_PREFIX . 'max_adults', true );
$max_children = get_post_meta( $post->ID, self::META_PREFIX . 'max_children', true );
$size = get_post_meta( $post->ID, self::META_PREFIX . 'size', true );
$beds = get_post_meta( $post->ID, self::META_PREFIX . 'beds', true );
$bathrooms = get_post_meta( $post->ID, self::META_PREFIX . 'bathrooms', true );
$status = get_post_meta( $post->ID, self::META_PREFIX . 'status', true );
?>
<table class="form-table">
<tr>
<th scope="row">
<label for="bnb_room_room_number"><?php esc_html_e( 'Room Number', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="text" id="bnb_room_room_number" name="bnb_room_room_number"
value="<?php echo esc_attr( $room_number ); ?>" class="regular-text">
<p class="description"><?php esc_html_e( 'Room identifier (e.g., 101, A-12, Suite B)', 'wp-bnb' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_room_floor"><?php esc_html_e( 'Floor', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="number" id="bnb_room_floor" name="bnb_room_floor"
value="<?php echo esc_attr( $floor ); ?>" class="small-text" min="0">
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_room_size"><?php esc_html_e( 'Size (m²)', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="number" id="bnb_room_size" name="bnb_room_size"
value="<?php echo esc_attr( $size ); ?>" class="small-text" min="1" step="0.1">
</td>
</tr>
<tr>
<th scope="row">
<?php esc_html_e( 'Capacity', 'wp-bnb' ); ?>
</th>
<td>
<label for="bnb_room_capacity"><?php esc_html_e( 'Total Guests', 'wp-bnb' ); ?></label>
<input type="number" id="bnb_room_capacity" name="bnb_room_capacity"
value="<?php echo esc_attr( $capacity ?: '2' ); ?>" class="small-text" min="1" max="20">
<br><br>
<label for="bnb_room_max_adults"><?php esc_html_e( 'Max Adults', 'wp-bnb' ); ?></label>
<input type="number" id="bnb_room_max_adults" name="bnb_room_max_adults"
value="<?php echo esc_attr( $max_adults ?: '2' ); ?>" class="small-text" min="1" max="10">
<br><br>
<label for="bnb_room_max_children"><?php esc_html_e( 'Max Children', 'wp-bnb' ); ?></label>
<input type="number" id="bnb_room_max_children" name="bnb_room_max_children"
value="<?php echo esc_attr( $max_children ?: '0' ); ?>" class="small-text" min="0" max="10">
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_room_beds"><?php esc_html_e( 'Beds', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="text" id="bnb_room_beds" name="bnb_room_beds"
value="<?php echo esc_attr( $beds ); ?>" class="regular-text">
<p class="description"><?php esc_html_e( 'Description of beds (e.g., 1 King, 2 Singles)', 'wp-bnb' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_room_bathrooms"><?php esc_html_e( 'Bathrooms', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="number" id="bnb_room_bathrooms" name="bnb_room_bathrooms"
value="<?php echo esc_attr( $bathrooms ?: '1' ); ?>" class="small-text" min="0" max="5" step="0.5">
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_room_status"><?php esc_html_e( 'Room Status', 'wp-bnb' ); ?></label>
</th>
<td>
<select id="bnb_room_status" name="bnb_room_status">
<?php foreach ( self::get_room_statuses() as $key => $label ) : ?>
<option value="<?php echo esc_attr( $key ); ?>" <?php selected( $status ?: 'available', $key ); ?>>
<?php echo esc_html( $label ); ?>
</option>
<?php endforeach; ?>
</select>
</td>
</tr>
</table>
<?php
}
/**
* Render gallery meta box.
*
* @param \WP_Post $post Current post object.
* @return void
*/
public static function render_gallery_meta_box( \WP_Post $post ): void {
$gallery_ids = get_post_meta( $post->ID, self::META_PREFIX . 'gallery', true );
$gallery_ids = $gallery_ids ? explode( ',', $gallery_ids ) : array();
?>
<div id="bnb-room-gallery" class="bnb-gallery-container">
<div class="bnb-gallery-images">
<?php foreach ( $gallery_ids as $image_id ) : ?>
<?php $image = wp_get_attachment_image_src( $image_id, 'thumbnail' ); ?>
<?php if ( $image ) : ?>
<div class="bnb-gallery-image" data-id="<?php echo esc_attr( $image_id ); ?>">
<img src="<?php echo esc_url( $image[0] ); ?>" alt="">
<button type="button" class="bnb-remove-image">&times;</button>
</div>
<?php endif; ?>
<?php endforeach; ?>
</div>
<input type="hidden" id="bnb_room_gallery" name="bnb_room_gallery"
value="<?php echo esc_attr( implode( ',', $gallery_ids ) ); ?>">
<button type="button" id="bnb-add-gallery-images" class="button">
<?php esc_html_e( 'Add Images', 'wp-bnb' ); ?>
</button>
</div>
<p class="description"><?php esc_html_e( 'Add additional images for this room. Drag to reorder.', 'wp-bnb' ); ?></p>
<?php
}
/**
* Render pricing meta box.
*
* @param \WP_Post $post Current post object.
* @return void
*/
public static function render_pricing_meta_box( \WP_Post $post ): void {
$pricing = Calculator::getRoomPricing( $post->ID );
$currency = get_option( 'wp_bnb_currency', 'CHF' );
?>
<table class="form-table bnb-pricing-table">
<tr>
<th scope="row" colspan="2">
<h4><?php esc_html_e( 'Base Prices', 'wp-bnb' ); ?></h4>
</th>
</tr>
<?php foreach ( PricingTier::cases() as $tier ) : ?>
<tr>
<th scope="row">
<label for="bnb_room_price_<?php echo esc_attr( $tier->value ); ?>">
<?php echo esc_html( $tier->label() ); ?>
</label>
</th>
<td>
<div class="bnb-price-input-wrapper">
<input type="number"
id="bnb_room_price_<?php echo esc_attr( $tier->value ); ?>"
name="bnb_room_price_<?php echo esc_attr( $tier->value ); ?>"
value="<?php echo esc_attr( $pricing[ $tier->value ]['price'] ?? '' ); ?>"
class="small-text"
min="0"
step="0.01">
<span class="bnb-price-unit">
<?php echo esc_html( $currency ); ?> <?php echo esc_html( $tier->unit() ); ?>
</span>
</div>
</td>
</tr>
<?php endforeach; ?>
<tr>
<th scope="row" colspan="2">
<h4><?php esc_html_e( 'Adjustments', 'wp-bnb' ); ?></h4>
</th>
</tr>
<tr>
<th scope="row">
<label for="bnb_room_price_weekend_surcharge">
<?php esc_html_e( 'Weekend Surcharge', 'wp-bnb' ); ?>
</label>
</th>
<td>
<div class="bnb-price-input-wrapper">
<input type="number"
id="bnb_room_price_weekend_surcharge"
name="bnb_room_price_weekend_surcharge"
value="<?php echo esc_attr( $pricing['weekend_surcharge']['price'] ?? '' ); ?>"
class="small-text"
min="0"
step="0.01">
<span class="bnb-price-unit">
<?php echo esc_html( $currency ); ?> <?php esc_html_e( 'per night', 'wp-bnb' ); ?>
</span>
</div>
<p class="description">
<?php esc_html_e( 'Additional amount charged for weekend nights (Friday/Saturday by default).', 'wp-bnb' ); ?>
</p>
</td>
</tr>
</table>
<p class="description">
<?php
printf(
/* translators: %s: Link to pricing settings */
esc_html__( 'Seasonal pricing and tier thresholds can be configured in %s.', 'wp-bnb' ),
'<a href="' . esc_url( admin_url( 'admin.php?page=wp-bnb-settings&tab=pricing' ) ) . '">' . esc_html__( 'Pricing Settings', 'wp-bnb' ) . '</a>'
);
?>
</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_room_meta_nonce'] ) ||
! wp_verify_nonce( sanitize_key( $_POST['bnb_room_meta_nonce'] ), 'bnb_room_meta' ) ) {
return;
}
// Check autosave.
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
// Check permissions.
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
// Building ID.
if ( isset( $_POST['bnb_room_building_id'] ) ) {
update_post_meta(
$post_id,
self::META_PREFIX . 'building_id',
absint( $_POST['bnb_room_building_id'] )
);
}
// Text fields.
$text_fields = array( 'room_number', 'beds', 'status' );
foreach ( $text_fields as $field ) {
$key = 'bnb_room_' . $field;
if ( isset( $_POST[ $key ] ) ) {
update_post_meta(
$post_id,
self::META_PREFIX . $field,
sanitize_text_field( wp_unslash( $_POST[ $key ] ) )
);
}
}
// Numeric fields.
$numeric_fields = array( 'floor', 'capacity', 'max_adults', 'max_children' );
foreach ( $numeric_fields as $field ) {
$key = 'bnb_room_' . $field;
if ( isset( $_POST[ $key ] ) ) {
update_post_meta(
$post_id,
self::META_PREFIX . $field,
absint( $_POST[ $key ] )
);
}
}
// Float fields.
$float_fields = array( 'size', 'bathrooms' );
foreach ( $float_fields as $field ) {
$key = 'bnb_room_' . $field;
if ( isset( $_POST[ $key ] ) ) {
update_post_meta(
$post_id,
self::META_PREFIX . $field,
floatval( $_POST[ $key ] )
);
}
}
// Gallery.
if ( isset( $_POST['bnb_room_gallery'] ) ) {
$gallery_ids = sanitize_text_field( wp_unslash( $_POST['bnb_room_gallery'] ) );
// Validate that each ID is numeric.
$ids = array_filter( explode( ',', $gallery_ids ), 'is_numeric' );
update_post_meta(
$post_id,
self::META_PREFIX . 'gallery',
implode( ',', $ids )
);
}
// Pricing fields.
$pricing_data = array();
foreach ( PricingTier::cases() as $tier ) {
$key = 'bnb_room_price_' . $tier->value;
if ( isset( $_POST[ $key ] ) && '' !== $_POST[ $key ] ) {
$pricing_data[ $tier->value ] = floatval( $_POST[ $key ] );
}
}
if ( isset( $_POST['bnb_room_price_weekend_surcharge'] ) ) {
$pricing_data['weekend_surcharge'] = floatval( $_POST['bnb_room_price_weekend_surcharge'] );
}
if ( ! empty( $pricing_data ) ) {
Calculator::saveRoomPricing( $post_id, $pricing_data );
}
}
/**
* 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 ) {
if ( 'taxonomy-bnb_room_type' === $key ) {
continue; // Will add after title.
}
if ( 'taxonomy-bnb_amenity' === $key ) {
continue; // Will add after room type.
}
$new_columns[ $key ] = $value;
if ( 'title' === $key ) {
$new_columns['building'] = __( 'Building', 'wp-bnb' );
$new_columns['room_number'] = __( 'Room #', 'wp-bnb' );
$new_columns['taxonomy-bnb_room_type'] = __( 'Room Type', 'wp-bnb' );
$new_columns['capacity'] = __( 'Capacity', 'wp-bnb' );
$new_columns['price'] = __( 'Price/Night', 'wp-bnb' );
$new_columns['status'] = __( 'Status', 'wp-bnb' );
}
}
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 'building':
$building_id = get_post_meta( $post_id, self::META_PREFIX . 'building_id', true );
if ( $building_id ) {
$building = get_post( $building_id );
if ( $building ) {
printf(
'<a href="%s">%s</a>',
esc_url( get_edit_post_link( $building_id ) ),
esc_html( $building->post_title )
);
} else {
echo '—';
}
} else {
echo '—';
}
break;
case 'room_number':
$room_number = get_post_meta( $post_id, self::META_PREFIX . 'room_number', true );
echo esc_html( $room_number ?: '—' );
break;
case 'capacity':
$capacity = get_post_meta( $post_id, self::META_PREFIX . 'capacity', true );
if ( $capacity ) {
printf(
'<span class="dashicons dashicons-groups"></span> %s',
esc_html( $capacity )
);
} else {
echo '—';
}
break;
case 'price':
$pricing = Calculator::getRoomPricing( $post_id );
$nightly = $pricing[ PricingTier::SHORT_TERM->value ]['price'] ?? null;
if ( $nightly ) {
echo esc_html( Calculator::formatPrice( $nightly ) );
} else {
echo '<span class="bnb-no-price">' . esc_html__( 'Not set', 'wp-bnb' ) . '</span>';
}
break;
case 'status':
$status = get_post_meta( $post_id, self::META_PREFIX . 'status', true ) ?: 'available';
$statuses = self::get_room_statuses();
$colors = self::get_status_colors();
?>
<span class="bnb-status-badge" style="background-color: <?php echo esc_attr( $colors[ $status ] ?? '#ccc' ); ?>">
<?php echo esc_html( $statuses[ $status ] ?? $status ); ?>
</span>
<?php
break;
}
}
/**
* Add sortable columns.
*
* @param array $columns Existing sortable columns.
* @return array
*/
public static function sortable_columns( array $columns ): array {
$columns['building'] = 'building';
$columns['room_number'] = 'room_number';
$columns['capacity'] = 'capacity';
return $columns;
}
/**
* Add building filter dropdown to admin list.
*
* @param string $post_type Current post type.
* @return void
*/
public static function add_building_filter( string $post_type ): void {
if ( self::POST_TYPE !== $post_type ) {
return;
}
$buildings = get_posts(
array(
'post_type' => Building::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'orderby' => 'title',
'order' => 'ASC',
)
);
if ( empty( $buildings ) ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter display only.
$selected = isset( $_GET['building_id'] ) ? absint( $_GET['building_id'] ) : 0;
?>
<select name="building_id">
<option value=""><?php esc_html_e( 'All Buildings', 'wp-bnb' ); ?></option>
<?php foreach ( $buildings as $building ) : ?>
<option value="<?php echo esc_attr( $building->ID ); ?>" <?php selected( $selected, $building->ID ); ?>>
<?php echo esc_html( $building->post_title ); ?>
</option>
<?php endforeach; ?>
</select>
<?php
}
/**
* Filter rooms by building in admin list.
*
* @param \WP_Query $query Current query.
* @return void
*/
public static function filter_by_building( \WP_Query $query ): void {
if ( ! is_admin() || ! $query->is_main_query() ) {
return;
}
if ( self::POST_TYPE !== $query->get( 'post_type' ) ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter query only.
if ( ! empty( $_GET['building_id'] ) ) {
$query->set(
'meta_query',
array(
array(
'key' => self::META_PREFIX . 'building_id',
'value' => absint( $_GET['building_id'] ),
),
)
);
}
}
/**
* 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 __( 'Enter room name', 'wp-bnb' );
}
return $placeholder;
}
/**
* Get room status options.
*
* @return array<string, string>
*/
public static function get_room_statuses(): array {
return array(
'available' => __( 'Available', 'wp-bnb' ),
'occupied' => __( 'Occupied', 'wp-bnb' ),
'maintenance' => __( 'Maintenance', 'wp-bnb' ),
'blocked' => __( 'Blocked', 'wp-bnb' ),
);
}
/**
* Get status color codes.
*
* @return array<string, string>
*/
public static function get_status_colors(): array {
return array(
'available' => '#00a32a',
'occupied' => '#72aee6',
'maintenance' => '#dba617',
'blocked' => '#d63638',
);
}
/**
* Get building for a room.
*
* @param int $room_id Room post ID.
* @return \WP_Post|null
*/
public static function get_building( int $room_id ): ?\WP_Post {
$building_id = get_post_meta( $room_id, self::META_PREFIX . 'building_id', true );
if ( ! $building_id ) {
return null;
}
return get_post( $building_id );
}
/**
* Get rooms for a building.
*
* @param int $building_id Building post ID.
* @return array<\WP_Post>
*/
public static function get_rooms_for_building( int $building_id ): array {
return get_posts(
array(
'post_type' => self::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'meta_query' => array(
array(
'key' => self::META_PREFIX . 'building_id',
'value' => $building_id,
),
),
'orderby' => 'meta_value',
'meta_key' => self::META_PREFIX . 'room_number',
'order' => 'ASC',
)
);
}
}

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

363
src/Pricing/Calculator.php Normal file
View File

@@ -0,0 +1,363 @@
<?php
/**
* Price calculator.
*
* Handles price calculations for room bookings.
*
* @package Magdev\WpBnb\Pricing
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Pricing;
use Magdev\WpBnb\PostTypes\Room;
/**
* Price calculator class.
*/
final class Calculator {
/**
* Meta key prefix for room pricing.
*
* @var string
*/
private const META_PREFIX = '_bnb_room_price_';
/**
* Room post ID.
*
* @var int
*/
private int $room_id;
/**
* Check-in date.
*
* @var \DateTimeImmutable
*/
private \DateTimeImmutable $check_in;
/**
* Check-out date.
*
* @var \DateTimeImmutable
*/
private \DateTimeImmutable $check_out;
/**
* Price breakdown.
*
* @var array
*/
private array $breakdown = array();
/**
* Constructor.
*
* @param int $room_id Room post ID.
* @param \DateTimeInterface|string $check_in Check-in date.
* @param \DateTimeInterface|string $check_out Check-out date.
*/
public function __construct( int $room_id, \DateTimeInterface|string $check_in, \DateTimeInterface|string $check_out ) {
$this->room_id = $room_id;
if ( is_string( $check_in ) ) {
$check_in = new \DateTimeImmutable( $check_in );
} elseif ( $check_in instanceof \DateTime ) {
$check_in = \DateTimeImmutable::createFromMutable( $check_in );
}
if ( is_string( $check_out ) ) {
$check_out = new \DateTimeImmutable( $check_out );
} elseif ( $check_out instanceof \DateTime ) {
$check_out = \DateTimeImmutable::createFromMutable( $check_out );
}
$this->check_in = $check_in;
$this->check_out = $check_out;
}
/**
* Get the number of nights.
*
* @return int
*/
public function getNights(): int {
$interval = $this->check_in->diff( $this->check_out );
return max( 1, $interval->days );
}
/**
* Get the pricing tier for this stay.
*
* @return PricingTier
*/
public function getTier(): PricingTier {
return PricingTier::fromNights( $this->getNights() );
}
/**
* Get the base price for a room.
*
* @param PricingTier $tier Pricing tier.
* @return float
*/
public function getBasePrice( PricingTier $tier ): float {
$meta_key = self::META_PREFIX . $tier->value;
$price = get_post_meta( $this->room_id, $meta_key, true );
return $price ? (float) $price : 0.0;
}
/**
* Get the weekend surcharge for a room.
*
* @return float Surcharge as absolute amount.
*/
public function getWeekendSurcharge(): float {
$surcharge = get_post_meta( $this->room_id, self::META_PREFIX . 'weekend_surcharge', true );
return $surcharge ? (float) $surcharge : 0.0;
}
/**
* Check if weekend surcharge is enabled for this room.
*
* @return bool
*/
public function hasWeekendSurcharge(): bool {
return $this->getWeekendSurcharge() > 0;
}
/**
* Check if a date is a weekend day.
*
* @param \DateTimeInterface $date Date to check.
* @return bool
*/
public static function isWeekend( \DateTimeInterface $date ): bool {
$day_of_week = (int) $date->format( 'N' ); // 1 = Monday, 7 = Sunday.
$weekend_days = array_map(
'intval',
explode( ',', (string) get_option( 'wp_bnb_weekend_days', '5,6' ) ) // Default: Friday, Saturday.
);
return in_array( $day_of_week, $weekend_days, true );
}
/**
* Calculate the total price.
*
* @return float
*/
public function calculate(): float {
$this->breakdown = array();
$nights = $this->getNights();
$tier = $this->getTier();
$base_price = $this->getBasePrice( $tier );
$weekend_surcharge = $this->getWeekendSurcharge();
// If no base price is set, return 0.
if ( $base_price <= 0 ) {
return 0.0;
}
$total = 0.0;
// Calculate based on tier.
switch ( $tier ) {
case PricingTier::LONG_TERM:
// Monthly pricing.
$months = ceil( $nights / 30 );
$total = $base_price * $months;
$this->breakdown['months'] = $months;
$this->breakdown['monthly_rate'] = $base_price;
break;
case PricingTier::MID_TERM:
// Weekly pricing.
$weeks = ceil( $nights / 7 );
$total = $base_price * $weeks;
$this->breakdown['weeks'] = $weeks;
$this->breakdown['weekly_rate'] = $base_price;
break;
case PricingTier::SHORT_TERM:
default:
// Nightly pricing with seasonal adjustments and weekend surcharges.
$current = $this->check_in;
$breakdown_nights = array();
for ( $i = 0; $i < $nights; $i++ ) {
$night_price = $base_price;
$modifiers = array();
// Apply seasonal pricing.
$season = Season::forDate( $current );
if ( $season ) {
$night_price *= $season->modifier;
$modifiers['season'] = array(
'name' => $season->name,
'modifier' => $season->modifier,
);
}
// Apply weekend surcharge.
if ( $weekend_surcharge > 0 && self::isWeekend( $current ) ) {
$night_price += $weekend_surcharge;
$modifiers['weekend'] = $weekend_surcharge;
}
$breakdown_nights[] = array(
'date' => $current->format( 'Y-m-d' ),
'base' => $base_price,
'final' => $night_price,
'modifiers' => $modifiers,
);
$total += $night_price;
$current = $current->modify( '+1 day' );
}
$this->breakdown['nights'] = $breakdown_nights;
$this->breakdown['nightly_rate'] = $base_price;
break;
}
$this->breakdown['tier'] = $tier->value;
$this->breakdown['total'] = $total;
/**
* Filter the calculated price.
*
* @param float $total Calculated total price.
* @param int $room_id Room post ID.
* @param Calculator $calculator Calculator instance.
*/
return (float) apply_filters( 'wp_bnb_calculate_price', $total, $this->room_id, $this );
}
/**
* Get the price breakdown.
*
* Must be called after calculate().
*
* @return array
*/
public function getBreakdown(): array {
return $this->breakdown;
}
/**
* Format a price with currency.
*
* @param float $amount Amount to format.
* @param string $currency Currency code. Default from settings.
* @return string
*/
public static function formatPrice( float $amount, string $currency = '' ): string {
if ( empty( $currency ) ) {
$currency = get_option( 'wp_bnb_currency', 'CHF' );
}
$symbols = array(
'CHF' => 'CHF',
'EUR' => "\u{20AC}",
'USD' => '$',
'GBP' => "\u{00A3}",
);
$symbol = $symbols[ $currency ] ?? $currency;
$formatted = number_format( $amount, 2, '.', "'" );
// CHF uses suffix, others use prefix.
if ( 'CHF' === $currency ) {
return $formatted . ' ' . $symbol;
}
return $symbol . ' ' . $formatted;
}
/**
* Get room pricing summary.
*
* @param int $room_id Room post ID.
* @return array
*/
public static function getRoomPricing( int $room_id ): array {
$pricing = array();
foreach ( PricingTier::cases() as $tier ) {
$meta_key = self::META_PREFIX . $tier->value;
$price = get_post_meta( $room_id, $meta_key, true );
$pricing[ $tier->value ] = array(
'label' => $tier->label(),
'unit' => $tier->unit(),
'price' => $price ? (float) $price : null,
);
}
$pricing['weekend_surcharge'] = array(
'label' => __( 'Weekend Surcharge', 'wp-bnb' ),
'price' => (float) get_post_meta( $room_id, self::META_PREFIX . 'weekend_surcharge', true ),
);
return $pricing;
}
/**
* Save room pricing.
*
* @param int $room_id Room post ID.
* @param array $pricing Pricing data with tier keys.
* @return void
*/
public static function saveRoomPricing( int $room_id, array $pricing ): void {
foreach ( PricingTier::cases() as $tier ) {
if ( isset( $pricing[ $tier->value ] ) ) {
update_post_meta(
$room_id,
self::META_PREFIX . $tier->value,
floatval( $pricing[ $tier->value ] )
);
}
}
if ( isset( $pricing['weekend_surcharge'] ) ) {
update_post_meta(
$room_id,
self::META_PREFIX . 'weekend_surcharge',
floatval( $pricing['weekend_surcharge'] )
);
}
}
/**
* Get the check-in date.
*
* @return \DateTimeImmutable
*/
public function getCheckIn(): \DateTimeImmutable {
return $this->check_in;
}
/**
* Get the check-out date.
*
* @return \DateTimeImmutable
*/
public function getCheckOut(): \DateTimeImmutable {
return $this->check_out;
}
/**
* Get the room ID.
*
* @return int
*/
public function getRoomId(): int {
return $this->room_id;
}
}

159
src/Pricing/PricingTier.php Normal file
View File

@@ -0,0 +1,159 @@
<?php
/**
* Pricing tier enumeration.
*
* Defines the pricing tiers for short, mid, and long-term stays.
*
* @package Magdev\WpBnb\Pricing
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Pricing;
/**
* Pricing tier enum.
*/
enum PricingTier: string {
/**
* Short-term stay (per night).
* Default: 1-6 nights.
*/
case SHORT_TERM = 'short_term';
/**
* Mid-term stay (per week).
* Default: 7-27 nights (1-4 weeks).
*/
case MID_TERM = 'mid_term';
/**
* Long-term stay (per month).
* Default: 28+ nights (1+ months).
*/
case LONG_TERM = 'long_term';
/**
* Get the label for this tier.
*
* @return string
*/
public function label(): string {
return match ( $this ) {
self::SHORT_TERM => __( 'Short-term (Nightly)', 'wp-bnb' ),
self::MID_TERM => __( 'Mid-term (Weekly)', 'wp-bnb' ),
self::LONG_TERM => __( 'Long-term (Monthly)', 'wp-bnb' ),
};
}
/**
* Get the unit label for this tier.
*
* @return string
*/
public function unit(): string {
return match ( $this ) {
self::SHORT_TERM => __( 'per night', 'wp-bnb' ),
self::MID_TERM => __( 'per week', 'wp-bnb' ),
self::LONG_TERM => __( 'per month', 'wp-bnb' ),
};
}
/**
* Get the unit label for this tier (singular).
*
* @return string
*/
public function unitSingular(): string {
return match ( $this ) {
self::SHORT_TERM => __( 'night', 'wp-bnb' ),
self::MID_TERM => __( 'week', 'wp-bnb' ),
self::LONG_TERM => __( 'month', 'wp-bnb' ),
};
}
/**
* Get the unit label for this tier (plural).
*
* @return string
*/
public function unitPlural(): string {
return match ( $this ) {
self::SHORT_TERM => __( 'nights', 'wp-bnb' ),
self::MID_TERM => __( 'weeks', 'wp-bnb' ),
self::LONG_TERM => __( 'months', 'wp-bnb' ),
};
}
/**
* Get the default minimum nights for this tier.
*
* @return int
*/
public function defaultMinNights(): int {
return match ( $this ) {
self::SHORT_TERM => 1,
self::MID_TERM => 7,
self::LONG_TERM => 28,
};
}
/**
* Get the default maximum nights for this tier.
*
* @return int|null Null means unlimited.
*/
public function defaultMaxNights(): ?int {
return match ( $this ) {
self::SHORT_TERM => 6,
self::MID_TERM => 27,
self::LONG_TERM => null,
};
}
/**
* Determine the pricing tier based on number of nights.
*
* @param int $nights Number of nights.
* @param int|null $short_term_max Maximum nights for short-term. Default 6.
* @param int|null $mid_term_max Maximum nights for mid-term. Default 27.
* @return self
*/
public static function fromNights( int $nights, ?int $short_term_max = null, ?int $mid_term_max = null ): self {
$short_term_max = $short_term_max ?? (int) get_option( 'wp_bnb_short_term_max_nights', 6 );
$mid_term_max = $mid_term_max ?? (int) get_option( 'wp_bnb_mid_term_max_nights', 27 );
if ( $nights <= $short_term_max ) {
return self::SHORT_TERM;
}
if ( $nights <= $mid_term_max ) {
return self::MID_TERM;
}
return self::LONG_TERM;
}
/**
* Get all tiers.
*
* @return array<self>
*/
public static function all(): array {
return self::cases();
}
/**
* Get tier options for select fields.
*
* @return array<string, string>
*/
public static function options(): array {
$options = array();
foreach ( self::cases() as $tier ) {
$options[ $tier->value ] = $tier->label();
}
return $options;
}
}

319
src/Pricing/Season.php Normal file
View File

@@ -0,0 +1,319 @@
<?php
/**
* Seasonal pricing management.
*
* Handles seasonal pricing periods with date ranges and price modifiers.
*
* @package Magdev\WpBnb\Pricing
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Pricing;
/**
* Season class for managing seasonal pricing.
*/
final class Season {
/**
* Option name for storing seasons.
*
* @var string
*/
private const OPTION_NAME = 'wp_bnb_seasons';
/**
* Season ID.
*
* @var string
*/
public string $id;
/**
* Season name.
*
* @var string
*/
public string $name;
/**
* Start date (format: MM-DD).
*
* @var string
*/
public string $start_date;
/**
* End date (format: MM-DD).
*
* @var string
*/
public string $end_date;
/**
* Price modifier (multiplier). 1.0 = normal, 1.2 = 20% increase, 0.8 = 20% decrease.
*
* @var float
*/
public float $modifier;
/**
* Priority for overlapping seasons. Higher value = higher priority.
*
* @var int
*/
public int $priority;
/**
* Whether this season is active.
*
* @var bool
*/
public bool $active;
/**
* Constructor.
*
* @param array $data Season data.
*/
public function __construct( array $data = array() ) {
$this->id = $data['id'] ?? wp_generate_uuid4();
$this->name = $data['name'] ?? '';
$this->start_date = $data['start_date'] ?? '';
$this->end_date = $data['end_date'] ?? '';
$this->modifier = isset( $data['modifier'] ) ? (float) $data['modifier'] : 1.0;
$this->priority = isset( $data['priority'] ) ? (int) $data['priority'] : 0;
$this->active = isset( $data['active'] ) ? (bool) $data['active'] : true;
}
/**
* Convert to array.
*
* @return array
*/
public function toArray(): array {
return array(
'id' => $this->id,
'name' => $this->name,
'start_date' => $this->start_date,
'end_date' => $this->end_date,
'modifier' => $this->modifier,
'priority' => $this->priority,
'active' => $this->active,
);
}
/**
* Check if a date falls within this season.
*
* @param \DateTimeInterface $date Date to check.
* @param int|null $year Year context (for spanning seasons like winter).
* @return bool
*/
public function containsDate( \DateTimeInterface $date, ?int $year = null ): bool {
if ( ! $this->active ) {
return false;
}
$year = $year ?? (int) $date->format( 'Y' );
// Parse start and end as month-day.
$start_parts = explode( '-', $this->start_date );
$end_parts = explode( '-', $this->end_date );
if ( count( $start_parts ) !== 2 || count( $end_parts ) !== 2 ) {
return false;
}
$start_month = (int) $start_parts[0];
$start_day = (int) $start_parts[1];
$end_month = (int) $end_parts[0];
$end_day = (int) $end_parts[1];
$check_month = (int) $date->format( 'm' );
$check_day = (int) $date->format( 'd' );
// Create comparable values (month * 100 + day).
$check_value = $check_month * 100 + $check_day;
$start_value = $start_month * 100 + $start_day;
$end_value = $end_month * 100 + $end_day;
// Handle year-spanning seasons (e.g., December to February).
if ( $start_value > $end_value ) {
// Season spans year boundary.
return $check_value >= $start_value || $check_value <= $end_value;
}
// Normal season within same year.
return $check_value >= $start_value && $check_value <= $end_value;
}
/**
* Get the display label for the modifier.
*
* @return string
*/
public function getModifierLabel(): string {
if ( $this->modifier === 1.0 ) {
return __( 'Normal', 'wp-bnb' );
}
$percentage = ( $this->modifier - 1 ) * 100;
if ( $percentage > 0 ) {
/* translators: %s: percentage increase */
return sprintf( __( '+%s%%', 'wp-bnb' ), number_format( $percentage, 0 ) );
}
/* translators: %s: percentage decrease */
return sprintf( __( '%s%%', 'wp-bnb' ), number_format( $percentage, 0 ) );
}
/**
* Get all seasons.
*
* @return array<self>
*/
public static function all(): array {
$data = get_option( self::OPTION_NAME, array() );
$seasons = array();
foreach ( $data as $season_data ) {
$seasons[] = new self( $season_data );
}
// Sort by priority (descending).
usort( $seasons, fn( Season $a, Season $b ) => $b->priority <=> $a->priority );
return $seasons;
}
/**
* Get all active seasons.
*
* @return array<self>
*/
public static function allActive(): array {
return array_filter( self::all(), fn( Season $s ) => $s->active );
}
/**
* Find a season by ID.
*
* @param string $id Season ID.
* @return self|null
*/
public static function find( string $id ): ?self {
foreach ( self::all() as $season ) {
if ( $season->id === $id ) {
return $season;
}
}
return null;
}
/**
* Get the applicable season for a date.
*
* Returns the highest priority active season that contains the date.
*
* @param \DateTimeInterface $date Date to check.
* @return self|null
*/
public static function forDate( \DateTimeInterface $date ): ?self {
$seasons = self::allActive();
foreach ( $seasons as $season ) {
if ( $season->containsDate( $date ) ) {
return $season;
}
}
return null;
}
/**
* Save a season.
*
* @param self $season Season to save.
* @return bool
*/
public static function save( self $season ): bool {
$data = get_option( self::OPTION_NAME, array() );
$exists = false;
foreach ( $data as $index => $existing ) {
if ( $existing['id'] === $season->id ) {
$data[ $index ] = $season->toArray();
$exists = true;
break;
}
}
if ( ! $exists ) {
$data[] = $season->toArray();
}
return update_option( self::OPTION_NAME, $data );
}
/**
* Delete a season.
*
* @param string $id Season ID to delete.
* @return bool
*/
public static function delete( string $id ): bool {
$data = get_option( self::OPTION_NAME, array() );
$data = array_filter( $data, fn( $s ) => $s['id'] !== $id );
return update_option( self::OPTION_NAME, array_values( $data ) );
}
/**
* Create default seasons.
*
* @return void
*/
public static function createDefaults(): void {
$existing = get_option( self::OPTION_NAME, array() );
if ( ! empty( $existing ) ) {
return;
}
$defaults = array(
new self(
array(
'name' => __( 'High Season', 'wp-bnb' ),
'start_date' => '06-15',
'end_date' => '09-15',
'modifier' => 1.25,
'priority' => 10,
'active' => true,
)
),
new self(
array(
'name' => __( 'Winter Holidays', 'wp-bnb' ),
'start_date' => '12-20',
'end_date' => '01-06',
'modifier' => 1.30,
'priority' => 20,
'active' => true,
)
),
new self(
array(
'name' => __( 'Low Season', 'wp-bnb' ),
'start_date' => '11-01',
'end_date' => '03-31',
'modifier' => 0.85,
'priority' => 5,
'active' => true,
)
),
);
$data = array_map( fn( Season $s ) => $s->toArray(), $defaults );
update_option( self::OPTION_NAME, $data );
}
}

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

242
src/Taxonomies/Amenity.php Normal file
View File

@@ -0,0 +1,242 @@
<?php
/**
* Amenity taxonomy.
*
* Non-hierarchical taxonomy for room amenities like WiFi, Parking, etc.
*
* @package Magdev\WpBnb\Taxonomies
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Taxonomies;
/**
* Amenity taxonomy class.
*/
final class Amenity {
/**
* Taxonomy slug.
*
* @var string
*/
public const TAXONOMY = 'bnb_amenity';
/**
* Initialize the taxonomy.
*
* @return void
*/
public static function init(): void {
add_action( 'init', array( self::class, 'register' ) );
add_action( 'bnb_amenity_add_form_fields', array( self::class, 'add_form_fields' ) );
add_action( 'bnb_amenity_edit_form_fields', array( self::class, 'edit_form_fields' ), 10, 2 );
add_action( 'created_bnb_amenity', array( self::class, 'save_term_meta' ), 10, 2 );
add_action( 'edited_bnb_amenity', array( self::class, 'save_term_meta' ), 10, 2 );
add_filter( 'manage_edit-bnb_amenity_columns', array( self::class, 'add_columns' ) );
add_filter( 'manage_bnb_amenity_custom_column', array( self::class, 'render_column' ), 10, 3 );
}
/**
* Register the taxonomy.
*
* @return void
*/
public static function register(): void {
$labels = array(
'name' => _x( 'Amenities', 'taxonomy general name', 'wp-bnb' ),
'singular_name' => _x( 'Amenity', 'taxonomy singular name', 'wp-bnb' ),
'search_items' => __( 'Search Amenities', 'wp-bnb' ),
'popular_items' => __( 'Popular Amenities', 'wp-bnb' ),
'all_items' => __( 'All Amenities', 'wp-bnb' ),
'parent_item' => null,
'parent_item_colon' => null,
'edit_item' => __( 'Edit Amenity', 'wp-bnb' ),
'update_item' => __( 'Update Amenity', 'wp-bnb' ),
'add_new_item' => __( 'Add New Amenity', 'wp-bnb' ),
'new_item_name' => __( 'New Amenity Name', 'wp-bnb' ),
'separate_items_with_commas' => __( 'Separate amenities with commas', 'wp-bnb' ),
'add_or_remove_items' => __( 'Add or remove amenities', 'wp-bnb' ),
'choose_from_most_used' => __( 'Choose from the most used amenities', 'wp-bnb' ),
'not_found' => __( 'No amenities found.', 'wp-bnb' ),
'menu_name' => __( 'Amenities', 'wp-bnb' ),
'back_to_items' => __( '&larr; Back to Amenities', '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' => true,
'show_in_quick_edit' => true,
'show_admin_column' => true,
'rewrite' => array(
'slug' => 'amenity',
'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_room' ), $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="amenity-icon"><?php esc_html_e( 'Icon', 'wp-bnb' ); ?></label>
<select name="amenity_icon" id="amenity-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 amenity.', '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, 'amenity_icon', true );
?>
<tr class="form-field term-icon-wrap">
<th scope="row">
<label for="amenity-icon"><?php esc_html_e( 'Icon', 'wp-bnb' ); ?></label>
</th>
<td>
<select name="amenity_icon" id="amenity-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 amenity.', '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['amenity_icon'] ) ) {
update_term_meta(
$term_id,
'amenity_icon',
sanitize_text_field( wp_unslash( $_POST['amenity_icon'] ) )
);
}
}
/**
* 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' );
}
}
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, 'amenity_icon', true );
if ( $icon ) {
return '<span class="dashicons dashicons-' . esc_attr( $icon ) . '"></span>';
}
return '—';
}
return $content;
}
/**
* Get available icon options.
*
* @return array<string, string>
*/
public static function get_icon_options(): array {
return array(
'' => __( '— Select Icon —', 'wp-bnb' ),
'wifi' => __( 'WiFi', 'wp-bnb' ),
'car' => __( 'Parking', 'wp-bnb' ),
'food' => __( 'Breakfast', 'wp-bnb' ),
'palmtree' => __( 'Garden/Pool', 'wp-bnb' ),
'pets' => __( 'Pet Friendly', 'wp-bnb' ),
'universal-access' => __( 'Accessibility', 'wp-bnb' ),
'tv' => __( 'Television', 'wp-bnb' ),
'superhero-alt' => __( 'Air Conditioning', 'wp-bnb' ),
'coffee' => __( 'Coffee/Tea', 'wp-bnb' ),
'admin-home' => __( 'Kitchen', 'wp-bnb' ),
'businessman' => __( 'Business Center', 'wp-bnb' ),
'heart' => __( 'Spa/Wellness', 'wp-bnb' ),
'groups' => __( 'Family Friendly', 'wp-bnb' ),
'location-alt' => __( 'Central Location', 'wp-bnb' ),
'building' => __( 'Elevator', 'wp-bnb' ),
'store' => __( 'Minibar', 'wp-bnb' ),
'admin-appearance' => __( 'Room Service', 'wp-bnb' ),
'shield' => __( 'Safe', 'wp-bnb' ),
'privacy' => __( 'Non-Smoking', 'wp-bnb' ),
);
}
/**
* Get default amenities to seed on activation.
*
* @return array<string, array{icon: string}>
*/
public static function get_default_terms(): array {
return array(
__( 'WiFi', 'wp-bnb' ) => array( 'icon' => 'wifi' ),
__( 'Parking', 'wp-bnb' ) => array( 'icon' => 'car' ),
__( 'Breakfast Included', 'wp-bnb' ) => array( 'icon' => 'food' ),
__( 'Air Conditioning', 'wp-bnb' ) => array( 'icon' => 'superhero-alt' ),
__( 'Television', 'wp-bnb' ) => array( 'icon' => 'tv' ),
__( 'Pet Friendly', 'wp-bnb' ) => array( 'icon' => 'pets' ),
__( 'Wheelchair Accessible', 'wp-bnb' ) => array( 'icon' => 'universal-access' ),
__( 'Non-Smoking', 'wp-bnb' ) => array( 'icon' => 'privacy' ),
);
}
}

224
src/Taxonomies/RoomType.php Normal file
View File

@@ -0,0 +1,224 @@
<?php
/**
* Room Type taxonomy.
*
* Hierarchical taxonomy for room types like Standard, Suite, Family, etc.
*
* @package Magdev\WpBnb\Taxonomies
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Taxonomies;
/**
* Room Type taxonomy class.
*/
final class RoomType {
/**
* Taxonomy slug.
*
* @var string
*/
public const TAXONOMY = 'bnb_room_type';
/**
* Initialize the taxonomy.
*
* @return void
*/
public static function init(): void {
add_action( 'init', array( self::class, 'register' ) );
add_action( 'bnb_room_type_add_form_fields', array( self::class, 'add_form_fields' ) );
add_action( 'bnb_room_type_edit_form_fields', array( self::class, 'edit_form_fields' ), 10, 2 );
add_action( 'created_bnb_room_type', array( self::class, 'save_term_meta' ), 10, 2 );
add_action( 'edited_bnb_room_type', array( self::class, 'save_term_meta' ), 10, 2 );
}
/**
* Register the taxonomy.
*
* @return void
*/
public static function register(): void {
$labels = array(
'name' => _x( 'Room Types', 'taxonomy general name', 'wp-bnb' ),
'singular_name' => _x( 'Room Type', 'taxonomy singular name', 'wp-bnb' ),
'search_items' => __( 'Search Room Types', 'wp-bnb' ),
'all_items' => __( 'All Room Types', 'wp-bnb' ),
'parent_item' => __( 'Parent Room Type', 'wp-bnb' ),
'parent_item_colon' => __( 'Parent Room Type:', 'wp-bnb' ),
'edit_item' => __( 'Edit Room Type', 'wp-bnb' ),
'update_item' => __( 'Update Room Type', 'wp-bnb' ),
'add_new_item' => __( 'Add New Room Type', 'wp-bnb' ),
'new_item_name' => __( 'New Room Type Name', 'wp-bnb' ),
'menu_name' => __( 'Room Types', 'wp-bnb' ),
'back_to_items' => __( '&larr; Back to Room Types', 'wp-bnb' ),
'not_found' => __( 'No room types found.', 'wp-bnb' ),
);
$args = array(
'labels' => $labels,
'hierarchical' => true, // Hierarchical (like categories).
'public' => true,
'publicly_queryable' => true,
'show_ui' => true,
'show_in_menu' => true,
'show_in_nav_menus' => true,
'show_in_rest' => true,
'show_in_quick_edit' => true,
'show_admin_column' => true,
'rewrite' => array(
'slug' => 'room-type',
'with_front' => false,
'hierarchical' => true,
),
'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_room' ), $args );
}
/**
* Add custom fields to the add term form.
*
* @return void
*/
public static function add_form_fields(): void {
?>
<div class="form-field term-base-capacity-wrap">
<label for="room-type-base-capacity"><?php esc_html_e( 'Base Capacity', 'wp-bnb' ); ?></label>
<input type="number" name="room_type_base_capacity" id="room-type-base-capacity" value="2" min="1" max="20">
<p><?php esc_html_e( 'Default number of guests this room type typically accommodates.', 'wp-bnb' ); ?></p>
</div>
<div class="form-field term-sort-order-wrap">
<label for="room-type-sort-order"><?php esc_html_e( 'Sort Order', 'wp-bnb' ); ?></label>
<input type="number" name="room_type_sort_order" id="room-type-sort-order" value="0" min="0">
<p><?php esc_html_e( 'Display order for this room type (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 {
$base_capacity = get_term_meta( $term->term_id, 'room_type_base_capacity', true );
$sort_order = get_term_meta( $term->term_id, 'room_type_sort_order', true );
?>
<tr class="form-field term-base-capacity-wrap">
<th scope="row">
<label for="room-type-base-capacity"><?php esc_html_e( 'Base Capacity', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="number" name="room_type_base_capacity" id="room-type-base-capacity"
value="<?php echo esc_attr( $base_capacity ?: '2' ); ?>" min="1" max="20">
<p class="description"><?php esc_html_e( 'Default number of guests this room type typically accommodates.', 'wp-bnb' ); ?></p>
</td>
</tr>
<tr class="form-field term-sort-order-wrap">
<th scope="row">
<label for="room-type-sort-order"><?php esc_html_e( 'Sort Order', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="number" name="room_type_sort_order" id="room-type-sort-order"
value="<?php echo esc_attr( $sort_order ?: '0' ); ?>" min="0">
<p class="description"><?php esc_html_e( 'Display order for this room type (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['room_type_base_capacity'] ) ) {
update_term_meta(
$term_id,
'room_type_base_capacity',
absint( $_POST['room_type_base_capacity'] )
);
}
if ( isset( $_POST['room_type_sort_order'] ) ) {
update_term_meta(
$term_id,
'room_type_sort_order',
absint( $_POST['room_type_sort_order'] )
);
}
}
/**
* Get default room types to seed on activation.
*
* @return array<string, array{capacity: int, order: int, children?: array<string, array{capacity: int, order: int}>}>
*/
public static function get_default_terms(): array {
return array(
__( 'Standard', 'wp-bnb' ) => array(
'capacity' => 2,
'order' => 10,
'children' => array(
__( 'Single', 'wp-bnb' ) => array(
'capacity' => 1,
'order' => 11,
),
__( 'Double', 'wp-bnb' ) => array(
'capacity' => 2,
'order' => 12,
),
__( 'Twin', 'wp-bnb' ) => array(
'capacity' => 2,
'order' => 13,
),
),
),
__( 'Superior', 'wp-bnb' ) => array(
'capacity' => 2,
'order' => 20,
),
__( 'Suite', 'wp-bnb' ) => array(
'capacity' => 2,
'order' => 30,
'children' => array(
__( 'Junior Suite', 'wp-bnb' ) => array(
'capacity' => 2,
'order' => 31,
),
__( 'Executive Suite', 'wp-bnb' ) => array(
'capacity' => 2,
'order' => 32,
),
),
),
__( 'Family', 'wp-bnb' ) => array(
'capacity' => 4,
'order' => 40,
),
__( 'Accessible', 'wp-bnb' ) => array(
'capacity' => 2,
'order' => 50,
),
__( 'Apartment', 'wp-bnb' ) => array(
'capacity' => 4,
'order' => 60,
),
);
}
}

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 Name: WP BnB Management
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wp-bnb * Plugin URI: https://src.bundespruefstelle.ch/magdev/wp-bnb
* Description: A comprehensive Bed & Breakfast management system for WordPress. Manage buildings, rooms, bookings, and guests. * Description: A comprehensive Bed & Breakfast management system for WordPress. Manage buildings, rooms, bookings, and guests.
* Version: 0.0.1 * Version: 0.7.2
* Requires at least: 6.0 * Requires at least: 6.0
* Requires PHP: 8.3 * Requires PHP: 8.3
* Author: Marco Graetsch * Author: Marco Graetsch
@@ -24,7 +24,7 @@ if ( ! defined( 'ABSPATH' ) ) {
} }
// Plugin version constant - MUST match Version in header above. // Plugin version constant - MUST match Version in header above.
define( 'WP_BNB_VERSION', '0.0.1' ); define( 'WP_BNB_VERSION', '0.7.2' );
// Plugin path constants. // Plugin path constants.
define( 'WP_BNB_PATH', plugin_dir_path( __FILE__ ) ); define( 'WP_BNB_PATH', plugin_dir_path( __FILE__ ) );
@@ -155,6 +155,20 @@ function wp_bnb_activate(): void {
); );
} }
// Load Composer autoloader for activation.
$autoloader = WP_BNB_PATH . 'vendor/autoload.php';
if ( file_exists( $autoloader ) ) {
require_once $autoloader;
// Register post types and taxonomies before flushing rewrite rules.
\Magdev\WpBnb\Taxonomies\Amenity::register();
\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. // Set default options.
add_option( 'wp_bnb_version', WP_BNB_VERSION ); add_option( 'wp_bnb_version', WP_BNB_VERSION );