✓' . esc_html__( 'Included', 'ai-alt-text-builder' ) . '';
} else {
echo '✕' . esc_html__( 'Not included', 'ai-alt-text-builder' ) . '';
}
}
function aatb_render_settings_page() {
$st = [];
$opt = get_option( 'aatb_settings', [] );
$token = $opt['site_token'] ?? '';
$model = $opt['model_choice'] ?? 'gpt-4.1-mini';
$lang = $opt['lang_choice'] ?? 'en';
$mode = function_exists( 'aatb_active_mode' ) ? aatb_active_mode() : 'none';
/* ───────────── FORM SUBMISSIONS (nonce-protected) ───────────── */
if ( isset($_POST['aatb_action']) ) {
$action = sanitize_key( wp_unslash( $_POST['aatb_action'] ) );
if ( ! check_admin_referer( $action ) ) {
wp_die( 'Bad nonce' );
}
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Unauthorized' );
}
/* Save Site Key (Pro) */
if ( $action === 'aatb_save_site_key' ) {
$new = sanitize_text_field( wp_unslash( $_POST['site_token'] ?? '' ) );
// A key copied from email, a PDF or a chat can carry invisible characters (no-break or
// zero-width spaces, a byte order mark) or wrapping quotes or backticks at either end.
// No valid key starts or ends with them, so they are removed before the check.
$new = (string) preg_replace( '/^[\s\p{Z}\p{Cf}`"\']+|[\s\p{Z}\p{Cf}`"\']+$/u', '', $new );
$chk = $new ? aatb_check_token_status( $new ) : [ 'error' => 'empty' ];
if ( ! empty( $chk['error'] ) ) {
// The key already saved stays: a typo or a short outage must not remove a working key
// and leave the free trial or an OpenAI key in charge of this site.
echo '
Site Key Error: '
. esc_html( (string)$chk['error'] ) . '
';
} else {
$opt['site_token'] = $new;
$is_edu_key = '' !== aatb_institution_slug( $new );
if ( $is_edu_key ) {
// An institutional site never falls back to the shared trial or to OpenAI, even if
// its key were removed later, so those credentials are cleared.
$opt['trial_token'] = '';
$opt['trial_remaining'] = 0;
$opt['openai_key'] = '';
}
$saved_msg = $is_edu_key
? __( 'Site key saved. Institutional plan activated.', 'ai-alt-text-builder' )
: __( 'Site Key saved. Pro mode activated!', 'ai-alt-text-builder' );
echo '
' . esc_html( $saved_msg ) . '
';
}
update_option( 'aatb_settings', $opt );
$token = $opt['site_token'] ?? '';
$mode = function_exists( 'aatb_active_mode' ) ? aatb_active_mode() : 'none';
}
/* Alt-Text settings (model + language + custom prompt + auto_generate) */
if ( $action === 'aatb_save_alt_settings' ) {
$allowed = ['gpt-4.1-mini','gpt-4o','gpt-4.1'];
$chosen = sanitize_text_field( wp_unslash( $_POST['model_choice'] ?? 'gpt-4.1-mini' ) );
$opt['model_choice'] = in_array( $chosen, $allowed, true ) ? $chosen : 'gpt-4.1-mini';
$sel = sanitize_text_field( wp_unslash( $_POST['lang_choice'] ?? 'en' ) );
$opt['lang_choice'] = ( $sel === 'other' )
? sanitize_text_field( wp_unslash( $_POST['lang_custom'] ?? 'en' ) )
: $sel;
// The field itself stops at 1,500 characters (maxlength); this cut only catches a form
// sent without it. Line breaks arrive as \r\n but count as one character in the field.
$cp = str_replace( "\r\n", "\n", sanitize_textarea_field( wp_unslash( $_POST['custom_prompt'] ?? '' ) ) );
$cut = mb_strlen( $cp ) > 1500;
if ( $cut ) { $cp = mb_substr( $cp, 0, 1500 ); }
$opt['custom_prompt'] = $cp;
$opt['auto_generate'] = ! empty( $_POST['auto_generate'] ) ? '1' : '';
update_option( 'aatb_settings', $opt );
$model = $opt['model_choice'];
$lang = $opt['lang_choice'];
if ( $cut ) {
echo '
' . esc_html__( 'Settings saved. Custom directives were shortened to the 1,500-character limit.', 'ai-alt-text-builder' ) . '
';
} else {
echo '
Settings saved.
';
}
}
}
/* ───────────── PRO MODE: TOKEN / USAGE NORMALIZATION ───────────── */
$usage=$limit=$remain=$site_usage=null;
$membership='free';
$allowed_models_ui = ['gpt-4.1-mini','gpt-4o','gpt-4.1'];
// Institution slug of an rpedu_ key (requests go to the institution's own API), '' otherwise.
$edu_slug = ( $token && $mode === 'pro' ) ? aatb_institution_slug( (string) $token ) : '';
if ( $token && $mode === 'pro' ) {
$st = aatb_check_token_status( $token );
if ( empty( $st['error'] ) ) {
$site_usage = (int)($st['site_usage'] ?? 0);
$limit = (int)( $st['plan_limit_total'] ?? $st['plan_limit'] ?? $st['token_limit'] ?? 0 );
$usage_total = null;
foreach ( ['usage_total','all_usage','total_usage','token_usage','usage'] as $k ) {
if ( isset($st[$k]) ) { $usage_total = (int)$st[$k]; break; }
}
if ( $usage_total === null ) { $usage_total = max(0, (int)$site_usage); }
$remain = aatb_status_remaining( $st );
$usage = (int)$usage_total;
$membership = strtolower( trim( $st['membership'] ?? 'free' ) );
if ( ! empty( $st['allowed_models'] ) && is_array( $st['allowed_models'] ) ) {
$allowed_models_ui = array_values( array_intersect( $allowed_models_ui, $st['allowed_models'] ) );
if ( empty($allowed_models_ui) ) { $allowed_models_ui = ['gpt-4.1-mini']; }
}
}
$canonical = ['gpt-4.1-mini','gpt-4o','gpt-4.1'];
$gate = [
'free' => ['gpt-4.1-mini'],
'starter' => ['gpt-4.1-mini','gpt-4.1'],
'creator' => ['gpt-4.1-mini','gpt-4.1','gpt-4o'],
'expert' => ['gpt-4.1-mini','gpt-4.1','gpt-4o'],
];
// 'institutional' is left out on purpose: its tiers are the allowed_models
// intersection above, so all three show when the institution's API lists them.
// When the institution's API cannot be reached, $membership is only the 'free' fallback.
// An institutional plan then keeps its tiers and its saved choice instead of being reset
// to Economical on every page view. Other keys keep this gate as before.
if ( empty( $st['error'] ) || '' === $edu_slug ) {
if ( isset($gate[$membership]) ) {
$allowed_models_ui = array_values( array_intersect($canonical, $gate[$membership]) );
}
if ( ! in_array($model, $allowed_models_ui, true) ) {
$model = 'gpt-4.1-mini';
$opt['model_choice'] = $model;
update_option('aatb_settings', $opt);
}
}
}
$trial_remaining = (int) ( $opt['trial_remaining'] ?? 0 );
$byok_key_masked = '';
if ( ! empty( $opt['openai_key'] ) ) {
$k = $opt['openai_key'];
$byok_key_masked = substr( $k, 0, 7 ) . str_repeat( '*', max( 0, strlen($k) - 11 ) ) . substr( $k, -4 );
}
$is_pro = $mode === 'pro';
// Institutional plan: an rpedu_ key (requests go to the institution's own API) or the
// "institutional" membership from token-info. Shown as "Institutional" instead of "Pro".
$is_institutional = $is_pro && ( '' !== $edu_slug || 'institutional' === $membership );
// The institution's API counts images, not tokens: every tier uses one image of the plan.
$edu_cost = '' !== $edu_slug ? __( '1 image from your plan', 'ai-alt-text-builder' ) : '';
?>