id, 'aiprocessia-assistant')) { return; } $locale_es = (0 === strpos(get_locale(), 'es')); printf( '

%s %s %s

', esc_html__('AIPROCESSIA Assistant:', 'aiprocessia-assistant'), esc_html( $locale_es ? 'activa 5 consultas gratis al día para tus visitantes — sin tarjeta, sin cuenta que crear a mano.' : 'activate 5 free AI answers a day for your visitors — no card, no account to create by hand.' ), esc_url(admin_url('options-general.php?page=aiprocessia-assistant')), esc_html($locale_es ? 'Activar ahora' : 'Activate now') ); } /** * "Daily cap reached" banner (contract §3b.3, channel 3 — the WordPress * panel). Reads ONLY the cached plan status (never calls the backend from * this hook, which fires on every wp-admin page); the cache is populated by * aipf_gratis_estado_refrescar(), called after a successful signup and from * the settings screen. Shown both on the dashboard and on the settings page * because admin_notices fires on every wp-admin screen. */ function aipf_tope_banner_html() { if ('' === (string) get_option('aipf_widget_token', '')) { return ''; } $estado = aipf_gratis_estado_cache_get(); if (!is_array($estado) || empty($estado['ok'])) { return ''; } // Fricción #16 (1.3.1): el aviso «Ver packs» es del plan gratis; a un cliente de pago no se le // vende un pack que ya tiene (sus topes son otros y no tiene «visitantes sin atender»). if ('gratis' !== (string) ($estado['plan'] ?? '')) { return ''; } $limite = isset($estado['limite_dia']) ? (int) $estado['limite_dia'] : 0; $usadas = isset($estado['usadas_hoy']) ? (int) $estado['usadas_hoy'] : 0; $sin_hoy = isset($estado['sin_atender_hoy']) ? (int) $estado['sin_atender_hoy'] : 0; $sin_7d = isset($estado['sin_atender_7d']) ? (int) $estado['sin_atender_7d'] : 0; // Fricción #4 (decisión de Jose, 1.3.1): el aviso dice la verdad sobre el periodo del que // habla. Si HOY se ha tocado el tope, cifras de hoy; si no, es un aviso de la SEMANA y se // titula como tal (antes se ponía el contador de 7 días bajo la palabra «Hoy»). $tope_hoy = ($limite > 0 && $usadas >= $limite); if (!$tope_hoy && $sin_7d <= 0) { return ''; } $locale_es = (0 === strpos(get_locale(), 'es')); $upgrade_url = !empty($estado['upgrade_url']) ? esc_url_raw($estado['upgrade_url']) : 'https://aiprocessia.com/chatbot-tienda-online/?utm_source=plugin&utm_medium=aviso&utm_campaign=gratis_tope'; // singular/plural del contador (fricción #9: «1 visitantes») if ($tope_hoy) { $texto = $locale_es ? sprintf('Hoy: %1$d/%2$d consultas usadas · %3$d %4$s sin atender', $usadas, $limite, $sin_hoy, 1 === $sin_hoy ? 'visitante' : 'visitantes') : sprintf('Today: %1$d/%2$d queries used · %3$d %4$s unattended', $usadas, $limite, $sin_hoy, 1 === $sin_hoy ? 'visitor' : 'visitors'); } else { $texto = $locale_es ? sprintf('Esta semana: %1$d %2$s sin atender', $sin_7d, 1 === $sin_7d ? 'visitante' : 'visitantes') : sprintf('This week: %1$d %2$s unattended', $sin_7d, 1 === $sin_7d ? 'visitor' : 'visitors'); } $enlace = $locale_es ? 'Ver packs desde 49 €/mes' : 'See packs from €49/month'; return sprintf( '

%s — %s · %s →

', esc_html__('AIPROCESSIA Assistant:', 'aiprocessia-assistant'), esc_html($texto), esc_url($upgrade_url), esc_html($enlace) ); } add_action('admin_notices', 'aipf_tope_notice'); function aipf_tope_notice() { if (!current_user_can('manage_options')) { return; } echo aipf_tope_banner_html(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- every piece is escaped inside aipf_tope_banner_html(). } /* ------------------------------------------------------------ chat widget */ /** * Inject the AIPROCESSIA chat widget on the storefront. * Guideline 7 compliance: nothing is loaded from remote servers until the * site owner has explicitly configured their widget token (free plan or * paid — same mechanism either way). */ add_action('wp_enqueue_scripts', 'aipf_enqueue_widget'); function aipf_enqueue_widget() { $token = get_option('aipf_widget_token', ''); if ('' === $token) { return; } wp_enqueue_script( 'aipf-widget', 'https://aiprocessia.com/cuenta/documentos/widget/?t=' . rawurlencode($token), array(), AIPF_VERSION, array( 'strategy' => 'defer', 'in_footer' => true, ) ); } /* ------------------------------------------------------------ product feed */ add_action('rest_api_init', 'aipf_register_feed_route'); function aipf_register_feed_route() { register_rest_route( 'aipf/v1', '/feed', array( 'methods' => 'GET', 'callback' => 'aipf_feed_callback', // The endpoint is public by design (AIPROCESSIA servers fetch it); // access is controlled by the secret token checked in the callback. 'permission_callback' => '__return_true', ) ); } /** * Serve the catalog as a Google Shopping RSS feed (same contract as the * AIPROCESSIA PrestaShop module): title, description, link, image, price, * availability, units sold in custom_label_0 and the product data sheet * (attributes) as product_detail entries — when WooCommerce is active AND * the site owner has enabled the catalog toggle (paid Store plan). * * Otherwise (free plan, or WooCommerce/catalog not enabled) this same * token-gated endpoint answers a small 200 JSON instead of 404: it is what * the free-plan signup (contract §1) checks to confirm the site is * publicly reachable before issuing a token, and a stable URL AIPROCESSIA * can use to start indexing the site's own pages (no product data * involved — that indexing reuses the existing generic web crawler). */ function aipf_feed_callback(WP_REST_Request $request) { $secret = (string) get_option('aipf_feed_token', ''); $given = (string) $request->get_param('token'); if ('' === $secret || !hash_equals($secret, $given)) { return new WP_Error('aipf_forbidden', __('Invalid feed token.', 'aiprocessia-assistant'), array('status' => 403)); } if (!get_option('aipf_feed_enabled') || !class_exists('WooCommerce')) { return new WP_REST_Response(aipf_feed_ping_body(), 200); } $fresh = (bool) $request->get_param('fresh'); $xml = $fresh ? false : get_transient('aipf_feed_xml'); if (false === $xml) { $xml = aipf_build_feed_xml(); set_transient('aipf_feed_xml', $xml, AIPF_FEED_CACHE_TTL); } // Raw XML output: WP_REST_Response would JSON-encode the body. header('Content-Type: application/xml; charset=utf-8'); echo $xml; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- XML built with esc_xml in aipf_build_feed_xml(). exit; } /** Minimal reachability/verification payload (no product data). */ function aipf_feed_ping_body() { return array( 'ok' => true, 'site' => home_url('/'), 'catalog' => false, 'plugin_version' => AIPF_VERSION, ); } /** * Query-var alias of the feed/verification endpoint: contract §1 documents * /?aipf_feed=1&token=... as the example URL shape, alongside "or this * plugin's real route". Kept as a thin alternative in case the backend * probes that exact shape instead of (or in addition to) the REST route — * same token check, same responses, no REST machinery involved. */ add_action('template_redirect', 'aipf_maybe_serve_feed_alias'); function aipf_maybe_serve_feed_alias() { if (!isset($_GET['aipf_feed'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public read-only endpoint, gated by the secret token checked below. return; } $secret = (string) get_option('aipf_feed_token', ''); $given = isset($_GET['token']) ? sanitize_text_field(wp_unslash($_GET['token'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- see above. if ('' === $secret || !hash_equals($secret, $given)) { status_header(403); header('Content-Type: application/json; charset=utf-8'); echo wp_json_encode(array('ok' => false, 'error' => 'forbidden')); exit; } if (get_option('aipf_feed_enabled') && class_exists('WooCommerce')) { $fresh = !empty($_GET['fresh']); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- see above. $xml = $fresh ? false : get_transient('aipf_feed_xml'); if (false === $xml) { $xml = aipf_build_feed_xml(); set_transient('aipf_feed_xml', $xml, AIPF_FEED_CACHE_TTL); } header('Content-Type: application/xml; charset=utf-8'); echo $xml; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- XML built with esc_xml in aipf_build_feed_xml(). exit; } header('Content-Type: application/json; charset=utf-8'); echo wp_json_encode(aipf_feed_ping_body()); exit; } /** XML-escape helper (feed values). */ function aipf_x($value) { return htmlspecialchars((string) $value, ENT_XML1 | ENT_COMPAT, 'UTF-8'); } function aipf_build_feed_xml() { $items = array(); $page = 1; do { $products = wc_get_products( array( 'status' => 'publish', 'limit' => 200, 'page' => $page, 'orderby' => 'ID', 'order' => 'ASC', ) ); foreach ($products as $product) { if (count($items) >= AIPF_FEED_MAX_PRODUCTS) { break 2; } if (!$product->is_visible()) { continue; } $price = (float) wc_get_price_to_display($product); if ($price <= 0) { continue; } $availability = 'in stock'; if (!$product->is_in_stock()) { $availability = $product->backorders_allowed() ? 'backorder' : 'out of stock'; } // Prefer the long description (enriched product sheets live there). $desc = trim(wp_strip_all_tags((string) $product->get_description())); if ('' === $desc) { $desc = trim(wp_strip_all_tags((string) $product->get_short_description())); } $desc = preg_replace('/\s+/u', ' ', $desc); if (function_exists('mb_substr')) { $desc = mb_substr($desc, 0, 1200); } $it = "\n"; $it .= '' . (int) $product->get_id() . "\n"; $it .= '' . aipf_x($product->get_name()) . "\n"; $it .= '' . aipf_x($desc) . "\n"; $it .= '' . aipf_x($product->get_permalink()) . "\n"; $image_id = $product->get_image_id(); if ($image_id) { $img = wp_get_attachment_image_url($image_id, 'large'); if ($img) { $it .= '' . aipf_x($img) . "\n"; } } $cats = get_the_terms($product->get_id(), 'product_cat'); if (is_array($cats) && !empty($cats)) { $it .= '' . aipf_x($cats[0]->name) . "\n"; } $it .= '' . number_format($price, 2, '.', '') . ' ' . aipf_x(get_woocommerce_currency()) . "\n"; $it .= '' . aipf_x($availability) . "\n"; $it .= "new\n"; // Units sold — the AIPROCESSIA platform turns this into the // "best sellers" popularity signal. $sales = (int) $product->get_total_sales(); if ($sales > 0) { $it .= 'ventas:' . $sales . "\n"; } // Data sheet (product attributes) as standard product_detail pairs. foreach ($product->get_attributes() as $attribute) { if (!($attribute instanceof WC_Product_Attribute)) { continue; } $label = wc_attribute_label($attribute->get_name()); if ($attribute->is_taxonomy()) { $terms = wp_get_post_terms($product->get_id(), $attribute->get_name(), array('fields' => 'names')); $values = is_wp_error($terms) ? array() : $terms; } else { $values = $attribute->get_options(); } $value = implode(', ', array_map('strval', (array) $values)); if ('' === $label || '' === $value) { continue; } $it .= '' . aipf_x($label) . '' . aipf_x($value) . "\n"; } $items[] = $it . ''; } $page++; } while (!empty($products)); $shop = get_bloginfo('name'); return '' . "\n" . '' . "\n\n" . '' . aipf_x($shop) . "\n" . '' . aipf_x(home_url('/')) . "\n" . '' . aipf_x($shop) . " - product catalog for the AI assistant\n" . implode("\n", $items) . "\n\n\n"; } // Keep the cached feed reasonably current when the catalog changes. add_action('woocommerce_update_product', 'aipf_flush_feed_cache'); add_action('woocommerce_delete_product', 'aipf_flush_feed_cache'); function aipf_flush_feed_cache() { delete_transient('aipf_feed_xml'); } /* ---------------------------------------------------------------- settings */ add_action('admin_menu', 'aipf_admin_menu'); function aipf_admin_menu() { add_options_page( __('AIPROCESSIA Assistant', 'aiprocessia-assistant'), __('AIPROCESSIA Assistant', 'aiprocessia-assistant'), 'manage_options', 'aiprocessia-assistant', 'aipf_settings_page' ); } add_action('admin_init', 'aipf_register_settings'); function aipf_register_settings() { register_setting( 'aipf_settings', 'aipf_widget_token', array( 'type' => 'string', 'sanitize_callback' => 'aipf_sanitize_token', 'default' => '', ) ); register_setting( 'aipf_settings', 'aipf_feed_enabled', array( 'type' => 'boolean', 'sanitize_callback' => 'rest_sanitize_boolean', 'default' => false, ) ); register_setting( 'aipf_settings', 'aipf_registro', array( 'type' => 'boolean', 'sanitize_callback' => 'aipf_sanitize_registro', 'default' => false, ) ); } /** * Re-entrancy guard for aipf_registro. The free-plan handler writes the flag * itself after having already sent the registration request; update_option() * runs the registered sanitize callback, which would otherwise fire a SECOND * request (with the admin email instead of the one just entered). */ function aipf_registro_en_curso($set = null) { static $en_curso = false; if (null !== $set) { $en_curso = (bool) $set; } return $en_curso; } /** * Optional update notices + free SEO+GEO report (opt-in). Since 1.3.1 the * usual way in is the checkbox inside the free-plan form; this settings * checkbox stays for sites that are not signed up here (paid customers * pasting their token by hand) and for the sites that already used it in * 1.2.0 — same option, same request, unchanged behaviour. * The checkbox is UNCHECKED by default; only when the site owner ticks it we * send ONE request to AIPROCESSIA with the site domain and the admin email. * AIPROCESSIA answers with a confirmation email — nothing is activated until * the owner confirms it. Unticking only stops future registration calls; the * mailing opt-out is the unsubscribe link in any email. */ function aipf_sanitize_registro($value) { $nuevo = rest_sanitize_boolean($value); $viejo = (bool) get_option('aipf_registro', false); if ($nuevo && !$viejo && !aipf_registro_en_curso()) { if (aipf_registrar_sitio()) { update_option('aipf_registro_fecha', gmdate('Y-m-d H:i'), false); add_settings_error( 'aipf_registro', 'aipf_registro_ok', sprintf( /* translators: %s: admin email address */ __('We have sent a confirmation email to %s. Open it and press the button to complete the registration.', 'aiprocessia-assistant'), get_option('admin_email') ), 'success' ); } else { add_settings_error( 'aipf_registro', 'aipf_registro_error', __('We could not contact AIPROCESSIA right now. Please try again later.', 'aiprocessia-assistant') ); return false; } } return $nuevo; } /** * POST the registration to AIPROCESSIA. Returns true on HTTP 200 + ok:true. * $email/$idioma let the free-plan form reuse this same circuit with what the * owner just typed there; with no arguments it behaves exactly as in 1.2.0 * (admin email + site locale), which is what the settings checkbox uses. */ function aipf_registrar_sitio($email = '', $idioma = '') { $email = is_email($email) ? $email : (string) get_option('admin_email'); $idioma = in_array($idioma, array('es', 'en'), true) ? $idioma : ((0 === strpos(get_locale(), 'es')) ? 'es' : 'en'); $body = array( 'modulo' => 'aiprocessia-assistant', 'version' => AIPF_VERSION, 'dominio' => (string) wp_parse_url(home_url('/'), PHP_URL_HOST), 'email' => $email, 'plataforma' => class_exists('WooCommerce') ? 'WordPress/WooCommerce' : 'WordPress', 'plataforma_version' => (string) get_bloginfo('version'), 'idioma' => $idioma, 'consent' => true, ); $res = wp_remote_post( 'https://aiprocessia.com/cuenta/modulos/registro/', array( 'timeout' => 12, 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', ), 'body' => wp_json_encode($body), ) ); if (is_wp_error($res) || 200 !== (int) wp_remote_retrieve_response_code($res)) { return false; } $json = json_decode((string) wp_remote_retrieve_body($res), true); return is_array($json) && !empty($json['ok']); } function aipf_sanitize_token($value) { return substr(preg_replace('/[^a-z0-9]/', '', strtolower((string) $value)), 0, 64); } /* ------------------------------------------------------------- plan gratis */ /** Cached plan status (transient, 1h). Never triggers a network request. */ function aipf_gratis_estado_cache_get() { $cache = get_transient('aipf_gratis_estado'); return is_array($cache) ? $cache : false; } /** * Poll the plan status endpoint (contract §5) and cache it for 1h. Safe to * call with no token (returns false, no request made). On a network or * format failure the PREVIOUS cache (if any) is left untouched, so the * admin still sees the last known state instead of a blank screen — this * function is only ever called from wp-admin (page load or explicit * "Refresh"/signup actions), never from a visitor-facing hook. */ function aipf_gratis_estado_refrescar($force = false) { $token = (string) get_option('aipf_widget_token', ''); if ('' === $token) { return false; } if (!$force) { $cached = aipf_gratis_estado_cache_get(); if (false !== $cached) { return $cached; } } $res = wp_remote_post( AIPF_GRATIS_ESTADO_URL, array( 'timeout' => 12, 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', ), 'body' => wp_json_encode(array('widget_token' => $token)), ) ); if (is_wp_error($res) || 200 !== (int) wp_remote_retrieve_response_code($res)) { return aipf_gratis_estado_cache_get(); } $json = json_decode((string) wp_remote_retrieve_body($res), true); if (!is_array($json) || empty($json['ok'])) { return aipf_gratis_estado_cache_get(); } set_transient('aipf_gratis_estado', $json, AIPF_GRATIS_CACHE_TTL); if (!empty($json['plan'])) { update_option('aipf_plan', sanitize_key($json['plan']), false); } return $json; } /** Fallback bilingual messages for known error codes (used only if the backend's own "mensaje" is missing). */ function aipf_gratis_error_mensaje($codigo, $locale_es) { $textos = array( 'ya_cliente' => array( 'es' => 'Este dominio ya tiene un plan de pago de AIPROCESSIA. Entra en tu cuenta para gestionarlo.', 'en' => 'This domain already has a paid AIPROCESSIA plan. Sign in to your account to manage it.', ), 'feed_inaccesible' => array( 'es' => 'No hemos podido acceder a tu web públicamente para verificarla. Comprueba que es accesible (sin modo mantenimiento ni contraseña) y vuelve a intentarlo.', 'en' => 'We could not reach this site publicly to verify it. Make sure it is publicly reachable (no maintenance mode or password) and try again.', ), 'no_disponible' => array( 'es' => 'El plan gratis no está disponible ahora mismo. Inténtalo de nuevo más tarde.', 'en' => 'The free plan is not available right now. Please try again later.', ), ); if (isset($textos[$codigo])) { return $locale_es ? $textos[$codigo]['es'] : $textos[$codigo]['en']; } return $locale_es /* translators: %s: error code from the AIPROCESSIA API */ ? sprintf('No hemos podido activar el plan gratis (código: %s). Inténtalo de nuevo en unos minutos.', $codigo) /* translators: %s: error code from the AIPROCESSIA API */ : sprintf('We could not activate the free plan (code: %s). Please try again in a few minutes.', $codigo); } /** * Free-plan form, optional checkbox (unchecked by default): "tell me about * plugin updates and send me the free SEO+GEO report of my site". Reuses the * 1.2.0 registration circuit — same endpoint, same options — with the email * and language just entered instead of the admin ones. A failure here NEVER * invalidates the free plan: it only adds a warning next to the signup notice. */ function aipf_gratis_registrar_avisos($email, $idioma, $locale_es) { if (aipf_registrar_sitio($email, $idioma)) { aipf_registro_en_curso(true); update_option('aipf_registro', true, false); aipf_registro_en_curso(false); update_option('aipf_registro_fecha', gmdate('Y-m-d H:i'), false); add_settings_error( 'aipf_gratis_alta', 'aipf_gratis_avisos_ok', $locale_es /* translators: %s: email address entered in the free-plan form */ ? sprintf('También te apuntamos a los avisos de actualización y al informe SEO+GEO: abre el correo de confirmación que te hemos enviado a %s.', $email) /* translators: %s: email address entered in the free-plan form */ : sprintf('We also signed you up for update notices and the free SEO+GEO report: open the confirmation email we sent to %s.', $email), 'success' ); return true; } add_settings_error( 'aipf_gratis_alta', 'aipf_gratis_avisos_error', $locale_es ? 'Tu plan gratis está activo, pero no hemos podido apuntarte a los avisos de actualización ni al informe SEO+GEO. Puedes activarlo más tarde desde esta misma pantalla.' : 'Your free plan is active, but we could not sign you up for the update notices and the free SEO+GEO report. You can enable it later from this same screen.', 'warning' ); return false; } /** * "Activate my free plan" form handler (contract §1). Fires ONLY on an * explicit admin submission (nonce-checked); never called automatically. */ add_action('admin_post_aipf_gratis_alta', 'aipf_admin_post_gratis_alta'); function aipf_admin_post_gratis_alta() { if (!current_user_can('manage_options')) { wp_die(esc_html__('You are not allowed to do this.', 'aiprocessia-assistant')); } check_admin_referer('aipf_gratis_alta', 'aipf_gratis_nonce'); // settings-updated=true: sin él, settings_errors() ignora el transient y el mensaje de éxito/error // del alta se perdía (fricción #11, prueba externa 8-sep). $redirect = admin_url('options-general.php?page=aiprocessia-assistant&settings-updated=true'); $locale_es = (0 === strpos(get_locale(), 'es')); $email = isset($_POST['aipf_gratis_email']) ? sanitize_email(wp_unslash($_POST['aipf_gratis_email'])) : ''; $sitio = isset($_POST['aipf_gratis_sitio']) ? esc_url_raw(wp_unslash($_POST['aipf_gratis_sitio'])) : ''; $idioma = (isset($_POST['aipf_gratis_idioma']) && 'en' === $_POST['aipf_gratis_idioma']) ? 'en' : 'es'; $consent = !empty($_POST['aipf_gratis_consent']); $avisos = !empty($_POST['aipf_gratis_avisos']); $alta_ok = false; if ('' === $sitio) { $sitio = home_url('/'); } if (!$consent) { add_settings_error( 'aipf_gratis_alta', 'aipf_gratis_consent', $locale_es ? 'Debes aceptar el envío de tus datos a aiprocessia.com para activar el plan gratis.' : 'You must accept sending your data to aiprocessia.com to activate the free plan.' ); } elseif (!is_email($email)) { add_settings_error( 'aipf_gratis_alta', 'aipf_gratis_email', $locale_es ? 'Introduce un correo válido.' : 'Please enter a valid email address.' ); } else { $body = array( 'email' => $email, 'sitio' => $sitio, 'idioma' => $idioma, 'feed_token' => (string) get_option('aipf_feed_token', ''), 'plugin_version' => AIPF_VERSION, 'consent' => true, ); $res = wp_remote_post( AIPF_GRATIS_ALTA_URL, array( 'timeout' => 20, 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', ), 'body' => wp_json_encode($body), ) ); if (is_wp_error($res)) { add_settings_error( 'aipf_gratis_alta', 'aipf_gratis_red', $locale_es ? 'No hemos podido contactar con AIPROCESSIA ahora mismo. Inténtalo de nuevo en un momento.' : 'We could not contact AIPROCESSIA right now. Please try again in a moment.' ); } else { $code = (int) wp_remote_retrieve_response_code($res); $json = json_decode((string) wp_remote_retrieve_body($res), true); if (200 === $code && is_array($json) && !empty($json['ok'])) { $token = isset($json['widget_token']) ? aipf_sanitize_token($json['widget_token']) : ''; if ('' !== $token) { update_option('aipf_widget_token', $token, false); update_option('aipf_plan', 'gratis', false); update_option('aipf_gratis_email', $email, false); delete_transient('aipf_gratis_estado'); aipf_gratis_estado_refrescar(true); $mensaje = (is_array($json) && !empty($json['mensaje'])) ? sanitize_text_field($json['mensaje']) : ($locale_es ? 'Plan gratis activado. Te hemos enviado un correo para verificar tu email; tienes 48 h.' : 'Free plan activated. We sent you an email to verify your address; you have 48h.'); add_settings_error('aipf_gratis_alta', 'aipf_gratis_ok', $mensaje, 'success'); $alta_ok = true; // Fricción #6 (decisión de Jose, 1.3.1): una pantalla, un formulario. La // casilla de avisos/informe SEO+GEO dispara aquí el MISMO circuito que la // casilla suelta de 1.2.0, con el correo que se acaba de escribir. if ($avisos) { aipf_gratis_registrar_avisos($email, $idioma, $locale_es); } } else { add_settings_error( 'aipf_gratis_alta', 'aipf_gratis_bad', $locale_es ? 'Respuesta inesperada de AIPROCESSIA.' : 'Unexpected response from AIPROCESSIA.' ); } } else { $error_code = (is_array($json) && !empty($json['error'])) ? sanitize_key($json['error']) : (string) $code; $mensaje = (is_array($json) && !empty($json['mensaje'])) ? sanitize_text_field($json['mensaje']) : aipf_gratis_error_mensaje($error_code, $locale_es); add_settings_error('aipf_gratis_alta', 'aipf_gratis_error', $mensaje); } } } set_transient('settings_errors', get_settings_errors(), 30); // Si el ALTA ha fallado, que el formulario vuelva con lo que el usuario escribió (fricción // #14): sin esto tenía que reescribir el correo y volver a marcar la casilla. Un fallo del // registro de avisos (1.3.1) no cuenta: el alta es válida y el formulario ya no se muestra. if (!$alta_ok) { set_transient('aipf_gratis_form', array('email' => $email, 'sitio' => $sitio, 'idioma' => $idioma), 120); } wp_safe_redirect($redirect); exit; } /** "Refresh" button on the status block — forces a fresh poll, bypassing the 1h cache. */ add_action('admin_post_aipf_gratis_refrescar', 'aipf_admin_post_gratis_refrescar'); function aipf_admin_post_gratis_refrescar() { if (!current_user_can('manage_options')) { wp_die(esc_html__('You are not allowed to do this.', 'aiprocessia-assistant')); } check_admin_referer('aipf_gratis_refrescar', 'aipf_gratis_refrescar_nonce'); aipf_gratis_estado_refrescar(true); wp_safe_redirect(admin_url('options-general.php?page=aiprocessia-assistant&settings-updated=true')); exit; } function aipf_settings_page() { if (!current_user_can('manage_options')) { return; } // Sin settings_errors() aquí: en las páginas del menú Ajustes lo llama el propio WordPress // (options-head.php) y salía duplicado (fricción #13). Lo que hace visibles los avisos es el // settings-updated=true de las redirecciones. $form_previo = get_transient('aipf_gratis_form'); if (is_array($form_previo)) { delete_transient('aipf_gratis_form'); } else { $form_previo = array(); } $widget_token = get_option('aipf_widget_token', ''); $feed_enabled = (bool) get_option('aipf_feed_enabled'); $feed_url = rest_url('aipf/v1/feed'); $feed_url = add_query_arg('token', rawurlencode((string) get_option('aipf_feed_token', '')), $feed_url); $has_woo = class_exists('WooCommerce'); $locale_es = (0 === strpos(get_locale(), 'es')); // The site domain travels in the signup link so the AIPROCESSIA panel can // pre-fill the widget's authorized domains and the catalog connection. $signup_url = add_query_arg( array( 'utm_source' => 'wordpress-plugin', 'utm_medium' => 'settings', 'site' => rawurlencode(home_url('/')), ), $locale_es ? 'https://aiprocessia.com/chatbot-tienda-online/' : 'https://aiprocessia.com/en/ai-shopping-assistant/' ); ?>

—

$locale_es ? 'Gratis' : 'Free', 'web' => 'Web', 'tienda' => $locale_es ? 'Tienda' : 'Store', 'documental' => $locale_es ? 'Documental' : 'Knowledge', ); ?>

→ →


' . esc_html__('Settings', 'aiprocessia-assistant') . '' ); return $links; }