>> */ public static function schema(): array { $currency = self::default_currency(); return array( 'general' => array( 'business_name' => array( 'type' => 'text', 'default' => get_bloginfo( 'name' ), ), 'business_email' => array( 'type' => 'email', 'default' => get_option( 'admin_email' ), ), 'business_phone' => array( 'type' => 'text', 'default' => '', ), 'whatsapp_number' => array( 'type' => 'phone', 'default' => '', ), 'vehicle_types' => array( 'type' => 'multicheck', 'default' => array( 'motorcycle', 'car' ), 'choices' => self::VEHICLE_TYPES, ), 'default_rate_unit' => array( 'type' => 'select', 'default' => 'day', 'choices' => array( 'hour', 'day' ), ), ), 'format' => array( 'currency' => array( 'type' => 'select', 'default' => $currency, 'choices' => array_keys( Currencies::all() ), ), 'currency_position' => array( 'type' => 'select', 'default' => 'left', 'choices' => array( 'left', 'right', 'left_space', 'right_space' ), ), 'thousand_separator' => array( 'type' => 'separator', 'default' => ',', ), 'decimal_separator' => array( 'type' => 'separator', 'default' => '.', ), 'decimals' => array( 'type' => 'int', 'default' => Currencies::get( $currency )['decimals'], 'min' => 0, 'max' => 4, ), 'time_format' => array( 'type' => 'select', 'default' => 'site', 'choices' => array( 'site', '24', '12' ), ), 'week_start' => array( 'type' => 'select', 'default' => 'site', 'choices' => array( 'site', '0', '1', '2', '3', '4', '5', '6' ), ), 'distance_unit' => array( 'type' => 'select', 'default' => 'km', 'choices' => array( 'km', 'mi' ), ), 'fuel_unit' => array( 'type' => 'select', 'default' => 'l', 'choices' => array( 'l', 'gal' ), ), ), 'rules' => array( 'day_mode' => array( 'type' => 'select', 'default' => '24h', 'choices' => array( '24h', 'calendar' ), ), 'grace_minutes' => array( 'type' => 'int', 'default' => 60, 'min' => 0, 'max' => 240, ), 'min_hours' => array( 'type' => 'int', 'default' => 1, 'min' => 1, 'max' => 720, ), 'max_days' => array( 'type' => 'int', 'default' => 0, 'min' => 0, 'max' => 365, ), 'buffer_minutes' => array( 'type' => 'int', 'default' => 0, 'min' => 0, 'max' => 1440, ), 'tax_mode' => array( 'type' => 'select', 'default' => 'none', 'choices' => array( 'none', 'exclusive', 'inclusive' ), ), 'tax_rate' => array( 'type' => 'decimal', 'default' => 0, 'min' => 0, 'max' => 100, ), 'deposit_mode' => array( 'type' => 'select', 'default' => 'fixed', 'choices' => array( 'fixed', 'percent' ), ), 'default_deposit' => array( 'type' => 'money', 'default' => 0, ), 'deposit_percent' => array( 'type' => 'decimal', 'default' => 0, 'min' => 0, 'max' => 100, ), ), 'payments' => array( 'methods' => array( 'type' => 'multicheck', 'default' => array( 'pay_at_pickup' ), 'choices' => array( 'pay_at_pickup', 'bank_transfer', 'qris', 'woocommerce' ), ), 'bank_details' => array( 'type' => 'textarea', 'default' => '', ), 'qris_image' => array( 'type' => 'media', 'default' => 0, ), 'instructions' => array( 'type' => 'textarea', 'default' => '', ), 'online_confirm' => array( 'type' => 'checkbox', 'default' => true, ), ), 'display' => array( 'style_mode' => array( 'type' => 'select', 'default' => 'rentbaik', 'choices' => array( 'rentbaik', 'minimal', 'theme' ), ), 'primary_color' => array( 'type' => 'color', 'default' => '#1f6feb', ), 'border_radius' => array( 'type' => 'int', 'default' => 8, 'min' => 0, 'max' => 32, ), ), 'notifications' => array( 'admin_recipients' => array( 'type' => 'emails', 'default' => get_option( 'admin_email' ), ), 'from_name' => array( 'type' => 'text', 'default' => get_bloginfo( 'name' ), ), 'from_email' => array( 'type' => 'email', 'default' => get_option( 'admin_email' ), ), 'whatsapp_booking' => array( 'type' => 'checkbox', 'default' => true, ), 'email_admin_new' => array( 'type' => 'checkbox', 'default' => true, ), 'email_received' => array( 'type' => 'checkbox', 'default' => true, ), 'email_confirmed' => array( 'type' => 'checkbox', 'default' => true, ), 'email_pickup' => array( 'type' => 'checkbox', 'default' => true, ), 'email_return' => array( 'type' => 'checkbox', 'default' => true, ), 'email_thanks' => array( 'type' => 'checkbox', 'default' => true, ), 'reminder_hours' => array( 'type' => 'int', 'default' => 24, 'min' => 1, 'max' => 72, ), ), 'advanced' => array( 'booking_prefix' => array( 'type' => 'slug', 'default' => 'RB-', ), 'hold_minutes' => array( 'type' => 'int', 'default' => 15, 'min' => 5, 'max' => 120, ), 'delete_data' => array( 'type' => 'checkbox', 'default' => false, ), ), ); } /** * Store currency guess for a new install: WooCommerce currency when available, else USD. * * @return string */ public static function default_currency(): string { $woo = strtoupper( (string) get_option( 'woocommerce_currency', '' ) ); return ( '' !== $woo && Currencies::exists( $woo ) ) ? $woo : 'USD'; } /** * Default values for a group. * * @param string $group Group key. * @return array */ public static function defaults( string $group ): array { $schema = self::schema(); $defaults = array(); foreach ( $schema[ $group ] ?? array() as $key => $field ) { $defaults[ $key ] = $field['default']; } return $defaults; } /** * All values of a group merged with defaults. * * @param string $group Group key. * @return array */ public static function group( string $group ): array { $saved = get_option( self::option_name( $group ), array() ); return array_merge( self::defaults( $group ), is_array( $saved ) ? $saved : array() ); } /** * One setting value. * * @param string $group Group key. * @param string $key Field key. * @return mixed */ public static function get( string $group, string $key ) { $values = self::group( $group ); return $values[ $key ] ?? null; } /** * Save defaults for groups that were never saved (activation). * * @return void */ public static function add_defaults(): void { foreach ( self::GROUPS as $group ) { if ( false === get_option( self::option_name( $group ), false ) ) { add_option( self::option_name( $group ), self::defaults( $group ), '', false ); } } } /** * Sanitize a submitted group against its schema. * * @param string $group Group key. * @param mixed $input Raw submitted values. * @return array */ public static function sanitize( string $group, $input ): array { $schema = self::schema()[ $group ] ?? array(); $input = is_array( $input ) ? $input : array(); $clean = array(); foreach ( $schema as $key => $field ) { $raw = $input[ $key ] ?? null; if ( 'secret' === $field['type'] ) { $clean[ $key ] = self::sanitize_secret( $group, $key, $field, $raw, ! empty( $input[ $key . '_remove' ] ) ); continue; } $clean[ $key ] = self::sanitize_field( $field, $raw ); } if ( 'format' === $group ) { $clean = self::follow_currency_decimals( $clean ); } return $clean; } /** * Secret values (API keys) are never printed back into the form: an empty field keeps the saved value. * * @param string $group Group key. * @param string $key Field key. * @param array $field Field schema. * @param mixed $raw Submitted value. * @param bool $remove Whether "remove" was ticked. * @return string */ private static function sanitize_secret( string $group, string $key, array $field, $raw, bool $remove ): string { if ( $remove ) { return ''; } $saved = (string) self::get( $group, $key ); $value = trim( sanitize_text_field( (string) $raw ) ); if ( '' === $value ) { return $saved; } if ( ! empty( $field['pattern'] ) && ! preg_match( $field['pattern'], $value ) ) { if ( function_exists( 'add_settings_error' ) ) { add_settings_error( self::option_name( $group ), 'rentbaik_' . $key, __( 'The key you entered does not look right, so the previous value was kept.', 'rentbaik' ) ); } return $saved; } return $value; } /** * When the currency changes and the decimals were left at the old currency's default, * switch to the new currency's default (e.g. USD 2 → IDR 0). * * @param array $clean Sanitized format values. * @return array */ private static function follow_currency_decimals( array $clean ): array { $previous = self::group( 'format' ); $old_default = Currencies::get( (string) $previous['currency'] )['decimals']; if ( $previous['currency'] !== $clean['currency'] && $old_default === (int) $clean['decimals'] ) { $clean['decimals'] = Currencies::get( (string) $clean['currency'] )['decimals']; } return $clean; } /** * Sanitize one value by field type. * * @param array $field Field schema. * @param mixed $raw Raw value (already unslashed by the Settings API). * @return mixed */ private static function sanitize_field( array $field, $raw ) { switch ( $field['type'] ) { case 'checkbox': return ! empty( $raw ); case 'email': $email = sanitize_email( (string) $raw ); return is_email( $email ) ? $email : $field['default']; case 'emails': $emails = array_filter( array_map( 'sanitize_email', explode( ',', (string) $raw ) ), 'is_email' ); return implode( ', ', $emails ); case 'phone': // Keep digits only, international format without "+" (e.g. 6281234567890). return preg_replace( '/\D+/', '', (string) $raw ); case 'int': $value = (int) $raw; return max( (int) $field['min'], min( (int) $field['max'], $value ) ); case 'select': $value = (string) $raw; return in_array( $value, $field['choices'], true ) ? $value : $field['default']; case 'multicheck': $values = array_map( 'sanitize_key', (array) $raw ); return array_values( array_intersect( $field['choices'], $values ) ); case 'color': $color = sanitize_hex_color( (string) $raw ); return $color ? $color : $field['default']; case 'separator': // One or two characters; a single space is allowed. $value = substr( wp_strip_all_tags( (string) $raw ), 0, 2 ); return ( '' === $value || preg_match( '/[0-9]/', $value ) ) ? $field['default'] : $value; case 'textarea': return sanitize_textarea_field( (string) $raw ); case 'decimal': $value = (float) str_replace( ',', '.', (string) $raw ); return round( max( (float) $field['min'], min( (float) $field['max'], $value ) ), 2 ); case 'money': return max( 0, Format::to_minor( (string) $raw ) ); case 'media': $id = absint( $raw ); return ( $id && wp_attachment_is_image( $id ) ) ? $id : 0; case 'slug': return strtoupper( substr( preg_replace( '/[^A-Za-z0-9\-_]/', '', (string) $raw ), 0, 10 ) ); default: return sanitize_text_field( (string) $raw ); } } }