diff --git a/CHANGELOG.md b/CHANGELOG.md index 74cd763..61f3bcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,74 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.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 @@ -290,6 +358,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Input sanitization and output escaping - Server secret masking in license settings +[0.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 diff --git a/PLAN.md b/PLAN.md index 899aae8..61b3722 100644 --- a/PLAN.md +++ b/PLAN.md @@ -116,11 +116,11 @@ This document outlines the implementation plan for the WP BnB Management plugin. - [x] Automatic price calculation - [x] Service summary display -## Phase 6: Frontend Features (v0.6.0) +## Phase 6: Frontend Features (v0.6.0) - Complete ### Search & Filtering -- [ ] Room search with filters +- [x] Room search with filters - Date range (availability) - Capacity - Room type @@ -130,23 +130,24 @@ This document outlines the implementation plan for the WP BnB Management plugin. ### Display Components -- [ ] Building list/grid shortcode -- [ ] Room list/grid shortcode -- [ ] Room detail template -- [ ] Availability widget +- [x] Building list/grid shortcode +- [x] Room list/grid shortcode +- [x] Room detail template +- [x] Availability widget ### Gutenberg Blocks -- [ ] Building block -- [ ] Room block -- [ ] Room search block -- [ ] Booking form block +- [x] Building block +- [x] Room block +- [x] Room search block +- [x] Buildings list block +- [x] Rooms list block ### Widgets -- [ ] Similar rooms widget -- [ ] Building rooms widget -- [ ] Availability calendar widget +- [x] Similar rooms widget +- [x] Building rooms widget +- [x] Availability calendar widget ## Phase 7: Contact Form 7 Integration (v0.7.0) @@ -293,7 +294,7 @@ The plugin will provide extensive hooks for customization: | 0.3.0 | Bookings | Complete | | 0.4.0 | Guests | Complete | | 0.5.0 | Services | Complete | -| 0.6.0 | Frontend | TBD | +| 0.6.0 | Frontend | Complete | | 0.7.0 | CF7 Integration | TBD | | 0.8.0 | Dashboard | TBD | | 1.0.0 | Stable Release | TBD | diff --git a/assets/css/blocks-editor.css b/assets/css/blocks-editor.css new file mode 100644 index 0000000..4bafcb9 --- /dev/null +++ b/assets/css/blocks-editor.css @@ -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; +} diff --git a/assets/css/frontend.css b/assets/css/frontend.css index 7662186..f71d84a 100644 --- a/assets/css/frontend.css +++ b/assets/css/frontend.css @@ -4,4 +4,1242 @@ * @package Magdev\WpBnb */ -/* Placeholder - Frontend styles will be added as features are implemented */ +/* ========================================================================== + CSS Variables + ========================================================================== */ + +:root { + --wp-bnb-primary: #2271b1; + --wp-bnb-primary-hover: #135e96; + --wp-bnb-secondary: #50575e; + --wp-bnb-success: #00a32a; + --wp-bnb-warning: #dba617; + --wp-bnb-error: #d63638; + --wp-bnb-border: #dcdcde; + --wp-bnb-bg-light: #f6f7f7; + --wp-bnb-text: #1e1e1e; + --wp-bnb-text-light: #646970; + --wp-bnb-radius: 4px; + --wp-bnb-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + --wp-bnb-gap: 20px; +} + +/* ========================================================================== + Grid System + ========================================================================== */ + +.wp-bnb-buildings-grid, +.wp-bnb-rooms-grid { + display: grid; + gap: var(--wp-bnb-gap); +} + +.wp-bnb-columns-1 { grid-template-columns: 1fr; } +.wp-bnb-columns-2 { grid-template-columns: repeat(2, 1fr); } +.wp-bnb-columns-3 { grid-template-columns: repeat(3, 1fr); } +.wp-bnb-columns-4 { grid-template-columns: repeat(4, 1fr); } + +@media (max-width: 1024px) { + .wp-bnb-columns-4 { grid-template-columns: repeat(3, 1fr); } +} + +@media (max-width: 768px) { + .wp-bnb-columns-3, + .wp-bnb-columns-4 { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 480px) { + .wp-bnb-columns-2, + .wp-bnb-columns-3, + .wp-bnb-columns-4 { grid-template-columns: 1fr; } +} + +/* List layout */ +.wp-bnb-buildings-list, +.wp-bnb-rooms-list { + display: flex; + flex-direction: column; + gap: var(--wp-bnb-gap); +} + +.wp-bnb-buildings-list .wp-bnb-building-card, +.wp-bnb-rooms-list .wp-bnb-room-card { + display: flex; + flex-direction: row; +} + +.wp-bnb-buildings-list .wp-bnb-building-image, +.wp-bnb-rooms-list .wp-bnb-room-image { + flex: 0 0 250px; + max-width: 250px; +} + +@media (max-width: 768px) { + .wp-bnb-buildings-list .wp-bnb-building-card, + .wp-bnb-rooms-list .wp-bnb-room-card { + flex-direction: column; + } + + .wp-bnb-buildings-list .wp-bnb-building-image, + .wp-bnb-rooms-list .wp-bnb-room-image { + flex: none; + max-width: 100%; + } +} + +/* ========================================================================== + Building Card + ========================================================================== */ + +.wp-bnb-building-card { + background: #fff; + border: 1px solid var(--wp-bnb-border); + border-radius: var(--wp-bnb-radius); + overflow: hidden; + box-shadow: var(--wp-bnb-shadow); + transition: box-shadow 0.2s ease; +} + +.wp-bnb-building-card:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.wp-bnb-building-image { + position: relative; + overflow: hidden; +} + +.wp-bnb-building-image img { + width: 100%; + height: 200px; + object-fit: cover; + display: block; + transition: transform 0.3s ease; +} + +.wp-bnb-building-card:hover .wp-bnb-building-image img { + transform: scale(1.05); +} + +.wp-bnb-building-content { + padding: 20px; +} + +.wp-bnb-building-title { + margin: 0 0 10px; + font-size: 1.25em; + line-height: 1.3; +} + +.wp-bnb-building-title a { + color: var(--wp-bnb-text); + text-decoration: none; +} + +.wp-bnb-building-title a:hover { + color: var(--wp-bnb-primary); +} + +.wp-bnb-building-address, +.wp-bnb-building-rooms { + margin: 0 0 8px; + font-size: 0.9em; + color: var(--wp-bnb-text-light); + display: flex; + align-items: center; + gap: 6px; +} + +.wp-bnb-building-address .dashicons, +.wp-bnb-building-rooms .dashicons { + font-size: 16px; + width: 16px; + height: 16px; +} + +.wp-bnb-building-excerpt { + margin: 15px 0; + font-size: 0.9em; + color: var(--wp-bnb-text-light); + line-height: 1.5; +} + +/* ========================================================================== + Room Card + ========================================================================== */ + +.wp-bnb-room-card { + background: #fff; + border: 1px solid var(--wp-bnb-border); + border-radius: var(--wp-bnb-radius); + overflow: hidden; + box-shadow: var(--wp-bnb-shadow); + transition: box-shadow 0.2s ease; + display: flex; + flex-direction: column; +} + +.wp-bnb-room-card:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.wp-bnb-room-image { + position: relative; + overflow: hidden; +} + +.wp-bnb-room-image img { + width: 100%; + height: 200px; + object-fit: cover; + display: block; + transition: transform 0.3s ease; +} + +.wp-bnb-room-card:hover .wp-bnb-room-image img { + transform: scale(1.05); +} + +.wp-bnb-room-type-badge { + position: absolute; + top: 10px; + left: 10px; + background: var(--wp-bnb-primary); + color: #fff; + padding: 4px 10px; + border-radius: var(--wp-bnb-radius); + font-size: 0.75em; + font-weight: 600; + text-transform: uppercase; +} + +.wp-bnb-room-content { + padding: 20px; + flex: 1; + display: flex; + flex-direction: column; +} + +.wp-bnb-room-title { + margin: 0 0 8px; + font-size: 1.15em; + line-height: 1.3; +} + +.wp-bnb-room-title a { + color: var(--wp-bnb-text); + text-decoration: none; +} + +.wp-bnb-room-title a:hover { + color: var(--wp-bnb-primary); +} + +.wp-bnb-room-building { + margin: 0 0 10px; + font-size: 0.85em; + color: var(--wp-bnb-text-light); + display: flex; + align-items: center; + gap: 4px; +} + +.wp-bnb-room-building a { + color: var(--wp-bnb-text-light); + text-decoration: none; +} + +.wp-bnb-room-building a:hover { + color: var(--wp-bnb-primary); +} + +.wp-bnb-room-meta { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 12px; + font-size: 0.85em; + color: var(--wp-bnb-text-light); +} + +.wp-bnb-room-capacity, +.wp-bnb-room-size, +.wp-bnb-room-beds { + display: flex; + align-items: center; + gap: 4px; +} + +.wp-bnb-room-meta .dashicons { + font-size: 14px; + width: 14px; + height: 14px; +} + +/* Room amenities */ +.wp-bnb-room-amenities { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 15px; +} + +.wp-bnb-amenity { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.8em; + color: var(--wp-bnb-text-light); + background: var(--wp-bnb-bg-light); + padding: 4px 8px; + border-radius: var(--wp-bnb-radius); +} + +.wp-bnb-amenity .dashicons { + font-size: 14px; + width: 14px; + height: 14px; +} + +.wp-bnb-amenity-name { + display: none; +} + +@media (min-width: 768px) { + .wp-bnb-amenity-name { + display: inline; + } +} + +.wp-bnb-amenity-more { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.75em; + color: var(--wp-bnb-text-light); + background: var(--wp-bnb-bg-light); + padding: 4px 8px; + border-radius: var(--wp-bnb-radius); +} + +/* Room footer */ +.wp-bnb-room-footer { + margin-top: auto; + padding-top: 15px; + border-top: 1px solid var(--wp-bnb-border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.wp-bnb-room-price { + display: flex; + align-items: baseline; + gap: 4px; +} + +.wp-bnb-price-amount { + font-size: 1.25em; + font-weight: 700; + color: var(--wp-bnb-text); +} + +.wp-bnb-price-unit { + font-size: 0.8em; + color: var(--wp-bnb-text-light); +} + +/* ========================================================================== + Buttons + ========================================================================== */ + +.wp-bnb-button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 10px 20px; + font-size: 0.9em; + font-weight: 500; + text-decoration: none; + border: none; + border-radius: var(--wp-bnb-radius); + cursor: pointer; + transition: all 0.2s ease; +} + +.wp-bnb-button-primary, +.wp-bnb-button { + background: var(--wp-bnb-primary); + color: #fff; +} + +.wp-bnb-button-primary:hover, +.wp-bnb-button:hover { + background: var(--wp-bnb-primary-hover); + color: #fff; +} + +.wp-bnb-button-secondary { + background: var(--wp-bnb-bg-light); + color: var(--wp-bnb-secondary); + border: 1px solid var(--wp-bnb-border); +} + +.wp-bnb-button-secondary:hover { + background: #e0e0e0; +} + +.wp-bnb-button .dashicons { + font-size: 16px; + width: 16px; + height: 16px; +} + +/* ========================================================================== + Search Form + ========================================================================== */ + +.wp-bnb-room-search { + margin-bottom: 30px; +} + +.wp-bnb-search-form { + background: #fff; + border: 1px solid var(--wp-bnb-border); + border-radius: var(--wp-bnb-radius); + padding: 20px; + margin-bottom: 20px; +} + +.wp-bnb-search-fields { + display: flex; + flex-wrap: wrap; + gap: 15px; + margin-bottom: 15px; +} + +.wp-bnb-field { + flex: 1 1 200px; +} + +.wp-bnb-field-dates { + display: flex; + gap: 10px; + flex: 1 1 auto; +} + +.wp-bnb-field-group { + flex: 1; +} + +.wp-bnb-field label, +.wp-bnb-field-group label { + display: block; + margin-bottom: 5px; + font-size: 0.85em; + font-weight: 500; + color: var(--wp-bnb-text); +} + +.wp-bnb-field input, +.wp-bnb-field select, +.wp-bnb-field-group input, +.wp-bnb-field-group select { + width: 100%; + padding: 10px 12px; + font-size: 0.95em; + border: 1px solid var(--wp-bnb-border); + border-radius: var(--wp-bnb-radius); + background: #fff; +} + +.wp-bnb-field input:focus, +.wp-bnb-field select:focus, +.wp-bnb-field-group input:focus, +.wp-bnb-field-group select:focus { + outline: none; + border-color: var(--wp-bnb-primary); + box-shadow: 0 0 0 1px var(--wp-bnb-primary); +} + +/* Price range inputs */ +.wp-bnb-price-range-inputs { + display: flex; + align-items: center; + gap: 8px; +} + +.wp-bnb-price-range-inputs input { + width: 100px; +} + +.wp-bnb-price-separator { + color: var(--wp-bnb-text-light); +} + +.wp-bnb-currency { + font-size: 0.85em; + color: var(--wp-bnb-text-light); +} + +/* Amenities checkboxes */ +.wp-bnb-search-amenities { + margin-bottom: 15px; +} + +.wp-bnb-search-amenities > label { + display: block; + margin-bottom: 10px; + font-size: 0.85em; + font-weight: 500; +} + +.wp-bnb-amenities-list { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.wp-bnb-amenity-checkbox { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--wp-bnb-bg-light); + border: 1px solid var(--wp-bnb-border); + border-radius: var(--wp-bnb-radius); + cursor: pointer; + font-size: 0.85em; + transition: all 0.2s ease; +} + +.wp-bnb-amenity-checkbox:hover { + border-color: var(--wp-bnb-primary); +} + +.wp-bnb-amenity-checkbox input { + width: auto; + margin: 0; +} + +.wp-bnb-amenity-checkbox input:checked + .dashicons, +.wp-bnb-amenity-checkbox input:checked ~ span { + color: var(--wp-bnb-primary); +} + +.wp-bnb-amenity-checkbox .dashicons { + font-size: 14px; + width: 14px; + height: 14px; +} + +/* Search actions */ +.wp-bnb-search-actions { + display: flex; + gap: 10px; +} + +/* Search results status */ +.wp-bnb-search-status { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + padding: 10px 0; + border-bottom: 1px solid var(--wp-bnb-border); +} + +.wp-bnb-results-count { + font-size: 0.9em; + color: var(--wp-bnb-text-light); +} + +.wp-bnb-sort-options { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.9em; +} + +.wp-bnb-sort-options label { + color: var(--wp-bnb-text-light); +} + +.wp-bnb-sort-options select { + padding: 6px 10px; + font-size: 0.9em; + border: 1px solid var(--wp-bnb-border); + border-radius: var(--wp-bnb-radius); +} + +/* Loading state */ +.wp-bnb-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + color: var(--wp-bnb-text-light); +} + +.wp-bnb-spinner { + width: 40px; + height: 40px; + border: 3px solid var(--wp-bnb-border); + border-top-color: var(--wp-bnb-primary); + border-radius: 50%; + animation: wp-bnb-spin 0.8s linear infinite; + margin-bottom: 10px; +} + +@keyframes wp-bnb-spin { + to { transform: rotate(360deg); } +} + +/* Load more */ +.wp-bnb-search-pagination { + text-align: center; + margin-top: 20px; +} + +.wp-bnb-load-more { + min-width: 200px; +} + +/* ========================================================================== + Single Building + ========================================================================== */ + +.wp-bnb-building-single { + max-width: 1200px; +} + +.wp-bnb-building-featured-image { + margin-bottom: 20px; +} + +.wp-bnb-building-featured-image img { + width: 100%; + height: auto; + border-radius: var(--wp-bnb-radius); +} + +.wp-bnb-building-header { + margin-bottom: 20px; +} + +.wp-bnb-building-header .wp-bnb-building-title { + margin: 0; + font-size: 2em; +} + +.wp-bnb-building-details { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 20px; + margin-bottom: 30px; + padding: 20px; + background: var(--wp-bnb-bg-light); + border-radius: var(--wp-bnb-radius); +} + +.wp-bnb-building-details h4 { + margin: 0 0 10px; + font-size: 0.85em; + text-transform: uppercase; + color: var(--wp-bnb-text-light); +} + +.wp-bnb-building-address address { + font-style: normal; + line-height: 1.6; +} + +.wp-bnb-building-contact p { + margin: 0 0 8px; + display: flex; + align-items: center; + gap: 8px; +} + +.wp-bnb-building-contact .dashicons { + color: var(--wp-bnb-primary); +} + +.wp-bnb-building-times p { + margin: 0 0 5px; +} + +.wp-bnb-building-description { + margin-bottom: 30px; + line-height: 1.7; +} + +.wp-bnb-building-rooms h3 { + margin-bottom: 20px; +} + +/* ========================================================================== + Single Room + ========================================================================== */ + +.wp-bnb-room-single { + max-width: 1200px; +} + +.wp-bnb-room-gallery { + margin-bottom: 20px; +} + +.wp-bnb-room-featured-image img { + width: 100%; + height: auto; + border-radius: var(--wp-bnb-radius); +} + +.wp-bnb-room-gallery-thumbnails { + display: flex; + gap: 10px; + margin-top: 10px; + overflow-x: auto; +} + +.wp-bnb-gallery-thumb { + flex: 0 0 80px; + height: 60px; + overflow: hidden; + border-radius: var(--wp-bnb-radius); + border: 2px solid transparent; + transition: border-color 0.2s ease; +} + +.wp-bnb-gallery-thumb:hover { + border-color: var(--wp-bnb-primary); +} + +.wp-bnb-gallery-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.wp-bnb-room-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 20px; + padding-bottom: 20px; + border-bottom: 1px solid var(--wp-bnb-border); +} + +.wp-bnb-room-header-content { + flex: 1; +} + +.wp-bnb-room-header .wp-bnb-room-title { + margin: 0 0 8px; + font-size: 1.75em; +} + +.wp-bnb-room-type { + display: inline-block; + background: var(--wp-bnb-bg-light); + padding: 4px 10px; + border-radius: var(--wp-bnb-radius); + font-size: 0.85em; + color: var(--wp-bnb-text-light); +} + +.wp-bnb-room-header-price { + text-align: right; +} + +.wp-bnb-room-header-price .wp-bnb-price-label { + display: block; + font-size: 0.8em; + color: var(--wp-bnb-text-light); +} + +.wp-bnb-room-header-price .wp-bnb-price-amount { + font-size: 1.75em; +} + +/* Room specs */ +.wp-bnb-room-specs { + display: flex; + flex-wrap: wrap; + gap: 20px; + margin-bottom: 20px; + padding: 20px; + background: var(--wp-bnb-bg-light); + border-radius: var(--wp-bnb-radius); +} + +.wp-bnb-spec { + display: flex; + align-items: center; + gap: 8px; +} + +.wp-bnb-spec .dashicons { + color: var(--wp-bnb-primary); + font-size: 20px; + width: 20px; + height: 20px; +} + +.wp-bnb-spec-label { + font-size: 0.8em; + color: var(--wp-bnb-text-light); +} + +.wp-bnb-spec-value { + font-weight: 600; +} + +/* Room amenities full */ +.wp-bnb-room-amenities-full { + margin-bottom: 20px; +} + +.wp-bnb-room-amenities-full h4 { + margin: 0 0 15px; +} + +.wp-bnb-room-amenities-full .wp-bnb-amenities-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 10px; + list-style: none; + margin: 0; + padding: 0; +} + +.wp-bnb-room-amenities-full .wp-bnb-amenity { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--wp-bnb-bg-light); + border-radius: var(--wp-bnb-radius); +} + +.wp-bnb-room-amenities-full .wp-bnb-amenity .dashicons { + color: var(--wp-bnb-primary); +} + +/* Room description */ +.wp-bnb-room-description { + margin-bottom: 30px; + line-height: 1.7; +} + +/* Room pricing details */ +.wp-bnb-room-pricing-details { + margin-bottom: 30px; +} + +.wp-bnb-room-pricing-details h4 { + margin: 0 0 15px; +} + +.wp-bnb-pricing-table { + width: 100%; + max-width: 400px; + border-collapse: collapse; +} + +.wp-bnb-pricing-table td { + padding: 12px 15px; + border-bottom: 1px solid var(--wp-bnb-border); +} + +.wp-bnb-tier-label { + font-weight: 500; +} + +.wp-bnb-tier-price { + text-align: right; + font-weight: 600; +} + +.wp-bnb-tier-unit { + font-weight: normal; + color: var(--wp-bnb-text-light); + font-size: 0.85em; +} + +/* Room availability form */ +.wp-bnb-room-availability { + margin-bottom: 30px; + padding: 20px; + background: var(--wp-bnb-bg-light); + border-radius: var(--wp-bnb-radius); +} + +.wp-bnb-room-availability h4 { + margin: 0 0 15px; +} + +.wp-bnb-availability-fields { + display: flex; + flex-wrap: wrap; + gap: 15px; + align-items: flex-end; +} + +.wp-bnb-availability-fields .wp-bnb-field-group { + flex: 1 1 150px; +} + +.wp-bnb-availability-result { + margin-top: 15px; + padding: 15px; + border-radius: var(--wp-bnb-radius); +} + +.wp-bnb-availability-result.wp-bnb-available { + background: rgba(0, 163, 42, 0.1); + border: 1px solid var(--wp-bnb-success); +} + +.wp-bnb-availability-result.wp-bnb-unavailable { + background: rgba(214, 54, 56, 0.1); + border: 1px solid var(--wp-bnb-error); +} + +.wp-bnb-availability-result .wp-bnb-result-price { + font-size: 1.25em; + font-weight: 700; + margin-top: 10px; +} + +/* ========================================================================== + Calendar Widget + ========================================================================== */ + +.wp-bnb-availability-calendar-widget { + font-size: 0.9em; +} + +.wp-bnb-calendar-month { + margin-bottom: 15px; +} + +.wp-bnb-calendar-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + background: var(--wp-bnb-primary); + color: #fff; + border-radius: var(--wp-bnb-radius) var(--wp-bnb-radius) 0 0; +} + +.wp-bnb-calendar-month-name { + font-weight: 600; +} + +.wp-bnb-calendar-nav { + background: transparent; + border: none; + color: #fff; + font-size: 1.25em; + cursor: pointer; + padding: 5px 10px; + opacity: 0.8; + transition: opacity 0.2s ease; +} + +.wp-bnb-calendar-nav:hover { + opacity: 1; +} + +.wp-bnb-calendar-grid { + width: 100%; + border-collapse: collapse; + background: #fff; + border: 1px solid var(--wp-bnb-border); + border-top: none; + border-radius: 0 0 var(--wp-bnb-radius) var(--wp-bnb-radius); +} + +.wp-bnb-calendar-grid th { + padding: 8px 4px; + text-align: center; + font-weight: 500; + font-size: 0.8em; + color: var(--wp-bnb-text-light); + border-bottom: 1px solid var(--wp-bnb-border); +} + +.wp-bnb-calendar-grid td { + padding: 8px 4px; + text-align: center; + font-size: 0.85em; +} + +.wp-bnb-calendar-empty { + background: var(--wp-bnb-bg-light); +} + +.wp-bnb-calendar-day { + position: relative; +} + +.wp-bnb-calendar-day.wp-bnb-available { + color: var(--wp-bnb-success); + font-weight: 500; +} + +.wp-bnb-calendar-day.wp-bnb-booked { + color: #fff; + background: var(--wp-bnb-error); +} + +.wp-bnb-calendar-day.wp-bnb-past { + color: var(--wp-bnb-text-light); + opacity: 0.5; +} + +.wp-bnb-calendar-day.wp-bnb-today { + font-weight: 700; + box-shadow: inset 0 0 0 2px var(--wp-bnb-primary); +} + +/* Calendar legend */ +.wp-bnb-calendar-legend { + display: flex; + justify-content: center; + gap: 20px; + margin-top: 10px; + font-size: 0.8em; +} + +.wp-bnb-legend-item { + display: flex; + align-items: center; + gap: 6px; +} + +.wp-bnb-legend-color { + width: 16px; + height: 16px; + border-radius: 2px; +} + +.wp-bnb-legend-available .wp-bnb-legend-color { + background: var(--wp-bnb-success); +} + +.wp-bnb-legend-booked .wp-bnb-legend-color { + background: var(--wp-bnb-error); +} + +/* ========================================================================== + Similar Rooms Widget + ========================================================================== */ + +.wp-bnb-similar-rooms-list { + list-style: none; + margin: 0; + padding: 0; +} + +.wp-bnb-similar-room { + display: flex; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--wp-bnb-border); +} + +.wp-bnb-similar-room:last-child { + border-bottom: none; +} + +.wp-bnb-similar-room-image { + flex: 0 0 80px; +} + +.wp-bnb-similar-room-image img { + width: 80px; + height: 60px; + object-fit: cover; + border-radius: var(--wp-bnb-radius); +} + +.wp-bnb-similar-room-content { + flex: 1; +} + +.wp-bnb-similar-room-title { + margin: 0 0 5px; + font-size: 0.95em; +} + +.wp-bnb-similar-room-title a { + color: var(--wp-bnb-text); + text-decoration: none; +} + +.wp-bnb-similar-room-title a:hover { + color: var(--wp-bnb-primary); +} + +.wp-bnb-similar-room-price { + font-size: 0.9em; + font-weight: 600; + color: var(--wp-bnb-primary); +} + +/* ========================================================================== + Building Rooms Widget + ========================================================================== */ + +.wp-bnb-building-rooms-list, +.wp-bnb-building-rooms-compact { + list-style: none; + margin: 0; + padding: 0; +} + +.wp-bnb-building-room { + border-bottom: 1px solid var(--wp-bnb-border); +} + +.wp-bnb-building-room:last-child { + border-bottom: none; +} + +.wp-bnb-building-room-link { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 0; + color: var(--wp-bnb-text); + text-decoration: none; +} + +.wp-bnb-building-room-link:hover { + color: var(--wp-bnb-primary); +} + +.wp-bnb-building-room-title { + font-weight: 500; +} + +.wp-bnb-building-room-number { + font-size: 0.85em; + color: var(--wp-bnb-text-light); + margin-left: 8px; +} + +.wp-bnb-building-room-status { + font-size: 0.75em; + padding: 2px 8px; + border-radius: var(--wp-bnb-radius); +} + +.wp-bnb-status-available { background: rgba(0, 163, 42, 0.1); color: var(--wp-bnb-success); } +.wp-bnb-status-occupied { background: rgba(114, 174, 230, 0.2); color: #135e96; } +.wp-bnb-status-maintenance { background: rgba(219, 166, 23, 0.1); color: var(--wp-bnb-warning); } +.wp-bnb-status-blocked { background: rgba(214, 54, 56, 0.1); color: var(--wp-bnb-error); } + +.wp-bnb-building-room-price { + font-weight: 600; + font-size: 0.9em; +} + +.wp-bnb-building-room-meta { + display: flex; + gap: 12px; + padding: 0 0 10px; + font-size: 0.8em; + color: var(--wp-bnb-text-light); +} + +.wp-bnb-meta-item { + display: flex; + align-items: center; + gap: 4px; +} + +.wp-bnb-view-all-rooms { + display: block; + text-align: center; + padding: 10px; + margin-top: 10px; + color: var(--wp-bnb-primary); + font-size: 0.9em; +} + +/* ========================================================================== + Utility Classes + ========================================================================== */ + +.wp-bnb-no-results, +.wp-bnb-error { + padding: 30px; + text-align: center; + color: var(--wp-bnb-text-light); + background: var(--wp-bnb-bg-light); + border-radius: var(--wp-bnb-radius); +} + +.wp-bnb-error { + color: var(--wp-bnb-error); + background: rgba(214, 54, 56, 0.05); +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .wp-bnb-room-header { + flex-direction: column; + gap: 15px; + } + + .wp-bnb-room-header-price { + text-align: left; + } + + .wp-bnb-search-fields { + flex-direction: column; + } + + .wp-bnb-field { + flex: 1 1 100%; + } + + .wp-bnb-field-dates { + flex-direction: column; + } + + .wp-bnb-search-actions { + flex-direction: column; + } + + .wp-bnb-search-actions .wp-bnb-button { + width: 100%; + justify-content: center; + } + + .wp-bnb-search-status { + flex-direction: column; + gap: 10px; + align-items: flex-start; + } + + .wp-bnb-availability-fields { + flex-direction: column; + } +} + +/* Print styles */ +@media print { + .wp-bnb-search-form, + .wp-bnb-availability-form, + .wp-bnb-button, + .wp-bnb-calendar-nav { + display: none !important; + } +} diff --git a/assets/js/blocks-editor.js b/assets/js/blocks-editor.js new file mode 100644 index 0000000..faec97b --- /dev/null +++ b/assets/js/blocks-editor.js @@ -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); diff --git a/assets/js/frontend.js b/assets/js/frontend.js index b19ed1b..79f500f 100644 --- a/assets/js/frontend.js +++ b/assets/js/frontend.js @@ -1,12 +1,825 @@ /** * WP BnB Frontend JavaScript * + * Handles search forms, calendar widgets, and interactive elements. + * * @package Magdev\WpBnb */ (function() { 'use strict'; - // Placeholder - Frontend scripts will be added as features are implemented + /** + * WP BnB Frontend namespace. + */ + const WpBnb = { + + /** + * Configuration from localized script. + */ + config: window.wpBnbFrontend || {}, + + /** + * Initialize all frontend components. + */ + init: function() { + this.initSearchForms(); + this.initCalendarWidgets(); + this.initAvailabilityForms(); + this.initPriceCalculators(); + }, + + /** + * Initialize room search forms. + */ + initSearchForms: function() { + const forms = document.querySelectorAll('.wp-bnb-search-form'); + forms.forEach(form => { + new SearchForm(form); + }); + }, + + /** + * Initialize calendar widgets. + */ + initCalendarWidgets: function() { + const calendars = document.querySelectorAll('.wp-bnb-availability-calendar-widget'); + calendars.forEach(calendar => { + new CalendarWidget(calendar); + }); + }, + + /** + * Initialize availability check forms on single room pages. + */ + initAvailabilityForms: function() { + const forms = document.querySelectorAll('.wp-bnb-availability-check'); + forms.forEach(form => { + new AvailabilityForm(form); + }); + }, + + /** + * Initialize price calculator forms. + */ + initPriceCalculators: function() { + const calculators = document.querySelectorAll('.wp-bnb-price-calculator'); + calculators.forEach(calculator => { + new PriceCalculator(calculator); + }); + }, + + /** + * Make an AJAX request. + * + * @param {string} action The AJAX action. + * @param {Object} data The request data. + * @return {Promise} Promise resolving to response data. + */ + ajax: function(action, data = {}) { + const formData = new FormData(); + formData.append('action', action); + formData.append('nonce', this.config.nonce || ''); + + Object.keys(data).forEach(key => { + if (data[key] !== null && data[key] !== undefined) { + formData.append(key, data[key]); + } + }); + + return fetch(this.config.ajaxUrl || '/wp-admin/admin-ajax.php', { + method: 'POST', + body: formData, + credentials: 'same-origin' + }) + .then(response => { + if (!response.ok) { + throw new Error('Network response was not ok'); + } + return response.json(); + }) + .then(data => { + if (!data.success) { + throw new Error(data.data?.message || 'Request failed'); + } + return data.data; + }); + }, + + /** + * Format a date as YYYY-MM-DD. + * + * @param {Date} date The date object. + * @return {string} Formatted date string. + */ + formatDate: function(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + }, + + /** + * Parse a date string. + * + * @param {string} dateStr Date string in YYYY-MM-DD format. + * @return {Date|null} Date object or null if invalid. + */ + parseDate: function(dateStr) { + if (!dateStr) return null; + const parts = dateStr.split('-'); + if (parts.length !== 3) return null; + return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2])); + }, + + /** + * Calculate nights between two dates. + * + * @param {Date} checkIn Check-in date. + * @param {Date} checkOut Check-out date. + * @return {number} Number of nights. + */ + calculateNights: function(checkIn, checkOut) { + if (!checkIn || !checkOut) return 0; + const diffTime = checkOut.getTime() - checkIn.getTime(); + return Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + }, + + /** + * Debounce a function. + * + * @param {Function} func The function to debounce. + * @param {number} wait Wait time in milliseconds. + * @return {Function} Debounced function. + */ + debounce: function(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + } + }; + + /** + * Search Form handler class. + */ + class SearchForm { + constructor(element) { + this.form = element; + this.resultsContainer = document.querySelector( + this.form.dataset.results || '.wp-bnb-search-results' + ); + this.currentPage = 1; + this.isLoading = false; + + this.bindEvents(); + } + + bindEvents() { + // Form submission. + this.form.addEventListener('submit', (e) => { + e.preventDefault(); + this.currentPage = 1; + this.search(); + }); + + // Date validation. + const checkIn = this.form.querySelector('[name="check_in"]'); + const checkOut = this.form.querySelector('[name="check_out"]'); + + if (checkIn && checkOut) { + // Set min date to today. + const today = WpBnb.formatDate(new Date()); + checkIn.setAttribute('min', today); + + checkIn.addEventListener('change', () => { + if (checkIn.value) { + // Set check-out min to day after check-in. + const minCheckOut = WpBnb.parseDate(checkIn.value); + if (minCheckOut) { + minCheckOut.setDate(minCheckOut.getDate() + 1); + checkOut.setAttribute('min', WpBnb.formatDate(minCheckOut)); + + // Clear check-out if it's before new minimum. + if (checkOut.value && checkOut.value <= checkIn.value) { + checkOut.value = ''; + } + } + } + }); + + checkOut.addEventListener('change', () => { + if (checkOut.value && checkIn.value && checkOut.value <= checkIn.value) { + alert(WpBnb.config.i18n?.invalidDateRange || 'Check-out must be after check-in'); + checkOut.value = ''; + } + }); + } + + // Reset button. + const resetBtn = this.form.querySelector('[type="reset"]'); + if (resetBtn) { + resetBtn.addEventListener('click', () => { + setTimeout(() => { + this.clearResults(); + }, 0); + }); + } + + // Load more button. + if (this.resultsContainer) { + this.resultsContainer.addEventListener('click', (e) => { + if (e.target.classList.contains('wp-bnb-load-more')) { + e.preventDefault(); + this.loadMore(); + } + }); + } + } + + getFormData() { + const formData = new FormData(this.form); + const data = {}; + + formData.forEach((value, key) => { + if (value) { + // Handle array fields (amenities[]). + if (key.endsWith('[]')) { + const cleanKey = key.slice(0, -2); + if (!data[cleanKey]) { + data[cleanKey] = []; + } + data[cleanKey].push(value); + } else { + data[key] = value; + } + } + }); + + // Convert arrays to comma-separated strings for AJAX. + Object.keys(data).forEach(key => { + if (Array.isArray(data[key])) { + data[key] = data[key].join(','); + } + }); + + return data; + } + + search() { + if (this.isLoading) return; + + this.isLoading = true; + this.showLoading(); + + const data = this.getFormData(); + data.page = this.currentPage; + data.per_page = this.form.dataset.perPage || 12; + + WpBnb.ajax('wp_bnb_search_rooms', data) + .then(response => { + this.renderResults(response, this.currentPage === 1); + }) + .catch(error => { + this.showError(error.message); + }) + .finally(() => { + this.isLoading = false; + this.hideLoading(); + }); + } + + loadMore() { + this.currentPage++; + this.search(); + } + + renderResults(response, replace = true) { + if (!this.resultsContainer) return; + + const { rooms, total, page, total_pages } = response; + + if (replace) { + this.resultsContainer.innerHTML = ''; + } else { + // Remove existing load more button. + const existingLoadMore = this.resultsContainer.querySelector('.wp-bnb-load-more-wrapper'); + if (existingLoadMore) { + existingLoadMore.remove(); + } + } + + if (rooms.length === 0 && replace) { + this.resultsContainer.innerHTML = ` +
${WpBnb.config.i18n?.noResults || 'No rooms found matching your criteria.'}
+${WpBnb.config.i18n?.resultsFound?.replace('%d', total) || `${total} rooms found`}
`; + 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 = ` + + `; + this.resultsContainer.appendChild(loadMoreWrapper); + } + } + + createRoomCard(room) { + const card = document.createElement('article'); + card.className = 'wp-bnb-room-card'; + + let imageHtml = ''; + if (room.thumbnail) { + imageHtml = ` +${this.escapeHtml(room.building_name)}
` : ''} + + ${amenitiesHtml} + ${priceHtml} + + ${WpBnb.config.i18n?.viewDetails || 'View Details'} + +${this.escapeHtml(message)}
+' . esc_html__( 'Please select a building.', 'wp-bnb' ) . '
'; + } + + 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 '' . esc_html__( 'Please select a room.', 'wp-bnb' ) . '
'; + } + + 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', + ) + ); + } +} diff --git a/src/Frontend/Search.php b/src/Frontend/Search.php new file mode 100644 index 0000000..2768cdb --- /dev/null +++ b/src/Frontend/Search.php @@ -0,0 +1,677 @@ + '', + '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() ) + ); + } + } +} diff --git a/src/Frontend/Shortcodes.php b/src/Frontend/Shortcodes.php new file mode 100644 index 0000000..a663573 --- /dev/null +++ b/src/Frontend/Shortcodes.php @@ -0,0 +1,867 @@ + '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 '' . esc_html__( 'No buildings found.', 'wp-bnb' ) . '
'; + } + + $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(); + ?> ++ + +
+ + + + 0 ) : ?> ++ + +
+ + + ID ) ) : ?> +' . esc_html__( 'No rooms found.', 'wp-bnb' ) . '
'; + } + + $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(); + ?> +' . esc_html__( 'Building ID is required.', 'wp-bnb' ) . '
'; + } + + $building = get_post( $building_id ); + + if ( ! $building || Building::POST_TYPE !== $building->post_type ) { + return '' . esc_html__( 'Building not found.', 'wp-bnb' ) . '
'; + } + + $show_rooms = 'yes' === $atts['show_rooms']; + $show_address = 'yes' === $atts['show_address']; + $show_contact = 'yes' === $atts['show_contact']; + + ob_start(); + ?> ++ + +
+ +
' . esc_html__( 'Room ID is required.', 'wp-bnb' ) . '
'; + } + + $room = get_post( $room_id ); + + if ( ! $room || Room::POST_TYPE !== $room->post_type ) { + return '' . esc_html__( 'Room not found.', 'wp-bnb' ) . '
'; + } + + $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(); + ?> ++ + + + + + , + +
+ + + + + +| label() ); ?> | ++ + unit() ); ?> + | +
+ + +
+ ++ + + +
+ ++ + +
+ ++ > + +
+ ++ > + +
+ '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 '+ + +
+ ++ + + +
+ ++ + + +
+ ++ + +
+ ++ > + +
+ ++ > + +
+ '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 '+ + +
+ ++ + +
+ ++ + +
+ ++ > + +
+ ++ > + +
+ admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'wp_bnb_frontend_nonce' ), + 'i18n' => array( + 'searching' => __( 'Searching...', 'wp-bnb' ), + 'noResults' => __( 'No rooms found matching your criteria.', 'wp-bnb' ), + 'resultsFound' => __( '%d rooms found', 'wp-bnb' ), + 'loadMore' => __( 'Load More', 'wp-bnb' ), + 'viewDetails' => __( 'View Details', 'wp-bnb' ), + 'perNight' => __( 'night', 'wp-bnb' ), + 'guests' => __( 'guests', 'wp-bnb' ), + 'invalidDateRange' => __( 'Check-out must be after check-in', 'wp-bnb' ), + 'selectDates' => __( 'Please select check-in and check-out dates.', 'wp-bnb' ), + 'available' => __( 'Room is available!', 'wp-bnb' ), + 'notAvailable' => __( 'Sorry, the room is not available for these dates.', 'wp-bnb' ), + 'totalPrice' => __( 'Total', 'wp-bnb' ), + 'bookNow' => __( 'Book Now', 'wp-bnb' ), + 'total' => __( 'Total', 'wp-bnb' ), + 'nights' => __( 'nights', 'wp-bnb' ), + 'basePrice' => __( 'Base', 'wp-bnb' ), + 'weekendSurcharge' => __( 'Weekend surcharge', 'wp-bnb' ), + 'season' => __( 'Season', 'wp-bnb' ), + ), + ) + ); } /** diff --git a/wp-bnb.php b/wp-bnb.php index 62bde2c..b2e1641 100644 --- a/wp-bnb.php +++ b/wp-bnb.php @@ -3,7 +3,7 @@ * Plugin Name: WP BnB Management * Plugin URI: https://src.bundespruefstelle.ch/magdev/wp-bnb * Description: A comprehensive Bed & Breakfast management system for WordPress. Manage buildings, rooms, bookings, and guests. - * Version: 0.5.0 + * Version: 0.6.0 * Requires at least: 6.0 * Requires PHP: 8.3 * Author: Marco Graetsch @@ -24,7 +24,7 @@ if ( ! defined( 'ABSPATH' ) ) { } // Plugin version constant - MUST match Version in header above. -define( 'WP_BNB_VERSION', '0.5.0' ); +define( 'WP_BNB_VERSION', '0.6.0' ); // Plugin path constants. define( 'WP_BNB_PATH', plugin_dir_path( __FILE__ ) );