Files
wp-bnb/src/PostTypes/Guest.php
magdev 13ba264431
All checks were successful
Create Release Package / build-release (push) Successful in 1m1s
Release v0.6.1 - Bug fixes and enhancements
New Features:
- Auto-update system with configurable check frequency
- Updates tab in settings with manual check button
- Localhost development mode bypasses license validation
- Extended general settings (address, contact, social media)
- Pricing settings split into subtabs
- Guest ID/passport encryption using AES-256-CBC
- Guest auto-creation from booking form

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 15:18:27 +01:00

1193 lines
36 KiB
PHP

<?php
/**
* Guest post type.
*
* Custom post type for BnB guests.
*
* @package Magdev\WpBnb\PostTypes
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\PostTypes;
/**
* Guest post type class.
*/
final class Guest {
/**
* Post type slug.
*
* @var string
*/
public const POST_TYPE = 'bnb_guest';
/**
* Meta key prefix.
*
* @var string
*/
private const META_PREFIX = '_bnb_guest_';
/**
* Encryption method.
*
* @var string
*/
private const ENCRYPTION_METHOD = 'aes-256-cbc';
/**
* Encrypt sensitive data.
*
* Uses WordPress AUTH_KEY for encryption key derivation.
*
* @param string $data Plain text data to encrypt.
* @return string Encrypted data (base64 encoded).
*/
private static function encrypt( string $data ): string {
if ( empty( $data ) ) {
return '';
}
$key = hash( 'sha256', AUTH_KEY . 'wp_bnb_guest_encryption', true );
$iv = openssl_random_pseudo_bytes( openssl_cipher_iv_length( self::ENCRYPTION_METHOD ) );
$encrypted = openssl_encrypt( $data, self::ENCRYPTION_METHOD, $key, 0, $iv );
if ( false === $encrypted ) {
return '';
}
// Store IV with encrypted data (IV is not secret, just needs to be unique).
return base64_encode( $iv . '::' . $encrypted );
}
/**
* Decrypt sensitive data.
*
* @param string $data Encrypted data (base64 encoded).
* @return string Decrypted plain text.
*/
private static function decrypt( string $data ): string {
if ( empty( $data ) ) {
return '';
}
$decoded = base64_decode( $data, true );
if ( false === $decoded ) {
// Data might be stored unencrypted (legacy), return as-is.
return $data;
}
$parts = explode( '::', $decoded, 2 );
if ( count( $parts ) !== 2 ) {
// Data might be stored unencrypted (legacy), return as-is.
return $data;
}
list( $iv, $encrypted ) = $parts;
$key = hash( 'sha256', AUTH_KEY . 'wp_bnb_guest_encryption', true );
$decrypted = openssl_decrypt( $encrypted, self::ENCRYPTION_METHOD, $key, 0, $iv );
if ( false === $decrypted ) {
// Decryption failed, might be legacy unencrypted data.
return $data;
}
return $decrypted;
}
/**
* Initialize the post type.
*
* @return void
*/
public static function init(): void {
add_action( 'init', array( self::class, 'register' ) );
add_action( 'add_meta_boxes', array( self::class, 'add_meta_boxes' ) );
add_action( 'save_post_' . self::POST_TYPE, array( self::class, 'save_meta' ), 10, 2 );
add_filter( 'manage_' . self::POST_TYPE . '_posts_columns', array( self::class, 'add_columns' ) );
add_action( 'manage_' . self::POST_TYPE . '_posts_custom_column', array( self::class, 'render_column' ), 10, 2 );
add_filter( 'manage_edit-' . self::POST_TYPE . '_sortable_columns', array( self::class, 'sortable_columns' ) );
add_action( 'restrict_manage_posts', array( self::class, 'add_filters' ) );
add_action( 'pre_get_posts', array( self::class, 'filter_query' ) );
add_filter( 'enter_title_here', array( self::class, 'change_title_placeholder' ), 10, 2 );
// Disable Gutenberg block editor for Guests - use classic editor for simpler UI.
add_filter( 'use_block_editor_for_post_type', array( self::class, 'disable_block_editor' ), 10, 2 );
}
/**
* Disable block editor for Guests post type.
*
* @param bool $use_block_editor Whether to use block editor.
* @param string $post_type Post type.
* @return bool
*/
public static function disable_block_editor( bool $use_block_editor, string $post_type ): bool {
if ( self::POST_TYPE === $post_type ) {
return false;
}
return $use_block_editor;
}
/**
* Register the post type.
*
* @return void
*/
public static function register(): void {
$labels = array(
'name' => _x( 'Guests', 'post type general name', 'wp-bnb' ),
'singular_name' => _x( 'Guest', 'post type singular name', 'wp-bnb' ),
'menu_name' => _x( 'Guests', 'admin menu', 'wp-bnb' ),
'name_admin_bar' => _x( 'Guest', 'add new on admin bar', 'wp-bnb' ),
'add_new' => _x( 'Add New', 'guest', 'wp-bnb' ),
'add_new_item' => __( 'Add New Guest', 'wp-bnb' ),
'new_item' => __( 'New Guest', 'wp-bnb' ),
'edit_item' => __( 'Edit Guest', 'wp-bnb' ),
'view_item' => __( 'View Guest', 'wp-bnb' ),
'all_items' => __( 'Guests', 'wp-bnb' ),
'search_items' => __( 'Search Guests', 'wp-bnb' ),
'parent_item_colon' => __( 'Parent Guests:', 'wp-bnb' ),
'not_found' => __( 'No guests found.', 'wp-bnb' ),
'not_found_in_trash' => __( 'No guests found in Trash.', 'wp-bnb' ),
'archives' => __( 'Guest archives', 'wp-bnb' ),
'insert_into_item' => __( 'Insert into guest', 'wp-bnb' ),
'uploaded_to_this_item' => __( 'Uploaded to this guest', 'wp-bnb' ),
'filter_items_list' => __( 'Filter guests list', 'wp-bnb' ),
'items_list_navigation' => __( 'Guests list navigation', 'wp-bnb' ),
'items_list' => __( 'Guests list', 'wp-bnb' ),
);
$args = array(
'labels' => $labels,
'public' => false,
'publicly_queryable' => false,
'show_ui' => true,
'show_in_menu' => 'wp-bnb',
'query_var' => false,
'rewrite' => false,
'capability_type' => 'post',
'has_archive' => false,
'hierarchical' => false,
'menu_position' => null,
'menu_icon' => 'dashicons-groups',
'supports' => array(
'title',
'revisions',
),
'show_in_rest' => true,
'rest_base' => 'guests',
'rest_controller_class' => 'WP_REST_Posts_Controller',
);
register_post_type( self::POST_TYPE, $args );
}
/**
* Add meta boxes.
*
* @return void
*/
public static function add_meta_boxes(): void {
add_meta_box(
'bnb_guest_personal',
__( 'Personal Information', 'wp-bnb' ),
array( self::class, 'render_personal_meta_box' ),
self::POST_TYPE,
'normal',
'high'
);
add_meta_box(
'bnb_guest_address',
__( 'Address', 'wp-bnb' ),
array( self::class, 'render_address_meta_box' ),
self::POST_TYPE,
'normal',
'high'
);
add_meta_box(
'bnb_guest_identification',
__( 'Identification', 'wp-bnb' ),
array( self::class, 'render_identification_meta_box' ),
self::POST_TYPE,
'normal',
'default'
);
add_meta_box(
'bnb_guest_consent',
__( 'Consent & Privacy', 'wp-bnb' ),
array( self::class, 'render_consent_meta_box' ),
self::POST_TYPE,
'side',
'high'
);
add_meta_box(
'bnb_guest_bookings',
__( 'Booking History', 'wp-bnb' ),
array( self::class, 'render_bookings_meta_box' ),
self::POST_TYPE,
'normal',
'default'
);
add_meta_box(
'bnb_guest_status',
__( 'Status & Notes', 'wp-bnb' ),
array( self::class, 'render_status_meta_box' ),
self::POST_TYPE,
'side',
'default'
);
}
/**
* Render personal information meta box.
*
* @param \WP_Post $post Current post object.
* @return void
*/
public static function render_personal_meta_box( \WP_Post $post ): void {
wp_nonce_field( 'bnb_guest_meta', 'bnb_guest_meta_nonce' );
$first_name = get_post_meta( $post->ID, self::META_PREFIX . 'first_name', true );
$last_name = get_post_meta( $post->ID, self::META_PREFIX . 'last_name', true );
$email = get_post_meta( $post->ID, self::META_PREFIX . 'email', true );
$phone = get_post_meta( $post->ID, self::META_PREFIX . 'phone', true );
$date_of_birth = get_post_meta( $post->ID, self::META_PREFIX . 'date_of_birth', true );
$nationality = get_post_meta( $post->ID, self::META_PREFIX . 'nationality', true );
?>
<table class="form-table">
<tr>
<th scope="row">
<label for="bnb_guest_first_name"><?php esc_html_e( 'First Name', 'wp-bnb' ); ?> <span class="required">*</span></label>
</th>
<td>
<input type="text" id="bnb_guest_first_name" name="bnb_guest_first_name"
value="<?php echo esc_attr( $first_name ); ?>" class="regular-text" required>
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_guest_last_name"><?php esc_html_e( 'Last Name', 'wp-bnb' ); ?> <span class="required">*</span></label>
</th>
<td>
<input type="text" id="bnb_guest_last_name" name="bnb_guest_last_name"
value="<?php echo esc_attr( $last_name ); ?>" class="regular-text" required>
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_guest_email"><?php esc_html_e( 'Email', 'wp-bnb' ); ?> <span class="required">*</span></label>
</th>
<td>
<input type="email" id="bnb_guest_email" name="bnb_guest_email"
value="<?php echo esc_attr( $email ); ?>" class="regular-text" required>
<p class="description"><?php esc_html_e( 'Used as unique identifier for the guest.', 'wp-bnb' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_guest_phone"><?php esc_html_e( 'Phone', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="tel" id="bnb_guest_phone" name="bnb_guest_phone"
value="<?php echo esc_attr( $phone ); ?>" class="regular-text">
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_guest_date_of_birth"><?php esc_html_e( 'Date of Birth', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="date" id="bnb_guest_date_of_birth" name="bnb_guest_date_of_birth"
value="<?php echo esc_attr( $date_of_birth ); ?>">
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_guest_nationality"><?php esc_html_e( 'Nationality', 'wp-bnb' ); ?></label>
</th>
<td>
<select id="bnb_guest_nationality" name="bnb_guest_nationality">
<option value=""><?php esc_html_e( '— Select Nationality —', 'wp-bnb' ); ?></option>
<?php foreach ( Building::get_countries() as $code => $name ) : ?>
<option value="<?php echo esc_attr( $code ); ?>" <?php selected( $nationality, $code ); ?>>
<?php echo esc_html( $name ); ?>
</option>
<?php endforeach; ?>
</select>
</td>
</tr>
</table>
<?php
}
/**
* Render address meta box.
*
* @param \WP_Post $post Current post object.
* @return void
*/
public static function render_address_meta_box( \WP_Post $post ): void {
$street = get_post_meta( $post->ID, self::META_PREFIX . 'street', true );
$city = get_post_meta( $post->ID, self::META_PREFIX . 'city', true );
$postal_code = get_post_meta( $post->ID, self::META_PREFIX . 'postal_code', true );
$country = get_post_meta( $post->ID, self::META_PREFIX . 'country', true );
?>
<table class="form-table">
<tr>
<th scope="row">
<label for="bnb_guest_street"><?php esc_html_e( 'Street Address', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="text" id="bnb_guest_street" name="bnb_guest_street"
value="<?php echo esc_attr( $street ); ?>" class="large-text">
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_guest_city"><?php esc_html_e( 'City', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="text" id="bnb_guest_city" name="bnb_guest_city"
value="<?php echo esc_attr( $city ); ?>" class="regular-text">
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_guest_postal_code"><?php esc_html_e( 'ZIP / Postal Code', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="text" id="bnb_guest_postal_code" name="bnb_guest_postal_code"
value="<?php echo esc_attr( $postal_code ); ?>" class="regular-text">
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_guest_country"><?php esc_html_e( 'Country', 'wp-bnb' ); ?></label>
</th>
<td>
<select id="bnb_guest_country" name="bnb_guest_country">
<option value=""><?php esc_html_e( '— Select Country —', 'wp-bnb' ); ?></option>
<?php foreach ( Building::get_countries() as $code => $name ) : ?>
<option value="<?php echo esc_attr( $code ); ?>" <?php selected( $country, $code ); ?>>
<?php echo esc_html( $name ); ?>
</option>
<?php endforeach; ?>
</select>
</td>
</tr>
</table>
<?php
}
/**
* Render identification meta box.
*
* @param \WP_Post $post Current post object.
* @return void
*/
public static function render_identification_meta_box( \WP_Post $post ): void {
$id_type = get_post_meta( $post->ID, self::META_PREFIX . 'id_type', true );
$id_number = self::decrypt( get_post_meta( $post->ID, self::META_PREFIX . 'id_number', true ) );
$id_expiry = get_post_meta( $post->ID, self::META_PREFIX . 'id_expiry', true );
?>
<p class="description" style="margin-bottom: 15px;">
<span class="dashicons dashicons-shield" style="color: #00a32a;"></span>
<?php esc_html_e( 'This information is encrypted and stored securely.', 'wp-bnb' ); ?>
</p>
<table class="form-table">
<tr>
<th scope="row">
<label for="bnb_guest_id_type"><?php esc_html_e( 'ID Type', 'wp-bnb' ); ?></label>
</th>
<td>
<select id="bnb_guest_id_type" name="bnb_guest_id_type">
<option value=""><?php esc_html_e( '— Select ID Type —', 'wp-bnb' ); ?></option>
<?php foreach ( self::get_id_types() as $type => $label ) : ?>
<option value="<?php echo esc_attr( $type ); ?>" <?php selected( $id_type, $type ); ?>>
<?php echo esc_html( $label ); ?>
</option>
<?php endforeach; ?>
</select>
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_guest_id_number"><?php esc_html_e( 'ID Number', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="text" id="bnb_guest_id_number" name="bnb_guest_id_number"
value="<?php echo esc_attr( $id_number ); ?>" class="regular-text">
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_guest_id_expiry"><?php esc_html_e( 'Expiry Date', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="date" id="bnb_guest_id_expiry" name="bnb_guest_id_expiry"
value="<?php echo esc_attr( $id_expiry ); ?>">
</td>
</tr>
</table>
<?php
}
/**
* Render consent & privacy meta box.
*
* @param \WP_Post $post Current post object.
* @return void
*/
public static function render_consent_meta_box( \WP_Post $post ): void {
$consent_marketing = get_post_meta( $post->ID, self::META_PREFIX . 'consent_marketing', true );
$consent_data = get_post_meta( $post->ID, self::META_PREFIX . 'consent_data', true );
$consent_date = get_post_meta( $post->ID, self::META_PREFIX . 'consent_date', true );
?>
<p>
<label>
<input type="checkbox" name="bnb_guest_consent_data" value="1"
<?php checked( $consent_data, '1' ); ?>>
<?php esc_html_e( 'Data Processing Consent', 'wp-bnb' ); ?>
</label>
</p>
<p class="description" style="margin-left: 24px;">
<?php esc_html_e( 'Guest consents to processing personal data for booking purposes.', 'wp-bnb' ); ?>
</p>
<p style="margin-top: 15px;">
<label>
<input type="checkbox" name="bnb_guest_consent_marketing" value="1"
<?php checked( $consent_marketing, '1' ); ?>>
<?php esc_html_e( 'Marketing Consent', 'wp-bnb' ); ?>
</label>
</p>
<p class="description" style="margin-left: 24px;">
<?php esc_html_e( 'Guest consents to receive marketing communications.', 'wp-bnb' ); ?>
</p>
<?php if ( $consent_date ) : ?>
<p style="margin-top: 15px; padding-top: 10px; border-top: 1px solid #ddd;">
<strong><?php esc_html_e( 'Consent recorded:', 'wp-bnb' ); ?></strong><br>
<?php echo esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), strtotime( $consent_date ) ) ); ?>
</p>
<?php endif; ?>
<?php
}
/**
* Render booking history meta box.
*
* @param \WP_Post $post Current post object.
* @return void
*/
public static function render_bookings_meta_box( \WP_Post $post ): void {
$bookings = self::get_bookings( $post->ID );
$total_spent = self::get_total_spent( $post->ID );
$booking_count = count( $bookings );
if ( 0 === $booking_count ) {
echo '<p>' . esc_html__( 'No bookings found for this guest.', 'wp-bnb' ) . '</p>';
return;
}
?>
<div class="bnb-guest-stats" style="display: flex; gap: 20px; margin-bottom: 15px; padding: 10px; background: #f9f9f9; border-radius: 4px;">
<div>
<strong><?php esc_html_e( 'Total Bookings:', 'wp-bnb' ); ?></strong>
<?php echo esc_html( $booking_count ); ?>
</div>
<div>
<strong><?php esc_html_e( 'Total Spent:', 'wp-bnb' ); ?></strong>
<?php echo esc_html( number_format( $total_spent, 2 ) ); ?> <?php echo esc_html( get_option( 'wp_bnb_currency', 'CHF' ) ); ?>
</div>
</div>
<table class="widefat striped">
<thead>
<tr>
<th><?php esc_html_e( 'Reference', 'wp-bnb' ); ?></th>
<th><?php esc_html_e( 'Room', 'wp-bnb' ); ?></th>
<th><?php esc_html_e( 'Dates', 'wp-bnb' ); ?></th>
<th><?php esc_html_e( 'Price', 'wp-bnb' ); ?></th>
<th><?php esc_html_e( 'Status', 'wp-bnb' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( array_slice( $bookings, 0, 10 ) as $booking ) : ?>
<?php
$reference = get_post_meta( $booking->ID, '_bnb_booking_reference', true );
$room_id = get_post_meta( $booking->ID, '_bnb_booking_room_id', true );
$room = get_post( $room_id );
$check_in = get_post_meta( $booking->ID, '_bnb_booking_check_in', true );
$check_out = get_post_meta( $booking->ID, '_bnb_booking_check_out', true );
$price = get_post_meta( $booking->ID, '_bnb_booking_calculated_price', true );
$status = get_post_meta( $booking->ID, '_bnb_booking_status', true );
$statuses = Booking::get_booking_statuses();
$colors = Booking::get_status_colors();
?>
<tr>
<td>
<a href="<?php echo esc_url( get_edit_post_link( $booking->ID ) ); ?>">
<?php echo esc_html( $reference ?: '#' . $booking->ID ); ?>
</a>
</td>
<td><?php echo esc_html( $room ? $room->post_title : '—' ); ?></td>
<td>
<?php
if ( $check_in && $check_out ) {
echo esc_html( wp_date( 'M j', strtotime( $check_in ) ) );
echo ' - ';
echo esc_html( wp_date( 'M j, Y', strtotime( $check_out ) ) );
} else {
echo '—';
}
?>
</td>
<td>
<?php
if ( $price ) {
echo esc_html( number_format( (float) $price, 2 ) );
} else {
echo '—';
}
?>
</td>
<td>
<span class="bnb-status-badge" style="background-color: <?php echo esc_attr( $colors[ $status ] ?? '#999' ); ?>; color: #fff; padding: 2px 8px; border-radius: 3px; font-size: 11px;">
<?php echo esc_html( $statuses[ $status ] ?? $status ); ?>
</span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if ( $booking_count > 10 ) : ?>
<p style="margin-top: 10px;">
<a href="<?php echo esc_url( admin_url( 'edit.php?post_type=' . Booking::POST_TYPE . '&guest_id=' . $post->ID ) ); ?>">
<?php
printf(
/* translators: %d: Number of additional bookings */
esc_html__( 'View all %d bookings', 'wp-bnb' ),
$booking_count
);
?>
</a>
</p>
<?php endif; ?>
<?php
}
/**
* Render status & notes meta box.
*
* @param \WP_Post $post Current post object.
* @return void
*/
public static function render_status_meta_box( \WP_Post $post ): void {
$status = get_post_meta( $post->ID, self::META_PREFIX . 'status', true ) ?: 'active';
$notes = get_post_meta( $post->ID, self::META_PREFIX . 'notes', true );
$preferences = get_post_meta( $post->ID, self::META_PREFIX . 'preferences', true );
?>
<p>
<label for="bnb_guest_status"><strong><?php esc_html_e( 'Status', 'wp-bnb' ); ?></strong></label>
<select id="bnb_guest_status" name="bnb_guest_status" class="widefat" style="margin-top: 5px;">
<?php foreach ( self::get_statuses() as $key => $label ) : ?>
<option value="<?php echo esc_attr( $key ); ?>" <?php selected( $status, $key ); ?>>
<?php echo esc_html( $label ); ?>
</option>
<?php endforeach; ?>
</select>
</p>
<p style="margin-top: 15px;">
<label for="bnb_guest_preferences"><strong><?php esc_html_e( 'Guest Preferences', 'wp-bnb' ); ?></strong></label>
<textarea id="bnb_guest_preferences" name="bnb_guest_preferences" class="widefat" rows="3"
style="margin-top: 5px;"><?php echo esc_textarea( $preferences ); ?></textarea>
<span class="description"><?php esc_html_e( 'Room preferences, dietary requirements, etc.', 'wp-bnb' ); ?></span>
</p>
<p style="margin-top: 15px;">
<label for="bnb_guest_notes"><strong><?php esc_html_e( 'Internal Notes', 'wp-bnb' ); ?></strong></label>
<textarea id="bnb_guest_notes" name="bnb_guest_notes" class="widefat" rows="3"
style="margin-top: 5px;"><?php echo esc_textarea( $notes ); ?></textarea>
<span class="description"><?php esc_html_e( 'Private notes (not shared with guest).', 'wp-bnb' ); ?></span>
</p>
<?php
}
/**
* Save post meta.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @return void
*/
public static function save_meta( int $post_id, \WP_Post $post ): void {
// Verify nonce.
if ( ! isset( $_POST['bnb_guest_meta_nonce'] ) ||
! wp_verify_nonce( sanitize_key( $_POST['bnb_guest_meta_nonce'] ), 'bnb_guest_meta' ) ) {
return;
}
// Check autosave.
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
// Check permissions.
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
// Text fields (non-sensitive).
$text_fields = array(
'first_name',
'last_name',
'phone',
'street',
'city',
'postal_code',
'country',
'nationality',
'id_type',
'status',
);
foreach ( $text_fields as $field ) {
$key = 'bnb_guest_' . $field;
if ( isset( $_POST[ $key ] ) ) {
update_post_meta(
$post_id,
self::META_PREFIX . $field,
sanitize_text_field( wp_unslash( $_POST[ $key ] ) )
);
}
}
// Sensitive field: ID number (encrypted).
if ( isset( $_POST['bnb_guest_id_number'] ) ) {
$id_number = sanitize_text_field( wp_unslash( $_POST['bnb_guest_id_number'] ) );
update_post_meta(
$post_id,
self::META_PREFIX . 'id_number',
self::encrypt( $id_number )
);
}
// Email field (special sanitization).
if ( isset( $_POST['bnb_guest_email'] ) ) {
update_post_meta(
$post_id,
self::META_PREFIX . 'email',
sanitize_email( wp_unslash( $_POST['bnb_guest_email'] ) )
);
}
// Date fields.
$date_fields = array( 'date_of_birth', 'id_expiry' );
foreach ( $date_fields as $field ) {
$key = 'bnb_guest_' . $field;
if ( isset( $_POST[ $key ] ) ) {
$date = sanitize_text_field( wp_unslash( $_POST[ $key ] ) );
if ( empty( $date ) || preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) {
update_post_meta( $post_id, self::META_PREFIX . $field, $date );
}
}
}
// Textarea fields.
$textarea_fields = array( 'notes', 'preferences' );
foreach ( $textarea_fields as $field ) {
$key = 'bnb_guest_' . $field;
if ( isset( $_POST[ $key ] ) ) {
update_post_meta(
$post_id,
self::META_PREFIX . $field,
sanitize_textarea_field( wp_unslash( $_POST[ $key ] ) )
);
}
}
// Consent checkboxes.
$consent_fields = array( 'consent_marketing', 'consent_data' );
$consent_changed = false;
foreach ( $consent_fields as $field ) {
$key = 'bnb_guest_' . $field;
$old_value = get_post_meta( $post_id, self::META_PREFIX . $field, true );
$new_value = isset( $_POST[ $key ] ) ? '1' : '';
if ( $old_value !== $new_value ) {
$consent_changed = true;
}
update_post_meta( $post_id, self::META_PREFIX . $field, $new_value );
}
// Update consent date if consent changed.
if ( $consent_changed ) {
update_post_meta( $post_id, self::META_PREFIX . 'consent_date', current_time( 'mysql' ) );
}
// Auto-update post title from name fields.
$first_name = get_post_meta( $post_id, self::META_PREFIX . 'first_name', true );
$last_name = get_post_meta( $post_id, self::META_PREFIX . 'last_name', true );
if ( $first_name || $last_name ) {
$full_name = trim( $first_name . ' ' . $last_name );
if ( $post->post_title !== $full_name ) {
remove_action( 'save_post_' . self::POST_TYPE, array( self::class, 'save_meta' ), 10 );
wp_update_post(
array(
'ID' => $post_id,
'post_title' => $full_name,
)
);
add_action( 'save_post_' . self::POST_TYPE, array( self::class, 'save_meta' ), 10, 2 );
}
}
}
/**
* Add custom columns to the post list.
*
* @param array $columns Existing columns.
* @return array
*/
public static function add_columns( array $columns ): array {
$new_columns = array();
foreach ( $columns as $key => $value ) {
$new_columns[ $key ] = $value;
if ( 'title' === $key ) {
$new_columns['email'] = __( 'Email', 'wp-bnb' );
$new_columns['phone'] = __( 'Phone', 'wp-bnb' );
$new_columns['country'] = __( 'Country', 'wp-bnb' );
$new_columns['bookings'] = __( 'Bookings', 'wp-bnb' );
$new_columns['status'] = __( 'Status', 'wp-bnb' );
}
}
// Remove default date column.
unset( $new_columns['date'] );
return $new_columns;
}
/**
* Render custom column content.
*
* @param string $column Column name.
* @param int $post_id Post ID.
* @return void
*/
public static function render_column( string $column, int $post_id ): void {
switch ( $column ) {
case 'email':
$email = get_post_meta( $post_id, self::META_PREFIX . 'email', true );
if ( $email ) {
printf(
'<a href="mailto:%s">%s</a>',
esc_attr( $email ),
esc_html( $email )
);
} else {
echo '—';
}
break;
case 'phone':
$phone = get_post_meta( $post_id, self::META_PREFIX . 'phone', true );
echo esc_html( $phone ?: '—' );
break;
case 'country':
$country = get_post_meta( $post_id, self::META_PREFIX . 'country', true );
if ( $country ) {
$countries = Building::get_countries();
echo esc_html( $countries[ $country ] ?? $country );
} else {
echo '—';
}
break;
case 'bookings':
$count = self::get_booking_count( $post_id );
if ( $count > 0 ) {
printf(
'<a href="%s">%s</a>',
esc_url( admin_url( 'edit.php?post_type=' . Booking::POST_TYPE . '&guest_id=' . $post_id ) ),
esc_html(
sprintf(
/* translators: %d: Number of bookings */
_n( '%d booking', '%d bookings', $count, 'wp-bnb' ),
$count
)
)
);
} else {
echo '—';
}
break;
case 'status':
$status = get_post_meta( $post_id, self::META_PREFIX . 'status', true ) ?: 'active';
$colors = self::get_status_colors();
$statuses = self::get_statuses();
printf(
'<span class="bnb-status-badge" style="background-color: %s; color: #fff; padding: 2px 8px; border-radius: 3px; font-size: 11px;">%s</span>',
esc_attr( $colors[ $status ] ?? '#999' ),
esc_html( $statuses[ $status ] ?? $status )
);
break;
}
}
/**
* Add sortable columns.
*
* @param array $columns Existing sortable columns.
* @return array
*/
public static function sortable_columns( array $columns ): array {
$columns['email'] = 'email';
$columns['country'] = 'country';
$columns['status'] = 'status';
return $columns;
}
/**
* Add filter dropdowns to admin list.
*
* @param string $post_type Current post type.
* @return void
*/
public static function add_filters( string $post_type ): void {
if ( self::POST_TYPE !== $post_type ) {
return;
}
// Status filter.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter display only.
$selected_status = isset( $_GET['guest_status'] ) ? sanitize_text_field( wp_unslash( $_GET['guest_status'] ) ) : '';
?>
<select name="guest_status">
<option value=""><?php esc_html_e( 'All Statuses', 'wp-bnb' ); ?></option>
<?php foreach ( self::get_statuses() as $key => $label ) : ?>
<option value="<?php echo esc_attr( $key ); ?>" <?php selected( $selected_status, $key ); ?>>
<?php echo esc_html( $label ); ?>
</option>
<?php endforeach; ?>
</select>
<?php
// Country filter.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter display only.
$selected_country = isset( $_GET['guest_country'] ) ? sanitize_text_field( wp_unslash( $_GET['guest_country'] ) ) : '';
?>
<select name="guest_country">
<option value=""><?php esc_html_e( 'All Countries', 'wp-bnb' ); ?></option>
<?php foreach ( Building::get_countries() as $code => $name ) : ?>
<option value="<?php echo esc_attr( $code ); ?>" <?php selected( $selected_country, $code ); ?>>
<?php echo esc_html( $name ); ?>
</option>
<?php endforeach; ?>
</select>
<?php
}
/**
* Filter query based on filters.
*
* @param \WP_Query $query The query object.
* @return void
*/
public static function filter_query( \WP_Query $query ): void {
if ( ! is_admin() || ! $query->is_main_query() ) {
return;
}
if ( self::POST_TYPE !== $query->get( 'post_type' ) ) {
return;
}
$meta_query = array();
// Status filter.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter query only.
if ( ! empty( $_GET['guest_status'] ) ) {
$meta_query[] = array(
'key' => self::META_PREFIX . 'status',
'value' => sanitize_text_field( wp_unslash( $_GET['guest_status'] ) ),
);
}
// Country filter.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter query only.
if ( ! empty( $_GET['guest_country'] ) ) {
$meta_query[] = array(
'key' => self::META_PREFIX . 'country',
'value' => sanitize_text_field( wp_unslash( $_GET['guest_country'] ) ),
);
}
if ( ! empty( $meta_query ) ) {
$meta_query['relation'] = 'AND';
$query->set( 'meta_query', $meta_query );
}
// Handle orderby for custom columns.
$orderby = $query->get( 'orderby' );
if ( 'email' === $orderby ) {
$query->set( 'meta_key', self::META_PREFIX . 'email' );
$query->set( 'orderby', 'meta_value' );
} elseif ( 'country' === $orderby ) {
$query->set( 'meta_key', self::META_PREFIX . 'country' );
$query->set( 'orderby', 'meta_value' );
} elseif ( 'status' === $orderby ) {
$query->set( 'meta_key', self::META_PREFIX . 'status' );
$query->set( 'orderby', 'meta_value' );
}
}
/**
* Change title placeholder.
*
* @param string $placeholder Default placeholder.
* @param \WP_Post $post Current post.
* @return string
*/
public static function change_title_placeholder( string $placeholder, \WP_Post $post ): string {
if ( self::POST_TYPE === $post->post_type ) {
return __( 'Guest name (auto-generated from first/last name)', 'wp-bnb' );
}
return $placeholder;
}
/**
* Get guest statuses.
*
* @return array<string, string>
*/
public static function get_statuses(): array {
return array(
'active' => __( 'Active', 'wp-bnb' ),
'inactive' => __( 'Inactive', 'wp-bnb' ),
'blocked' => __( 'Blocked', 'wp-bnb' ),
);
}
/**
* Get status colors.
*
* @return array<string, string>
*/
public static function get_status_colors(): array {
return array(
'active' => '#00a32a',
'inactive' => '#72aee6',
'blocked' => '#d63638',
);
}
/**
* Get ID types.
*
* @return array<string, string>
*/
public static function get_id_types(): array {
return array(
'passport' => __( 'Passport', 'wp-bnb' ),
'id_card' => __( 'ID Card', 'wp-bnb' ),
'drivers_license' => __( "Driver's License", 'wp-bnb' ),
'other' => __( 'Other', 'wp-bnb' ),
);
}
/**
* Get guest by email.
*
* @param string $email Email address to search for.
* @return \WP_Post|null Guest post or null if not found.
*/
public static function get_by_email( string $email ): ?\WP_Post {
if ( empty( $email ) ) {
return null;
}
$guests = get_posts(
array(
'post_type' => self::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => 1,
'meta_query' => array(
array(
'key' => self::META_PREFIX . 'email',
'value' => $email,
),
),
)
);
return ! empty( $guests ) ? $guests[0] : null;
}
/**
* Get all bookings for a guest.
*
* @param int $guest_id Guest post ID.
* @return array Array of booking posts.
*/
public static function get_bookings( int $guest_id ): array {
$email = get_post_meta( $guest_id, self::META_PREFIX . 'email', true );
if ( empty( $email ) ) {
return array();
}
// First try to find bookings by guest_id (new way).
$bookings_by_id = get_posts(
array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'meta_query' => array(
array(
'key' => '_bnb_booking_guest_id',
'value' => $guest_id,
),
),
)
);
// Also find bookings by email (legacy way).
$bookings_by_email = get_posts(
array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'meta_query' => array(
array(
'key' => '_bnb_booking_guest_email',
'value' => $email,
),
),
)
);
// Merge and deduplicate.
$all_bookings = array_merge( $bookings_by_id, $bookings_by_email );
$unique_ids = array();
$result = array();
foreach ( $all_bookings as $booking ) {
if ( ! in_array( $booking->ID, $unique_ids, true ) ) {
$unique_ids[] = $booking->ID;
$result[] = $booking;
}
}
// Sort by check-in date descending.
usort(
$result,
function ( $a, $b ) {
$date_a = get_post_meta( $a->ID, '_bnb_booking_check_in', true );
$date_b = get_post_meta( $b->ID, '_bnb_booking_check_in', true );
return strcmp( $date_b, $date_a );
}
);
return $result;
}
/**
* Get booking count for a guest.
*
* @param int $guest_id Guest post ID.
* @return int Number of bookings.
*/
public static function get_booking_count( int $guest_id ): int {
return count( self::get_bookings( $guest_id ) );
}
/**
* Get total spent by a guest.
*
* @param int $guest_id Guest post ID.
* @return float Total amount spent.
*/
public static function get_total_spent( int $guest_id ): float {
$bookings = self::get_bookings( $guest_id );
$total = 0.0;
foreach ( $bookings as $booking ) {
$status = get_post_meta( $booking->ID, '_bnb_booking_status', true );
// Only count completed bookings (checked_out) or confirmed ones.
if ( in_array( $status, array( 'confirmed', 'checked_in', 'checked_out' ), true ) ) {
$price = get_post_meta( $booking->ID, '_bnb_booking_calculated_price', true );
$total += floatval( $price );
}
}
return $total;
}
/**
* Get formatted address for a guest.
*
* @param int $guest_id Guest post ID.
* @return string Formatted address.
*/
public static function get_formatted_address( int $guest_id ): string {
$street = get_post_meta( $guest_id, self::META_PREFIX . 'street', true );
$city = get_post_meta( $guest_id, self::META_PREFIX . 'city', true );
$postal_code = get_post_meta( $guest_id, self::META_PREFIX . 'postal_code', true );
$country = get_post_meta( $guest_id, self::META_PREFIX . 'country', true );
$parts = array();
if ( $street ) {
$parts[] = $street;
}
if ( $postal_code || $city ) {
$parts[] = trim( $postal_code . ' ' . $city );
}
if ( $country ) {
$countries = Building::get_countries();
$parts[] = $countries[ $country ] ?? $country;
}
return implode( "\n", $parts );
}
/**
* Get guest's full name.
*
* @param int $guest_id Guest post ID.
* @return string Full name.
*/
public static function get_full_name( int $guest_id ): string {
$first_name = get_post_meta( $guest_id, self::META_PREFIX . 'first_name', true );
$last_name = get_post_meta( $guest_id, self::META_PREFIX . 'last_name', true );
return trim( $first_name . ' ' . $last_name );
}
/**
* Get guest's ID number (decrypted).
*
* @param int $guest_id Guest post ID.
* @return string Decrypted ID number.
*/
public static function get_id_number( int $guest_id ): string {
$encrypted = get_post_meta( $guest_id, self::META_PREFIX . 'id_number', true );
return self::decrypt( $encrypted );
}
}