verify(); $message = ProviderNotice::message( $code, Settings::openai_model() ); if ( '' === $code ) { \WC_Admin_Settings::add_message( $message ); } else { \WC_Admin_Settings::add_error( $message ); } } } /** * Sanitises the budget field; other fields pass through. * * @param mixed $value Value WooCommerce prepared. * @param mixed $option Field definition. * @param mixed $raw Submitted value. * @return mixed */ public static function sanitize( $value, $option, $raw ) { $id = is_array( $option ) ? ( $option['id'] ?? '' ) : ''; if ( self::KEY_FIELD === $id ) { self::save_key( is_string( $raw ) ? trim( $raw ) : '' ); return null; // Never stored by WooCommerce in plain text. } if ( self::CLEAR_FIELD === $id ) { if ( 'saved' !== self::$key_change && ( '1' === $raw || 'yes' === $raw ) && ! OpenAiKey::from_constant() ) { OpenAiKey::clear(); self::$key_change = 'cleared'; } return null; } if ( self::BUDGET_FIELD !== $id ) { return $value; } $saved = get_option( Settings::OPTION_LIMITS, array() ); $saved = is_array( $saved ) && isset( $saved['monthly_budget'] ) && is_scalar( $saved['monthly_budget'] ) ? (string) $saved['monthly_budget'] : (string) Limits::DEFAULTS['monthly_budget']; return BudgetField::sanitize( $raw, $saved ); } /** * Stores a newly entered key; an empty field keeps the saved one. * * @param string $key Submitted key. */ private static function save_key( string $key ): void { if ( '' === $key || OpenAiKey::from_constant() ) { return; } if ( ! OpenAiKey::looks_valid( $key ) ) { \WC_Admin_Settings::add_error( __( 'That does not look like an OpenAI API key (it should start with "sk-"). The saved key was kept.', 'fitzaro' ) ); return; } OpenAiKey::save( $key ); self::$key_change = 'saved'; } /** * What the sanitize callbacks did to the key during the last save. */ private static function key_change(): string { return self::$key_change; } /** * Field definitions. * * @return list> */ private static function fields(): array { return array_merge( self::storefront_fields(), self::provider_fields(), self::spending_fields() ); } /** * Master switch and product types. * * @return list> */ private static function storefront_fields(): array { return array( array( 'id' => 'fitzaro_storefront_section', 'type' => 'title', 'title' => __( 'Storefront', 'fitzaro' ), 'desc' => __( 'Each product and product category also has a Virtual try-on setting, to turn it off or to say what a product is.', 'fitzaro' ), ), array( 'id' => Settings::OPTION_ENABLED, 'type' => 'checkbox', 'title' => __( 'Virtual try-on', 'fitzaro' ), 'desc' => __( 'Show the Try it on button on product pages', 'fitzaro' ), 'default' => 'yes', ), array( 'id' => Settings::OPTION_TYPES, 'type' => 'multiselect', 'title' => __( 'Product types', 'fitzaro' ), 'class' => 'wc-enhanced-select', 'css' => 'min-width: 24em;', 'options' => array( ProductType::TOP => __( 'Tops', 'fitzaro' ), ProductType::DRESS => __( 'Dresses', 'fitzaro' ), ProductType::EYEWEAR => __( 'Eyewear', 'fitzaro' ), ), 'default' => array( ProductType::TOP, ProductType::DRESS, ProductType::EYEWEAR ), 'desc_tip' => __( 'Try-on is only offered for these kinds of products.', 'fitzaro' ), ), array( 'id' => 'fitzaro_storefront_section', 'type' => 'sectionend', ), ); } /** * Provider, key and model. * * @return list> */ private static function provider_fields(): array { $providers = array( '' => __( 'Automatic', 'fitzaro' ), OpenAiProvider::ID => __( 'OpenAI', 'fitzaro' ), ); if ( Registry::mock_allowed( wp_get_environment_type(), defined( 'FITZARO_ENABLE_MOCK' ) && (bool) constant( 'FITZARO_ENABLE_MOCK' ) ) ) { $providers['mock'] = __( 'Mock (development only, no AI)', 'fitzaro' ); } $key = OpenAiKey::get(); if ( OpenAiKey::from_constant() ) { $key_field = array( 'placeholder' => OpenAiKey::mask( $key ), 'desc' => __( 'Set with FITZARO_OPENAI_KEY in wp-config.php.', 'fitzaro' ), 'custom_attributes' => array( 'disabled' => 'disabled' ), ); } else { $key_field = array( 'placeholder' => '' === $key ? 'sk-…' : OpenAiKey::mask( $key ), 'desc' => '' === $key ? __( 'Create a key on platform.openai.com → API keys. Try-ons are billed to that account.', 'fitzaro' ) : __( 'A key is saved (encrypted). Leave the field empty to keep it.', 'fitzaro' ), 'custom_attributes' => array( 'autocomplete' => 'new-password' ), ); } $fields = array( array( 'id' => 'fitzaro_provider_section', 'type' => 'title', 'title' => __( 'AI provider', 'fitzaro' ), 'desc' => __( 'Fitzaro uses your own OpenAI account. Shopper photos and product images are sent to OpenAI to generate each try-on.', 'fitzaro' ), ), array( 'id' => Settings::OPTION_PROVIDER, 'type' => 'select', 'title' => __( 'Provider', 'fitzaro' ), 'options' => $providers, 'default' => '', 'desc_tip' => __( 'Automatic uses OpenAI once a key is saved.', 'fitzaro' ), ), array_merge( array( 'id' => self::KEY_FIELD, 'type' => 'password', 'title' => __( 'OpenAI API key', 'fitzaro' ), 'css' => 'width: 24em;', ), $key_field ), ); if ( '' !== $key && ! OpenAiKey::from_constant() ) { $fields[] = array( 'id' => self::CLEAR_FIELD, 'type' => 'checkbox', 'title' => '', 'desc' => __( 'Remove the saved key', 'fitzaro' ), 'default' => 'no', ); } $fields[] = array( 'id' => Settings::OPTION_MODEL, 'type' => 'select', 'title' => __( 'Model', 'fitzaro' ), 'options' => array_combine( OpenAiProvider::MODELS, OpenAiProvider::MODELS ), 'default' => OpenAiProvider::DEFAULT_MODEL, 'desc_tip' => __( 'gpt-image-2.5-sunburst gives the most faithful results. Older models cost less but change faces and bodies more.', 'fitzaro' ), ); $fields[] = array( 'id' => 'fitzaro_provider_section', 'type' => 'sectionend', ); return $fields; } /** * Budget. * * @return list> */ private static function spending_fields(): array { return array( array( 'id' => 'fitzaro_limits_section', 'type' => 'title', 'title' => __( 'Spending', 'fitzaro' ), 'desc' => __( 'Every try-on is generated with your AI key and billed to your AI account. The budget stops new try-ons for the rest of the month once it is reached.', 'fitzaro' ), ), array( 'id' => self::BUDGET_FIELD, 'type' => 'text', 'title' => __( 'Monthly budget (USD)', 'fitzaro' ), 'default' => (string) Limits::DEFAULTS['monthly_budget'], 'css' => 'width: 8em;', 'desc_tip' => __( 'Months are counted in UTC. Enter 0 for no budget (not recommended).', 'fitzaro' ), 'desc' => self::spent_text(), 'custom_attributes' => array( 'inputmode' => 'decimal', ), ), array( 'id' => 'fitzaro_limits_section', 'type' => 'sectionend', ), ); } /** * "Spent this month" line under the budget field. */ private static function spent_text(): string { $spend = Jobs::requests()->spend_since( Limits::month_start( time() ) ); $text = sprintf( /* translators: %s: amount in USD, e.g. 1.25 */ __( 'Spent this month: $%s', 'fitzaro' ), number_format_i18n( $spend['micros'] / Money::MICROS, 2 ) ); if ( $spend['unpriced'] > 0 ) { $text .= ' ' . sprintf( /* translators: %d: number of try-ons. */ _n( '(plus %d try-on in progress or without a reported cost)', '(plus %d try-ons in progress or without a reported cost)', $spend['unpriced'], 'fitzaro' ), $spend['unpriced'] ); } return $text; } }