0 ) { // Vaste duur (padel, pickleball): extra slot niet van toepassing. return $slot_minutes; } // Variabele duur (tennis): 15 minuten per speler + optioneel extra. $base = max( 1, min( 4, $players_count ) ) * self::MINUTES_PER_PLAYER; return $base + ( $extra_15min ? self::EXTRA_MINUTES : 0 ); } /** * Krijgen reserveringen op deze baan automatisch 15 minuten extra? * * Alleen als de baan het vinkje "15 minuten extra" heeft (kolom * allow_extra_slot) én het een tennisbaan met variabele duur is. * Padel en pickleball (vaste slotduur) krijgen nooit extra tijd, ook niet * als de kolom per ongeluk op 1 staat. * * @param object|null $court Baan-object uit Boardit_DB::get_court() / get_courts(). * @return bool */ public static function court_has_extra_slot( $court ): bool { if ( ! is_object( $court ) ) { return false; } if ( ( $court->sport_type ?? 'tennis' ) !== 'tennis' ) { return false; } if ( intval( $court->slot_minutes ?? 0 ) > 0 ) { return false; } return intval( $court->allow_extra_slot ?? 0 ) === 1; } /** * Speelduur (minuten) van een reservering met $players_count spelers op $court, * volgens de regels van díe baan: spelersaantal (min/max), vaste slotduur * (padel/pickleball) en "15 minuten extra" (alleen tennis). * * Gebruikt bij verplaatsen: een reservering neemt de regels van de doelbaan * over. Tennis 2 spelers → padel (min. 4) kan dus niet; tennis 4 spelers * → padel wordt 90 min; padel 4 spelers → tennis wordt 60 min (+15 extra). * * @param object $court Baan-object (Boardit_DB::get_court()). * @param int $players_count Aantal spelers van de reservering. * @return int|WP_Error Duur in minuten, of WP_Error 'invalid_players_for_court'. */ public static function duration_for_court( $court, int $players_count ) { $min = intval( $court->min_players ?? 1 ); $max = intval( $court->max_players ?? 4 ); if ( $players_count < $min || $players_count > $max ) { return self::players_not_allowed_error( $players_count, $min, $max ); } return self::calculate_duration( $players_count, self::court_has_extra_slot( $court ), intval( $court->slot_minutes ?? 0 ) ); } /** * Foutmelding: het spelersaantal van de reservering past niet bij de baan. * * @param int $players_count Spelers in de reservering. * @param int $min Minimum van de baan. * @param int $max Maximum van de baan. * @return WP_Error */ public static function players_not_allowed_error( int $players_count, int $min, int $max ): WP_Error { if ( $max === $min ) { /* translators: 1: aantal spelers van de reservering, 2: vereist aantal spelers op de baan */ $msg = sprintf( __( 'This reservation has %1$d player(s); this court requires %2$d players.', 'boardit' ), $players_count, $min ); } else { /* translators: 1: aantal spelers van de reservering, 2: minimum, 3: maximum aantal spelers op de baan */ $msg = sprintf( __( 'This reservation has %1$d player(s); this court allows %2$d to %3$d players.', 'boardit' ), $players_count, $min, $max ); } return new WP_Error( 'invalid_players_for_court', $msg ); } /** * Banen die op het bord getoond en geboekt mogen worden: alle actieve banen, * in bordvolgorde (sort_order, id). Er is geen limiet op het aantal banen. * * @return array Lijst met baan-objecten. */ public static function get_bookable_courts(): array { return (array) Boardit_DB::get_courts( true ); } /** * Maak een nieuwe reservering aan. * * @param array $data Velden: court_id, user_id, date, start_time, players_count (1–4, standaard 1), * player_name, partner_name, notes. De extra 15 minuten bepaalt de baan * (zie court_has_extra_slot()). * @return int|WP_Error Reservering-ID of WP_Error */ public static function create( array $data ) { $required = array( 'court_id', 'user_id', 'date', 'start_time' ); foreach ( $required as $field ) { if ( empty( $data[ $field ] ) ) { return new WP_Error( 'missing_field', sprintf( /* translators: %s: veldnaam */ __( 'Required field missing: %s', 'boardit' ), $field ) ); } } if ( Boardit_DB::is_member_blocked( $data['user_id'] ) ) { return new WP_Error( 'user_blocked', __( 'Your account has been blocked from making reservations.', 'boardit' ) ); } if ( ! self::is_valid_date( $data['date'] ) ) { return new WP_Error( 'invalid_date', __( 'Invalid date.', 'boardit' ) ); } if ( $data['date'] < self::today() ) { return new WP_Error( 'past_date', __( 'You cannot make a reservation in the past.', 'boardit' ) ); } // Spelersaantal valideren op basis van per-baan instellingen. $players_count = intval( $data['players_count'] ?? 1 ); $court_for_validation = Boardit_DB::get_court( intval( $data['court_id'] ) ); $court_min_players = $court_for_validation ? intval( $court_for_validation->min_players ?? 1 ) : 1; $court_max_players = $court_for_validation ? intval( $court_for_validation->max_players ?? 4 ) : 4; $court_slot_minutes = $court_for_validation ? intval( $court_for_validation->slot_minutes ?? 0 ) : 0; if ( $players_count < $court_min_players || $players_count > $court_max_players ) { return new WP_Error( 'invalid_players', sprintf( /* translators: 1: minimum spelers, 2: maximum spelers */ __( 'Invalid number of players for this court (%1$d–%2$d).', 'boardit' ), $court_min_players, $court_max_players ) ); } /** * Extra controle op een nieuwe reservering, bijvoorbeeld voor velden van een uitbreiding. * * @param true|WP_Error $valid true, of een WP_Error om de reservering te weigeren. * @param array $data Gegevens van de reservering. */ $valid = apply_filters( 'boardit_validate_reservation', true, $data ); if ( is_wp_error( $valid ) ) { return $valid; } // Max reserveringen per speler controleren (0 = onbeperkt). $max_res = intval( get_option( 'boardit_max_reservations', 0 ) ); if ( $max_res > 0 && ! boardit_can_manage() ) { $user_count = Boardit_DB::get_user_upcoming_count( intval( $data['user_id'] ) ); if ( $user_count >= $max_res ) { return new WP_Error( 'max_reservations', __( 'You have reached the maximum number of reservations.', 'boardit' ) ); } } // Extra 15 min: per baan instelbaar, alleen voor tennisbanen met variabele duur. $extra_15min = self::court_has_extra_slot( $court_for_validation ) ? 1 : 0; // Duur en eindtijd berekenen (vaste duur bij padel/pickleball, variabel bij tennis). $duration_min = self::calculate_duration( $players_count, (bool) $extra_15min, $court_slot_minutes ); $start_ts = strtotime( $data['start_time'] ); $end_ts = $start_ts + $duration_min * 60; $end_time = gmdate( 'H:i', $end_ts ); // Sluitingstijd controleren. $close_time = get_option( 'boardit_close_time', '22:00' ); if ( $end_ts > strtotime( $close_time ) ) { return new WP_Error( 'outside_hours', __( 'Reservation falls outside closing time.', 'boardit' ) ); } $court = Boardit_DB::get_court( intval( $data['court_id'] ) ); if ( ! $court || ! $court->is_active ) { return new WP_Error( 'invalid_court', __( 'Invalid or inactive court.', 'boardit' ) ); } if ( ! Boardit_DB::is_slot_available( $data['court_id'], $data['date'], $data['start_time'], $end_time ) ) { return new WP_Error( 'slot_taken', __( 'This time slot is already taken. Please choose another time.', 'boardit' ) ); } $max_advance = intval( get_option( 'boardit_max_advance_days', 14 ) ); if ( strtotime( $data['date'] ) > strtotime( "+{$max_advance} days" ) ) { return new WP_Error( 'too_far_ahead', sprintf( /* translators: %d: maximaal aantal dagen vooruit */ __( 'You can reserve at most %d days in advance.', 'boardit' ), $max_advance ) ); } // Partner-ID's valideren en max-check uitvoeren. $partner_ids_raw = $data['partner_ids'] ?? ''; $partner_ids_arr = array(); if ( ! empty( $partner_ids_raw ) ) { foreach ( explode( ',', $partner_ids_raw ) as $pid ) { $pid = intval( trim( $pid ) ); if ( $pid > 0 ) { $partner_ids_arr[] = $pid; } } } if ( $max_res > 0 && ! boardit_can_manage() ) { foreach ( $partner_ids_arr as $pid ) { $count = Boardit_DB::get_user_upcoming_count( $pid ); if ( $count >= $max_res ) { $partner_user = get_userdata( $pid ); $partner_name_str = $partner_user ? $partner_user->display_name : "ID:$pid"; return new WP_Error( 'partner_max_reservations', sprintf( /* translators: %s: naam van de meespeler */ __( '%s has reached the maximum number of reservations.', 'boardit' ), $partner_name_str ) ); } } } $partner_ids_str = implode( ',', $partner_ids_arr ); $insert = array( 'court_id' => intval( $data['court_id'] ), 'user_id' => intval( $data['user_id'] ), 'date' => sanitize_text_field( $data['date'] ), 'start_time' => sanitize_text_field( $data['start_time'] ), 'end_time' => $end_time, 'players_count' => $players_count, 'extra_15min' => $extra_15min, 'player_name' => sanitize_text_field( $data['player_name'] ?? '' ), 'partner_name' => sanitize_text_field( $data['partner_name'] ?? '' ), 'partner_ids' => $partner_ids_str, 'notes' => sanitize_textarea_field( $data['notes'] ?? '' ), 'status' => 'confirmed', ); /** * Kolommen voor de nieuwe reservering, vóór het opslaan (een uitbreiding kan eigen kolommen toevoegen). * * @param array $insert Kolommen en waarden. * @param array $data Gegevens van de reservering. */ $insert = (array) apply_filters( 'boardit_reservation_insert_data', $insert, $data ); $id = Boardit_DB::insert_reservation( $insert ); if ( ! $id ) { $db_err = Boardit_DB::get_last_db_error(); $msg = __( 'Reservation could not be saved.', 'boardit' ); if ( $db_err ) { $msg .= ' [DB: ' . $db_err . ']'; } return new WP_Error( 'db_error', $msg ); } do_action( 'boardit_reservation_created', $id, $insert ); return $id; } /** * Annuleer een reservering. * * @param int $reservation_id Reservering-ID. * @param int|null $user_id Alleen als deze gebruiker de boeker is (beheerders mogen altijd). * @return true|WP_Error */ public static function cancel( $reservation_id, $user_id = null ) { $reservation = Boardit_DB::get_reservation( $reservation_id ); if ( ! $reservation ) { return new WP_Error( 'not_found', __( 'Reservation not found.', 'boardit' ) ); } if ( 'confirmed' !== $reservation->status ) { return new WP_Error( 'already_cancelled', __( 'This reservation has already been cancelled.', 'boardit' ) ); } if ( $user_id && intval( $reservation->user_id ) !== intval( $user_id ) && ! current_user_can( 'manage_options' ) && ! boardit_can_manage() ) { return new WP_Error( 'not_allowed', __( 'You are not allowed to cancel this reservation.', 'boardit' ) ); } $result = Boardit_DB::cancel_reservation( $reservation_id, $user_id ); if ( false === $result ) { return new WP_Error( 'db_error', __( 'Cancellation failed.', 'boardit' ) ); } do_action( 'boardit_reservation_cancelled', $reservation_id, $reservation ); return true; } /** * Genereer 15-minuten tijdslots voor een dag. * Het raster is altijd 15 minuten per cel; de boekingsduur wordt * bepaald door het spelersaantal, niet door deze slotgrootte. * * @return array Array van slots met 'start', 'end' en 'label'. */ public static function generate_time_slots() { $open_time = get_option( 'boardit_open_time', '08:00' ); $close_time = get_option( 'boardit_close_time', '22:00' ); $slots = array(); $current = strtotime( $open_time ); $close = strtotime( $close_time ); $step = self::MINUTES_PER_PLAYER * 60; // 15 minuten in seconden while ( $current + $step <= $close ) { $slots[] = array( 'start' => gmdate( 'H:i', $current ), 'end' => gmdate( 'H:i', $current + $step ), 'label' => gmdate( 'H:i', $current ), ); $current += $step; } return $slots; } /** * De datum van vandaag (Y-m-d) in de tijdzone van de site. * * @return string */ public static function today() { return current_time( 'Y-m-d' ); } /** * Ligt het begin van een slot op of vóór het huidige moment, in de tijdzone van de site? * Geldt voor elke datum: alle slots op een eerdere dag liggen in het verleden. * * @param string $date Datum (Y-m-d). * @param string $time Begintijd (H:i of H:i:s). * @param int|null $now Unix-tijd om mee te vergelijken; standaard nu. * @return bool */ public static function is_past_slot( $date, $time, $now = null ) { $slot = DateTimeImmutable::createFromFormat( '!Y-m-d H:i', $date . ' ' . substr( (string) $time, 0, 5 ), wp_timezone() ); if ( ! $slot ) { return false; } return $slot->getTimestamp() <= ( null === $now ? time() : (int) $now ); } /** * De tijdslots van één dag, elk met 'past' (true als het slot al begonnen of voorbij is). * * @param string $date Datum (Y-m-d). * @param int|null $now Unix-tijd om mee te vergelijken; standaard nu. * @return array */ public static function time_slots_for_date( $date, $now = null ) { $slots = self::generate_time_slots(); foreach ( $slots as $i => $slot ) { $slots[ $i ]['past'] = self::is_past_slot( $date, $slot['start'], $now ); } return $slots; } /** * Is dit een geldige datum in de vorm Y-m-d? * * @param string $date Datum. * @return bool */ private static function is_valid_date( $date ) { $d = DateTime::createFromFormat( 'Y-m-d', $date ); return $d && $d->format( 'Y-m-d' ) === $date; } /** Ondersteunde sporttypes per baan, in vaste weergavevolgorde. */ const SPORT_TYPES = array( 'tennis', 'padel', 'pickleball' ); /** * Vertaalde weergavenaam per sporttype. */ public static function get_sport_names(): array { return array( 'tennis' => __( 'Tennis', 'boardit' ), 'padel' => __( 'Padel', 'boardit' ), 'pickleball' => __( 'Pickleball', 'boardit' ), ); } /** * Sporttypes van de banen die op het bord staan (get_bookable_courts()), * ontdubbeld en in de vaste volgorde van SPORT_TYPES. Onbekende types * worden genegeerd. Zonder banen: array( 'tennis' ). */ public static function get_sport_types(): array { $found = array(); foreach ( self::get_bookable_courts() as $court ) { $found[ $court->sport_type ?? 'tennis' ] = true; } $types = array_values( array_filter( self::SPORT_TYPES, fn( $t ) => isset( $found[ $t ] ) ) ); return $types ? $types : array( 'tennis' ); } /** * Label(s) voor de koptekst van het bord en het aanmeldscherm. * * Afgeleid van de sporttypes van de banen (vervangt de vroegere globale * instelling boardit_sport_type). Eén sport → "Tennis"; meerdere → * "Tennis · Padel". * * @return array{sport: string, sports: string[]} */ public static function get_sport_labels(): array { $names = self::get_sport_names(); $sports = array_map( fn( $t ) => $names[ $t ], self::get_sport_types() ); return array( 'sport' => implode( ' · ', $sports ), 'sports' => $sports, ); } }