\WP_REST_Server::READABLE, 'callback' => array( $this, 'get_yachts' ), 'permission_callback' => '__return_true', ) ); // GET /yacht-booking/v1/yachts/{id} register_rest_route( self::NAMESPACE, '/yachts/(?P\d+)', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_yacht' ), 'permission_callback' => '__return_true', 'args' => array( 'id' => array( 'required' => true, 'validate_callback' => function( $param ) { return is_numeric( $param ); }, ), ), ) ); // GET /yacht-booking/v1/availability/{yacht_id} register_rest_route( self::NAMESPACE, '/availability/(?P\d+)', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_availability' ), 'permission_callback' => '__return_true', 'args' => array( 'yacht_id' => array( 'required' => true, 'validate_callback' => function( $param ) { return is_numeric( $param ); }, ), 'start' => array( 'required' => true, 'validate_callback' => function( $param ) { return strtotime( $param ) !== false; }, ), 'end' => array( 'required' => true, 'validate_callback' => function( $param ) { return strtotime( $param ) !== false; }, ), ), ) ); // GET /yacht-booking/v1/availability/all register_rest_route( self::NAMESPACE, '/availability/all', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_all_availability' ), 'permission_callback' => '__return_true', 'args' => array( 'start' => array( 'required' => false, 'sanitize_callback' => 'sanitize_text_field', ), 'end' => array( 'required' => false, 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); // GET /yacht-booking/v1/availability/bounds register_rest_route( self::NAMESPACE, '/availability/bounds', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_availability_bounds' ), 'permission_callback' => '__return_true', ) ); // POST /yacht-booking/v1/bookings register_rest_route( self::NAMESPACE, '/bookings', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_booking' ), 'permission_callback' => '__return_true', 'args' => array( 'yacht_id' => array( 'required' => true, 'validate_callback' => function( $param ) { return is_numeric( $param ) && get_post_type( $param ) === 'yacht'; }, 'sanitize_callback' => 'absint', ), 'start_date' => array( 'required' => true, 'sanitize_callback' => 'sanitize_text_field', ), 'end_date' => array( 'required' => true, 'sanitize_callback' => 'sanitize_text_field', ), 'customer_name' => array( 'required' => true, 'sanitize_callback' => 'sanitize_text_field', ), 'customer_email' => array( 'required' => true, 'validate_callback' => 'is_email', 'sanitize_callback' => 'sanitize_email', ), 'customer_phone' => array( 'required' => true, 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); // POST /yacht-booking/v1/inquiries register_rest_route( self::NAMESPACE, '/inquiries', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_inquiry' ), 'permission_callback' => '__return_true', 'args' => array( 'yacht_id' => array( 'required' => true, 'validate_callback' => function( $param ) { return is_numeric( $param ) && get_post_type( $param ) === 'yacht'; }, 'sanitize_callback' => 'absint', ), 'customer_name' => array( 'required' => true, 'sanitize_callback' => 'sanitize_text_field', ), 'customer_email' => array( 'required' => true, 'validate_callback' => 'is_email', 'sanitize_callback' => 'sanitize_email', ), 'customer_phone' => array( 'required' => true, 'sanitize_callback' => 'sanitize_text_field', ), 'preferred_dates' => array( 'required' => false, 'sanitize_callback' => 'sanitize_text_field', ), 'message' => array( 'required' => false, 'sanitize_callback' => 'sanitize_textarea_field', ), ), ) ); // GET /yacht-booking/v1/bookings (admin only) register_rest_route( self::NAMESPACE, '/bookings', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_bookings' ), 'permission_callback' => array( $this, 'can_manage_bookings' ), ) ); // PUT /yacht-booking/v1/bookings/{id}/status (admin only) register_rest_route( self::NAMESPACE, '/bookings/(?P\d+)/status', array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update_booking_status' ), 'permission_callback' => array( $this, 'can_manage_bookings' ), 'args' => array( 'id' => array( 'required' => true, 'validate_callback' => 'is_numeric', 'sanitize_callback' => 'absint', ), 'status' => array( 'required' => true, 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); } /** * Get all yachts * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ public function get_yachts( $request ) { $yachts = get_posts( array( 'post_type' => 'yacht', 'posts_per_page' => -1, 'orderby' => 'title', 'order' => 'ASC', ) ); $data = array(); foreach ( $yachts as $yacht ) { $data[] = array( 'id' => $yacht->ID, 'title' => $yacht->post_title, 'description' => $yacht->post_content, 'capacity' => Yacht::get_capacity( $yacht->ID ), 'price_per_day' => Yacht::get_price_per_day( $yacht->ID ), 'features' => Yacht::get_features( $yacht->ID ), 'image' => get_the_post_thumbnail_url( $yacht->ID, 'large' ), ); } return rest_ensure_response( $data ); } /** * Get single yacht * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response|\WP_Error */ public function get_yacht( $request ) { $yacht_id = $request->get_param( 'id' ); $yacht = get_post( $yacht_id ); if ( ! $yacht || $yacht->post_type !== 'yacht' ) { return new \WP_Error( 'not_found', __( 'Jacht nie znaleziony', 'yacht-booking' ), array( 'status' => 404 ) ); } $data = array( 'id' => $yacht->ID, 'title' => $yacht->post_title, 'description' => $yacht->post_content, 'capacity' => Yacht::get_capacity( $yacht->ID ), 'price_per_day' => Yacht::get_price_per_day( $yacht->ID ), 'features' => Yacht::get_features( $yacht->ID ), 'image' => get_the_post_thumbnail_url( $yacht->ID, 'large' ), ); return rest_ensure_response( $data ); } /** * Get yacht availability * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ public function get_availability( $request ) { $yacht_id = $request->get_param( 'yacht_id' ); $start = $request->get_param( 'start' ); $end = $request->get_param( 'end' ); $calendar = Availability::get_availability_calendar( $yacht_id, $start, $end ); // Convert to array format for FullCalendar $events = array(); foreach ( $calendar as $date => $info ) { $events[] = array( 'date' => $date, 'status' => $info['status'], ); } return rest_ensure_response( $events ); } /** * Get aggregated availability for all yachts + globalne wydarzenia kalendarza. * * Zwraca tablicę FullCalendar events (timed 12:00 → 12:00 dla efektu half-day). * Eventy z yacht_id > 0: kolor z palety per-jacht. * Eventy yacht_id = 0 (sync_mode=global): kolor `GLOBAL_EVENT_COLOR`. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ public function get_all_availability( $request ) { $start = $request->get_param( 'start' ); $end = $request->get_param( 'end' ); if ( ! $start || ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $start ) ) { $start = gmdate( 'Y-m-01' ); } if ( ! $end || ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $end ) ) { $end = gmdate( 'Y-m-d', strtotime( $start . ' +12 months' ) ); } $is_global_mode = ( 'global' === Settings::get_ical_sync_mode() ); // Build yacht_id → color map (admin-selected `_yacht_color` lub fallback z palety po ID). $yacht_posts_full = get_posts( array( 'post_type' => 'yacht', 'post_status' => 'publish', 'posts_per_page' => -1, 'orderby' => 'ID', 'order' => 'ASC', ) ); $yacht_ids = array(); foreach ( $yacht_posts_full as $yp ) { $yacht_ids[] = (int) $yp->ID; } $color_map = self::get_yacht_color_palette( $yacht_ids ); // Build name/alias → yacht_id map (lowercase keys, sorted by length DESC for longest match). $name_map = array(); foreach ( $yacht_posts_full as $yp ) { $title = mb_strtolower( trim( (string) $yp->post_title ) ); if ( '' !== $title ) { $name_map[ $title ] = (int) $yp->ID; } $alias = mb_strtolower( trim( (string) \YachtBooking\Yacht::get_gcal_alias( $yp->ID ) ) ); if ( '' !== $alias ) { $name_map[ $alias ] = (int) $yp->ID; } } uksort( $name_map, function( $a, $b ) { return mb_strlen( $b ) - mb_strlen( $a ); } ); // Query bookings overlapping [start, end] with status confirmed or pending. $bookings = get_posts( array( 'post_type' => 'yacht_booking', 'post_status' => 'publish', 'posts_per_page' => -1, 'meta_query' => array( 'relation' => 'AND', array( 'key' => '_booking_status', 'value' => array( 'confirmed', 'pending' ), 'compare' => 'IN', ), array( 'key' => '_booking_end_date', 'value' => $start, 'compare' => '>=', 'type' => 'DATE', ), array( 'key' => '_booking_start_date', 'value' => $end, 'compare' => '<=', 'type' => 'DATE', ), ), ) ); $ical_sources = array( \YachtBooking\Integrations\ICal\ICal_Import::GLOBAL_CALENDAR_SOURCE, \YachtBooking\Integrations\ICal\ICal_Import::GLOBAL_IMPORT_SOURCE, ); $events = array(); foreach ( $bookings as $booking ) { $booking_id = $booking->ID; $start_date = Booking::get_start_date( $booking_id ); $end_date = Booking::get_end_date( $booking_id ); if ( ! $start_date || ! $end_date ) { continue; } $yacht_id = Booking::get_yacht_id( $booking_id ); $source = (string) get_post_meta( $booking_id, '_booking_source', true ); $is_global_event = ( 0 === $yacht_id || \YachtBooking\Integrations\ICal\ICal_Import::GLOBAL_CALENDAR_SOURCE === $source ); // Title: raw SUMMARY z _booking_notes (iCal) lub customer_name (frontend). // Klient świadomie cofa privacy z 09-04 — tytuły rezerwacji widoczne publicznie. if ( in_array( $source, $ical_sources, true ) ) { $notes = (string) get_post_meta( $booking_id, '_booking_notes', true ); $title = '' !== trim( $notes ) ? $notes : __( 'Rezerwacja', 'yacht-booking' ); } else { $customer = (string) Booking::get_customer_name( $booking_id ); $title = '' !== trim( $customer ) ? $customer : __( 'Rezerwacja', 'yacht-booking' ); } $title = sanitize_text_field( $title ); // Color resolution: // - per-yacht event (yacht_id > 0): admin color or palette fallback // - global event (yacht_id = 0): match yacht name/alias anywhere in title (longest wins) if ( ! $is_global_event ) { $color = isset( $color_map[ $yacht_id ] ) ? $color_map[ $yacht_id ] : self::GLOBAL_EVENT_COLOR; $y_id = $yacht_id; } else { $color = self::GLOBAL_EVENT_COLOR; $y_id = 0; $title_lower = mb_strtolower( $title ); foreach ( $name_map as $needle => $matched_id ) { if ( '' !== $needle && false !== mb_strpos( $title_lower, $needle ) ) { if ( isset( $color_map[ $matched_id ] ) ) { $color = $color_map[ $matched_id ]; } break; } } } // Split na N eventów per dzień (allDay = każdy event mieści się w jednej komórce). // Iteracja od start_date do end_date INCLUSIVE — pierwszy i ostatni dzień // mają half-day visual (yacht odbierany / zwracany w południe). try { $cursor = new \DateTimeImmutable( $start_date ); $end_dt = new \DateTimeImmutable( $end_date ); } catch ( \Exception $e ) { continue; } while ( $cursor <= $end_dt ) { $day = $cursor->format( 'Y-m-d' ); $is_first = ( $day === $start_date ); $is_last = ( $day === $end_date ); $events[] = array( 'id' => $booking_id . '-' . $day, 'title' => $title, 'start' => $day, 'allDay' => true, 'color' => $color, 'yacht_id' => $y_id, 'extendedProps' => array( 'is_first' => $is_first, 'is_last_night' => $is_last, 'booking_id' => (int) $booking_id, ), ); $cursor = $cursor->modify( '+1 day' ); } } return rest_ensure_response( $events ); } /** * Zwraca granice nawigacji kalendarza zbiorczego: datę ostatniej rezerwacji * (confirmed/pending) z `_booking_end_date >= dziś`. * * Frontend używa tej wartości do ustawienia `validRange.end` w FullCalendar, * blokując nawigację w przód poza miesiąc ostatniej rezerwacji. * * @return \WP_REST_Response { max_booking_date: 'YYYY-MM-DD' | null } */ public function get_availability_bounds() { $today = gmdate( 'Y-m-d' ); $bookings = get_posts( array( 'post_type' => 'yacht_booking', 'post_status' => 'publish', 'posts_per_page' => 1, 'fields' => 'ids', 'meta_key' => '_booking_end_date', 'orderby' => 'meta_value', 'meta_type' => 'DATE', 'order' => 'DESC', 'meta_query' => array( 'relation' => 'AND', array( 'key' => '_booking_status', 'value' => array( 'confirmed', 'pending' ), 'compare' => 'IN', ), array( 'key' => '_booking_end_date', 'value' => $today, 'compare' => '>=', 'type' => 'DATE', ), ), ) ); $max_date = null; if ( ! empty( $bookings ) ) { $end = (string) get_post_meta( (int) $bookings[0], '_booking_end_date', true ); if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $end ) ) { $max_date = $end; } } return rest_ensure_response( array( 'max_booking_date' => $max_date ) ); } /** * Buduje deterministyczną mapę yacht_id → kolor z palety. * * Sortuje yacht_ids rosnąco i indeksuje paletę modulo długość. Zapewnia stabilny * kolor dla danego jachtu między requestami i wspólny przypisanie z frontendem. * * @param array $yacht_ids Lista ID jachtów. * @return array yacht_id => hex color. */ public static function get_yacht_color_palette( $yacht_ids ) { $ids = array_map( 'intval', (array) $yacht_ids ); sort( $ids, SORT_NUMERIC ); $palette = self::YACHT_COLOR_PALETTE; $count = count( $palette ); $map = array(); foreach ( $ids as $i => $yacht_id ) { $admin_color = \YachtBooking\Yacht::get_color( $yacht_id ); $map[ $yacht_id ] = '' !== $admin_color ? $admin_color : $palette[ $i % $count ]; } return $map; } /** * Create new booking * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response|\WP_Error */ public function create_booking( $request ) { // Check if online booking is enabled if ( ! Settings::is_booking_enabled() ) { return new \WP_Error( 'booking_disabled', __( 'Rezerwacje online są obecnie wyłączone. Skontaktuj się z nami telefonicznie lub mailowo.', 'yacht-booking' ), array( 'status' => 403 ) ); } // Verify nonce if ( ! wp_verify_nonce( $request->get_header( 'X-WP-Nonce' ), 'wp_rest' ) ) { return new \WP_Error( 'invalid_nonce', __( 'Nieprawidłowy token bezpieczeństwa', 'yacht-booking' ), array( 'status' => 403 ) ); } $yacht_id = $request->get_param( 'yacht_id' ); $start_date = $request->get_param( 'start_date' ); $end_date = $request->get_param( 'end_date' ); // Check availability if ( ! Availability::is_available( $yacht_id, $start_date, $end_date ) ) { return new \WP_Error( 'not_available', __( 'Jacht niedostępny w wybranych terminach', 'yacht-booking' ), array( 'status' => 400 ) ); } // Calculate price $days = Availability::count_days( $start_date, $end_date ); $price_per_day = Yacht::get_price_per_day( $yacht_id ); $total_price = $days * $price_per_day; // Create booking $booking_id = Booking::create( array( 'yacht_id' => $yacht_id, 'start_date' => $start_date, 'end_date' => $end_date, 'customer_name' => $request->get_param( 'customer_name' ), 'customer_email' => $request->get_param( 'customer_email' ), 'customer_phone' => $request->get_param( 'customer_phone' ), 'total_price' => $total_price, 'status' => Settings::get_default_status(), ) ); if ( ! $booking_id ) { return new \WP_Error( 'booking_failed', __( 'Nie udało się utworzyć rezerwacji', 'yacht-booking' ), array( 'status' => 500 ) ); } // Mark as booked in availability cache Availability::mark_as_booked( $yacht_id, $start_date, $end_date, $booking_id ); return rest_ensure_response( array( 'success' => true, 'message' => __( 'Rezerwacja została wysłana pomyślnie!', 'yacht-booking' ), 'booking_id' => $booking_id, ) ); } /** * Create new inquiry * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response|\WP_Error */ public function create_inquiry( $request ) { // Verify nonce if ( ! wp_verify_nonce( $request->get_header( 'X-WP-Nonce' ), 'wp_rest' ) ) { return new \WP_Error( 'invalid_nonce', __( 'Nieprawidłowy token bezpieczeństwa', 'yacht-booking' ), array( 'status' => 403 ) ); } $inquiry_id = Inquiry::create( array( 'yacht_id' => $request->get_param( 'yacht_id' ), 'customer_name' => $request->get_param( 'customer_name' ), 'customer_email' => $request->get_param( 'customer_email' ), 'customer_phone' => $request->get_param( 'customer_phone' ), 'preferred_dates' => $request->get_param( 'preferred_dates' ), 'message' => $request->get_param( 'message' ), ) ); if ( ! $inquiry_id ) { return new \WP_Error( 'inquiry_failed', __( 'Nie udało się wysłać zapytania', 'yacht-booking' ), array( 'status' => 500 ) ); } Inquiry::send_emails( $inquiry_id ); return rest_ensure_response( array( 'success' => true, 'message' => __( 'Twoje zapytanie zostało wysłane. Sprawdź swoją skrzynkę email — wysłaliśmy potwierdzenie.', 'yacht-booking' ), ) ); } /** * Check if current user can manage bookings. * * @return bool */ public function can_manage_bookings() { return current_user_can( 'yacht_booking_manage_bookings' ); } /** * Get bookings list (admin only). * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ public function get_bookings( $request ) { $bookings = get_posts( array( 'post_type' => 'yacht_booking', 'post_status' => 'publish', 'posts_per_page' => -1, 'orderby' => 'date', 'order' => 'DESC', ) ); $data = array(); foreach ( $bookings as $booking ) { $booking_id = $booking->ID; $yacht_id = Booking::get_yacht_id( $booking_id ); $yacht = get_post( $yacht_id ); $data[] = array( 'id' => $booking_id, 'yacht_id' => $yacht_id, 'yacht_name' => $yacht ? $yacht->post_title : '', 'start_date' => Booking::get_start_date( $booking_id ), 'end_date' => Booking::get_end_date( $booking_id ), 'status' => Booking::get_status( $booking_id ), 'customer_name' => Booking::get_customer_name( $booking_id ), 'customer_email' => Booking::get_customer_email( $booking_id ), 'customer_phone' => Booking::get_customer_phone( $booking_id ), 'total_price' => Booking::get_total_price( $booking_id ), 'created_at' => get_the_date( 'Y-m-d H:i:s', $booking ), ); } return rest_ensure_response( $data ); } /** * Update booking status (admin only). * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response|\WP_Error */ public function update_booking_status( $request ) { $booking_id = absint( $request->get_param( 'id' ) ); $status = sanitize_text_field( $request->get_param( 'status' ) ); if ( ! in_array( $status, array( 'pending', 'confirmed', 'cancelled' ), true ) ) { return new \WP_Error( 'invalid_status', __( 'Nieprawidłowy status rezerwacji', 'yacht-booking' ), array( 'status' => 400 ) ); } $booking = get_post( $booking_id ); if ( ! $booking || 'yacht_booking' !== $booking->post_type ) { return new \WP_Error( 'not_found', __( 'Rezerwacja nie została znaleziona', 'yacht-booking' ), array( 'status' => 404 ) ); } Booking::update_status( $booking_id, $status ); if ( 'cancelled' === $status ) { Availability::clear_booking_availability( $booking_id ); } return rest_ensure_response( array( 'success' => true, 'status' => $status, ) ); } /** * Send booking notification email to admin * * @param int $booking_id Booking post ID. */ public function send_booking_notification( $booking_id ) { $template_data = Email_Templates::get_booking_template_data( $booking_id ); $template = Email_Templates::compile( Email_Templates::TYPE_ADMIN_NEW_BOOKING, $template_data ); $admin_email = get_option( 'admin_email' ); $customer_email = Booking::get_customer_email( $booking_id ); if ( empty( $template['subject'] ) || empty( $admin_email ) ) { return; } $headers = array( 'Content-Type: text/html; charset=UTF-8', 'From: ' . Settings::get_email_from_name() . ' <' . Settings::get_email_from_address() . '>', 'Reply-To: ' . $template_data['customer_name'] . ' <' . $customer_email . '>', ); wp_mail( $admin_email, $template['subject'], $template['body'], $headers ); do_action( 'yacht_booking_notification_sent', $booking_id, $admin_email ); } }