Plugin Directory

Changeset 3732385


Ignore:
Timestamp:
10/07/2026 10:44:52 AM (3 weeks ago)
Author:
jcodex
Message:

Update version 1.2.6

Location:
jcodex-ai-product-generator-for-woocommerce
Files:
67 added
15 edited

Legend:

Unmodified
Added
Removed
  • jcodex-ai-product-generator-for-woocommerce/trunk/ai-product-generator.php

    r3724671 r3732385  
    11<?php
    22/**
    3  * Plugin Name: All-in-One AI Product Manager for WooCommerce
     3 * Plugin Name: All in One - AI Product Manager for WooCommerce
    44 * Description: Generate product descriptions, short descriptions, features, benefits, tags, SEO titles and more using AI directly in WooCommerce.
    55 * Author:      Jcodex
    6  * Version:     1.2.5
     6 * Version:     1.2.6
     7 * Requires at least: 5.8
     8 * Requires PHP: 7.4
    79 * License: GPLv2 or later
    810 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    1921 * This program is free software: you can redistribute it and/or modify
    2022 * it under the terms of the GNU General Public License as published by
    21  * the Free Software Foundation, either version 3 of the License, or
     23 * the Free Software Foundation, either version 2 of the License, or
    2224 * (at your option) any later version.
    2325 */
    … …  
    2729}
    2830
    29 define( 'JCODEX_APGWCO_VERSION', '1.2.5' );
     31/*
     32 * Free and Pro share class names and option keys. They cannot run together.
     33 * If Pro already loaded (it defines these constants first), leave quietly
     34 * and switch this copy off so it cannot redefine them.
     35 */
     36if ( defined( 'JCODEX_APGWCO_PATH' ) || class_exists( 'Jcodex_Apgwco_Plugin', false ) ) {
     37    if ( ! function_exists( 'deactivate_plugins' ) ) {
     38        require_once ABSPATH . 'wp-admin/includes/plugin.php';
     39    }
     40    deactivate_plugins( plugin_basename( __FILE__ ), true );
     41    return;
     42}
     43
     44define( 'JCODEX_APGWCO_VERSION', '1.2.6' );
    3045define( 'JCODEX_APGWCO_PATH', plugin_dir_path( __FILE__ ) );
    3146define( 'JCODEX_APGWCO_URL', plugin_dir_url( __FILE__ ) );
    … …  
    3348define( 'JCODEX_APGWCO_PRO_URL', 'https://jcodex.com/plugins/jcodex-ai-product-generator-for-woocommerce/' );
    3449
     50if ( ! function_exists( 'jcodex_apgwco_activate' ) ) {
     51    function jcodex_apgwco_activate() {
     52        update_option( 'jcodex_apgwco_activation_redirect', 1, false );
     53    }
     54}
     55register_activation_hook( __FILE__, 'jcodex_apgwco_activate' );
     56
     57if ( ! function_exists( 'jcodex_apgwco_maybe_activation_redirect' ) ) {
     58    function jcodex_apgwco_maybe_activation_redirect() {
     59        if ( ! get_option( 'jcodex_apgwco_activation_redirect' ) ) {
     60            return;
     61        }
     62        if ( ! current_user_can( 'activate_plugins' ) ) {
     63            return;
     64        }
     65        if ( wp_doing_ajax() || ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) ) {
     66            return;
     67        }
     68        if ( is_network_admin() ) {
     69            return;
     70        }
     71        if ( defined( 'WP_CLI' ) && WP_CLI ) {
     72            return;
     73        }
     74        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- activation flag only.
     75        if ( isset( $_GET['activate-multi'] ) ) {
     76            return;
     77        }
     78        delete_option( 'jcodex_apgwco_activation_redirect' );
     79        wp_safe_redirect( admin_url( 'admin.php?page=jcodex-apgwco' ) );
     80        exit;
     81    }
     82}
     83add_action( 'admin_init', 'jcodex_apgwco_maybe_activation_redirect' );
     84
    3585/** AI product image generation — enabled in free version (10 uses). */
    3686define( 'JCODEX_APGWCO_IMAGE_ENABLED', true );
    … …  
    4090        $default = defined( 'JCODEX_APGWCO_IMAGE_ENABLED' ) && JCODEX_APGWCO_IMAGE_ENABLED;
    4191        return (bool) apply_filters( 'jcodex_apgwco_image_enabled', $default );
     92    }
     93}
     94
     95if ( ! function_exists( 'jcodex_apgwco_enqueue_admin_chrome' ) ) {
     96    /**
     97     * Load DM Sans and the shared type/color rules on plugin screens only.
     98     *
     99     * @param string $hook Current admin page hook.
     100     */
     101    function jcodex_apgwco_enqueue_admin_chrome( $hook ) {
     102        $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
     103        $is_plugin_page = ( 0 === strpos( $page, 'jcodex-apgwco' ) );
     104        $screen         = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
     105        $is_product     = $screen && ! empty( $screen->post_type ) && 'product' === $screen->post_type && in_array( $hook, array( 'post.php', 'post-new.php' ), true );
     106
     107        if ( ! $is_plugin_page && ! $is_product ) {
     108            return;
     109        }
     110
     111        wp_enqueue_style(
     112            'jcodex-apgwco-chrome',
     113            JCODEX_APGWCO_URL . 'assets/css/chrome.css',
     114            array(),
     115            JCODEX_APGWCO_VERSION
     116        );
     117    }
     118}
     119
     120if ( ! function_exists( 'jcodex_apgwco_admin_body_class' ) ) {
     121    /**
     122     * Mark plugin admin pages so chrome.css can scope the type scale.
     123     *
     124     * @param string $classes Existing body classes.
     125     * @return string
     126     */
     127    function jcodex_apgwco_admin_body_class( $classes ) {
     128        $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
     129        if ( 0 === strpos( $page, 'jcodex-apgwco' ) ) {
     130            $classes .= ' jcodex-apgwco-screen';
     131        }
     132        return $classes;
    42133    }
    43134}
    … …  
    54145require_once JCODEX_APGWCO_PATH . 'includes/class-language.php';
    55146require_once JCODEX_APGWCO_PATH . 'includes/class-brand-voice.php';
     147require_once JCODEX_APGWCO_PATH . 'includes/class-optimizer.php';
    56148require_once JCODEX_APGWCO_PATH . 'includes/class-api.php';
    57149
    … …  
    70162
    71163        private function __construct() {
     164            add_action( 'init', array( $this, 'load_textdomain' ), 0 );
    72165            add_action( 'plugins_loaded', array( $this, 'init' ) );
    73166        }
    … …  
    79172            }
    80173
    81             $this->load_textdomain();
     174            add_action( 'admin_enqueue_scripts', 'jcodex_apgwco_enqueue_admin_chrome', 99 );
     175            add_filter( 'admin_body_class', 'jcodex_apgwco_admin_body_class' );
    82176
    83177            new Jcodex_Apgwco_Admin();
    … …  
    87181            new Jcodex_Apgwco_Language();
    88182            new Jcodex_Apgwco_Brand_Voice();
     183            new Jcodex_Apgwco_Optimizer();
    89184            new Jcodex_Apgwco_API();
    90185            new Jcodex_Apgwco_Seo_Output();
  • jcodex-ai-product-generator-for-woocommerce/trunk/assets/css/admin.css

    r3724671 r3732385  
    3636    height: 36px;
    3737    border-radius: 10px;
    38     background: rgba(255,255,255,0.2);
     38    background: rgb(255, 255, 255);
    3939    color: #fff;
    4040    font-size: 11px;
     41    padding: 6px;
    4142    font-weight: 800;
    4243    letter-spacing: 0.05em;
    4344    flex-shrink: 0;
     45}
     46
     47.jcodex-apgwco-badge svg {
     48    width: 26px;
     49    height: 26px;
    4450}
    4551
    … …  
    8389.jcodex-apgwco-label {
    8490    display: block;
    85     font-size: 12px;
     91    font-size: 14px;
    8692    font-weight: 700;
    8793    color: #374151;
    … …  
    297303
    298304.jcodex-apgwco-btn:disabled,
    299 .jcodex-apgwco-btn.primary:disabled {
     305.jcodex-apgwco-btn.primary:disabled,
     306a.jcodex-apgwco-btn.jcodex-apgwco-btn-premium {
    300307    opacity: 0.45;
    301308    cursor: not-allowed;
    … …  
    304311    border: 1px solid #e5e7eb !important;
    305312    box-shadow: none !important;
     313    pointer-events: auto;
     314}
     315
     316a.jcodex-apgwco-btn.jcodex-apgwco-btn-premium:hover {
     317    background: #e5e7eb !important;
     318    color: #9ca3af !important;
    306319}
    307320
    … …  
    310323    font-size: 13px;
    311324    min-height: 20px;
    312     margin-bottom: 4px;
     325    margin: 10px 0 4px;
    313326}
    314327
    … …  
    428441.jcodex-apgwco-health-bar { height: 7px; overflow: hidden; border-radius: 99px; background: #dbeafe; }
    429442.jcodex-apgwco-health-bar i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #2563eb, #4f46e5); }
     443.jcodex-apgwco-health-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
     444.jcodex-apgwco-health-actions .jcodex-apgwco-btn { text-decoration: none; }
     445.jcodex-apgwco-health-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 24px; margin-top: 14px; }
     446.jcodex-apgwco-health-group h4 { margin: 0 0 8px; color: #1e3a8a; font-size: 12px; font-weight: 700; letter-spacing: .02em; text-transform: uppercase; }
     447.jcodex-apgwco-health-list { margin: 0; padding: 0; list-style: none; }
     448.jcodex-apgwco-health-list li { display: flex; align-items: flex-start; gap: 8px; margin: 0 0 6px; color: #334155; font-size: 13px; line-height: 1.35; }
     449.jcodex-apgwco-health-mark { width: 1em; flex-shrink: 0; font-weight: 700; line-height: 1.35; }
     450.jcodex-apgwco-health-list .is-ok { color: #166534; }
     451.jcodex-apgwco-health-list .is-ok .jcodex-apgwco-health-mark::before { content: "\2713"; color: #16a34a; }
     452.jcodex-apgwco-health-list .is-weak { color: #92400e; }
     453.jcodex-apgwco-health-list .is-weak .jcodex-apgwco-health-mark::before { content: "!"; color: #d97706; }
     454.jcodex-apgwco-health-list .is-missing { color: #991b1b; }
     455.jcodex-apgwco-health-list .is-missing .jcodex-apgwco-health-mark::before { content: "\2715"; color: #dc2626; }
     456.jcodex-apgwco-health-gaps { margin: 12px 0 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 6px; }
     457.jcodex-apgwco-health-gaps li { display: inline-flex; align-items: center; gap: 6px; margin: 0; padding: 4px 10px; border-radius: 999px; background: #fff; border: 1px solid #e2e8f0; color: #334155; font-size: 12px; }
     458.jcodex-apgwco-health-dot { width: 7px; height: 7px; border-radius: 50%; background: #94a3b8; flex-shrink: 0; }
     459.jcodex-apgwco-health-gaps .is-missing .jcodex-apgwco-health-dot { background: #dc2626; }
     460.jcodex-apgwco-health-gaps .is-weak .jcodex-apgwco-health-dot { background: #d97706; }
     461.jcodex-apgwco-health-gaps .is-manual { color: #64748b; border-style: dashed; }
     462.jcodex-apgwco-health-hint { margin: 8px 0 0; color: #64748b; font-size: 12px; }
     463.jcodex-apgwco-health-tips { margin: 10px 0 0; padding-left: 16px; color: #64748b; font-size: 12px; }
    430464.jcodex-apgwco-health-issues { margin: 8px 0 0; padding-left: 18px; color: #475569; font-size: 12px; }
    431465.jcodex-apgwco-health-issues li { margin: 7px 0; }
     466
     467@media (max-width: 782px) {
     468    .jcodex-apgwco-health-grid { grid-template-columns: 1fr; }
     469}
    432470
    433471@media (max-width: 600px) {
  • jcodex-ai-product-generator-for-woocommerce/trunk/assets/css/dashboard.css

    r3724671 r3732385  
    77.jcodex-apgwco-dashboard-card .jcodex-apgwco-dashboard-meta { font-size: 12px; color: #64748b; }
    88.jcodex-apgwco-dashboard-badge { padding: 2px 8px; border-radius: 999px; background: #dcfce7; color: #15803d; font-size: 11px; font-weight: 600; }
     9.jcodex-apgwco-dashboard-badge-pro { background: #e0e7ff; color: #1e40af; }
    910.jcodex-apgwco-dashboard-pro { border-color: #bbf7d0; background: #f0fdf4; }
     11.jcodex-apgwco-dashboard-card-locked {
     12        border-style: dashed;
     13        background: #f8fafc;
     14}
     15.jcodex-apgwco-dashboard-card-locked h2,
     16.jcodex-apgwco-dashboard-card-locked p {
     17        opacity: 0.72;
     18}
     19.jcodex-apgwco-dashboard-card-locked .button-primary {
     20        background: linear-gradient(135deg, #1e40af 0%, #4f46e5 100%);
     21        border-color: transparent;
     22        color: #fff;
     23}
    1024
    1125.jcodex-apgwco-language-card { max-width: 640px; margin-top: 18px; }
  • jcodex-ai-product-generator-for-woocommerce/trunk/assets/js/admin.js

    r3724671 r3732385  
    2222
    2323        function setLoading( btn ) {
     24            usageSyncStale = true;
    2425            btn.data( 'original-text', btn.html() );
    2526            btn.html( i18n.generating );
    … …  
    4546        }
    4647
    47         function updateLimitBadge( type, remaining ) {
    48             $( '[data-type="' + type + '"]' ).text( remaining );
     48        var usageSyncStale = false;
     49
     50        function updateLimitBadge( type, remaining, fromSync ) {
     51            remaining = parseInt( remaining, 10 );
     52            if ( isNaN( remaining ) ) {
     53                return;
     54            }
     55            if ( fromSync ) {
     56                if ( usageSyncStale ) {
     57                    return;
     58                }
     59                var current = parseInt( $( '.jcodex-apgwco-limit-badge[data-type="' + type + '"]' ).first().text(), 10 );
     60                if ( ! isNaN( current ) && remaining > current ) {
     61                    return;
     62                }
     63            } else {
     64                usageSyncStale = true;
     65            }
     66            var display = remaining;
     67            $( '.jcodex-apgwco-limit-badge[data-type="' + type + '"]' ).text( display );
     68            $( '.jcodex-apgwco-limit-badge-inline[data-type="' + type + '"]' ).text( display + ' ' + ( i18n.remaining || 'left' ) );
     69            $( '.jcodex-apgwco-image-remaining-number[data-type="' + type + '"]' ).text( display );
     70        }
     71
     72        function applyRemaining( type, res ) {
     73            if ( ! res || ! res.data || res.data.free_regenerate ) {
     74                return;
     75            }
     76            if ( typeof res.data.remaining === 'undefined' ) {
     77                return;
     78            }
     79            updateLimitBadge( type, res.data.remaining );
     80        }
     81
     82        if ( ajax.needs_usage_sync ) {
     83            $.post( ajaxUrl, {
     84                action: 'jcodex_apgwco_sync_usage',
     85                nonce: nonce
     86            }, function ( res ) {
     87                if ( usageSyncStale || ! res || ! res.success || ! res.data || ! res.data.remaining ) {
     88                    return;
     89                }
     90                $.each( res.data.remaining, function ( type, value ) {
     91                    updateLimitBadge( type, value, true );
     92                } );
     93            } );
    4994        }
    5095
    … …  
    186231
    187232                    showStatus( $status, i18n.descGenerated + bonusNote( res ), false, false );
    188                     updateLimitBadge( 'text', res.data.remaining );
     233                    applyRemaining( 'text', res );
    189234                } else {
    190235                    showStatus( $status, res.data.message, true, true );
    … …  
    237282
    238283                    showStatus( $status, i18n.shortDescApplied + bonusNote( res ), false, false );
    239                     updateLimitBadge( 'short_desc', res.data.remaining );
     284                    applyRemaining( 'short_desc', res );
    240285                } else {
    241286                    showStatus( $status, res.data.message, true, true );
    … …  
    275320                    $output.show();
    276321                    $status.html( bonusNote( res ).trim() );
    277                     updateLimitBadge( 'features', res.data.remaining );
     322                    applyRemaining( 'features', res );
    278323                } else {
    279324                    showStatus( $status, res.data.message, true, true );
    … …  
    314359                    $output.show();
    315360                    $status.html( bonusNote( res ).trim() );
    316                     updateLimitBadge( 'benefits', res.data.remaining );
     361                    applyRemaining( 'benefits', res );
    317362                } else {
    318363                    showStatus( $status, res.data.message, true, true );
    … …  
    363408                    $output.show();
    364409                    $status.html( bonusNote( res ).trim() );
    365                     updateLimitBadge( 'tags', res.data.remaining );
     410                    applyRemaining( 'tags', res );
    366411                } else {
    367412                    showStatus( $status, res.data.message, true, true );
    … …  
    447492                    $output.show();
    448493                    $status.html( bonusNote( res ).trim() );
    449                     updateLimitBadge( 'seo_title', res.data.remaining );
     494                    applyRemaining( 'seo_title', res );
    450495                } else {
    451496                    showStatus( $status, res.data.message, true, true );
    … …  
    685730                    $output.show();
    686731                    $status.html( bonusNote( res ).trim() );
    687                     updateLimitBadge( 'meta_desc', res.data.remaining );
     732                    applyRemaining( 'meta_desc', res );
    688733                } else {
    689734                    showStatus( $status, res.data.message, true, true );
    … …  
    779824                    $output.show();
    780825                    $status.html( bonusNote( res ).trim() );
    781                     updateLimitBadge( 'name_improve', res.data.remaining );
     826                    applyRemaining( 'name_improve', res );
    782827                } else {
    783828                    showStatus( $status, res.data.message, true, true );
    … …  
    810855        var healthResults = $( '#jcodex-apgwco-health-results' );
    811856
    812         function healthItem( item ) {
    813             return $( '<li />' ).text( item.message || item );
     857        function liveProductTitle() {
     858            var $classic = $( '#title' );
     859            if ( $classic.length ) {
     860                return $.trim( $classic.val() || '' );
     861            }
     862            var $block = $( '.editor-post-title__input' ).first();
     863            if ( $block.length ) {
     864                return $.trim( $block.val() || $block.text() || '' );
     865            }
     866            return '';
    814867        }
    815868
    816869        function renderHealth( analysis ) {
    817             if ( ! analysis || ! analysis.categories ) return;
     870            if ( ! analysis ) return;
    818871            healthScore.empty().append( document.createTextNode( analysis.overall_score ) ).append( $( '<small />' ).text( '/ 100' ) );
    819             healthSummary.text( analysis.overall_score >= 70 ? i18n.healthGood : i18n.healthNeedsWork );
     872            var groups = [
     873                { key: 'content', label: ( i18n.categoryLabels && i18n.categoryLabels.content ) || 'Content' },
     874                { key: 'seo', label: ( i18n.categoryLabels && i18n.categoryLabels.seo ) || 'SEO' },
     875                { key: 'images', label: ( i18n.categoryLabels && i18n.categoryLabels.images ) || 'Images' },
     876                { key: 'conversion', label: ( i18n.categoryLabels && i18n.categoryLabels.conversion ) || 'Conversion' }
     877            ];
     878            var gapCount = 0;
    820879            healthResults.empty();
    821 
    822             var $categories = $( '<div class="jcodex-apgwco-health-categories" />' );
    823             $.each( analysis.categories, function ( key, category ) {
    824                 var label = i18n.categoryLabels && i18n.categoryLabels[ key ] ? i18n.categoryLabels[ key ] : key;
    825                 var $row = $( '<div class="jcodex-apgwco-health-category" />' );
    826                 $row.append( $( '<span />' ).text( label ) );
    827                 $row.append( $( '<span class="jcodex-apgwco-health-bar" aria-hidden="true" />' ).append( $( '<i />' ).css( 'width', Math.max( 0, Math.min( 100, category.score ) ) + '%' ) ) );
    828                 $row.append( $( '<strong />' ).text( category.score + '/100' ) );
    829                 $categories.append( $row );
    830             } );
    831             healthResults.append( $categories );
    832 
    833             if ( analysis.priority_issues && analysis.priority_issues.length ) {
    834                 healthResults.append( $( '<h4 />' ).text( i18n.priorityIssues ) );
    835                 var $issues = $( '<ul class="jcodex-apgwco-health-issues" />' );
    836                 $.each( analysis.priority_issues, function ( _, item ) { $issues.append( healthItem( item ) ); } );
    837                 healthResults.append( $issues );
    838             }
    839             if ( analysis.quick_wins && analysis.quick_wins.length ) {
    840                 healthResults.append( $( '<h4 />' ).text( i18n.quickWins ) );
    841                 var $wins = $( '<ul class="jcodex-apgwco-health-issues" />' );
    842                 $.each( analysis.quick_wins, function ( _, item ) { $wins.append( healthItem( item ) ); } );
    843                 healthResults.append( $wins );
    844             }
     880            var $grid = $( '<div class="jcodex-apgwco-health-grid" />' );
     881            $.each( groups, function ( _, group ) {
     882                var items = ( analysis.checklist && analysis.checklist[ group.key ] ) || [];
     883                if ( ! items.length ) {
     884                    return;
     885                }
     886                var $section = $( '<section class="jcodex-apgwco-health-group" />' );
     887                $section.append( $( '<h4 />' ).text( group.label ) );
     888                var $list = $( '<ul class="jcodex-apgwco-health-list" />' );
     889                $.each( items, function ( __, item ) {
     890                    if ( ! item || ! item.label ) {
     891                        return;
     892                    }
     893                    var status = item.status || 'missing';
     894                    if ( status !== 'ok' ) {
     895                        gapCount++;
     896                    }
     897                    $list.append(
     898                        $( '<li />' )
     899                            .addClass( 'is-' + status )
     900                            .append( $( '<span class="jcodex-apgwco-health-mark" aria-hidden="true" />' ) )
     901                            .append( $( '<span />' ).text( item.label ) )
     902                    );
     903                } );
     904                $section.append( $list );
     905                $grid.append( $section );
     906            } );
     907            healthResults.append( $grid );
     908            healthSummary.text(
     909                gapCount
     910                    ? ( i18n.toFix || '%d to fix' ).replace( '%d', String( gapCount ) )
     911                    : ( i18n.allClear || i18n.healthGood )
     912            );
    845913            healthResults.prop( 'hidden', false );
    846914        }
    847915
     916        function setHealthButtonLabel( hasScore ) {
     917            healthButton.text( hasScore ? ( i18n.healthRecheck || 'Re-check' ) : ( i18n.healthCheck || 'Check' ) );
     918        }
     919
    848920        healthButton.on( 'click', function () {
    849             var original = healthButton.text();
     921            var previous = healthButton.text();
    850922            healthButton.prop( 'disabled', true ).text( i18n.analyzing );
    851923            healthStatus.empty();
    852             $.post( ajaxUrl, { action: 'jcodex_apgwco_analyze_product_health', nonce: nonce, post_id: ajax.post_id }, function ( res ) {
    853                 healthButton.prop( 'disabled', false ).text( original );
     924            $.post( ajaxUrl, { action: 'jcodex_apgwco_analyze_product_health', nonce: nonce, post_id: ajax.post_id, title: liveProductTitle() }, function ( res ) {
     925                healthButton.prop( 'disabled', false );
    854926                if ( res.success ) {
    855927                    renderHealth( res.data.analysis );
    856                     showStatus( healthStatus, res.data.analysis.ai_warning || i18n.analysisComplete, !! res.data.ai_warning, false );
    857                     updateLimitBadge( 'text', res.data.remaining );
     928                    setHealthButtonLabel( true );
     929                    if ( res.data.analysis.ai_warning ) {
     930                        showStatus( healthStatus, res.data.analysis.ai_warning, true, false );
     931                    }
     932                    applyRemaining( 'text', res );
    858933                } else {
     934                    healthButton.text( previous );
    859935                    showStatus( healthStatus, res.data && res.data.message ? res.data.message : i18n.analysisFailed, true, false );
    860936                }
    861937            } ).fail( function () {
    862                 healthButton.prop( 'disabled', false ).text( original );
     938                healthButton.prop( 'disabled', false ).text( previous );
    863939                showStatus( healthStatus, i18n.analysisFailed, true, false );
    864940            } );
    865941        } );
    866942
    867         if ( ajax.health && ajax.health.cached ) renderHealth( ajax.health.cached );
     943        // Health stays idle until the merchant clicks Check.
    868944
    869945        // ---------------------------------------------------------------
    … …  
    9461022        // Preset button click handler
    9471023        $( document ).on( 'click', '.jcodex-apgwco-preset-btn', function () {
    948             $( '.jcodex-apgwco-preset-btn' ).css( { 'background': '#fff', 'color': '#333', 'border-color': '#ddd' } );
    949             $( this ).css( { 'background': '#7c3aed', 'color': '#fff', 'border-color': '#7c3aed' } );
     1024            $( '.jcodex-apgwco-preset-btn' ).removeClass( 'is-active' );
     1025            $( this ).addClass( 'is-active' );
    9501026            $( '#jcodex-apgwco-style-preset' ).val( $( this ).data( 'preset' ) );
    9511027        } );
    … …  
    10321108                    updateProductFeaturedImage( res.data.attach_id, res.data.thumbnail_html );
    10331109                    showStatus( imageStatus, imageI18n.imageAdded + bonusNote( res ), false, false );
    1034                     updateLimitBadge( 'image', res.data.remaining );
     1110                    applyRemaining( 'image', res );
    10351111                } else {
    10361112                    showStatus( imageStatus, res.data.message, true, true );
  • jcodex-ai-product-generator-for-woocommerce/trunk/includes/class-admin-image.php

    r3724671 r3732385  
    8787                            <?php esc_html_e( 'Quick Style', 'jcodex-ai-product-generator-for-woocommerce' ); ?>
    8888                        </label>
    89                         <div class="jcodex-apgwco-style-presets" style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:8px;">
    90                             <button type="button" class="jcodex-apgwco-preset-btn" data-preset="ecommerce" style="padding:4px 10px;border:2px solid #7c3aed;background:#7c3aed;color:#fff;border-radius:4px;cursor:pointer;font-size:11px;">🛒 E-commerce</button>
    91                             <button type="button" class="jcodex-apgwco-preset-btn" data-preset="lifestyle" style="padding:4px 10px;border:2px solid #ddd;background:#fff;color:#333;border-radius:4px;cursor:pointer;font-size:11px;">✨ Lifestyle</button>
    92                             <button type="button" class="jcodex-apgwco-preset-btn" data-preset="luxury" style="padding:4px 10px;border:2px solid #ddd;background:#fff;color:#333;border-radius:4px;cursor:pointer;font-size:11px;">💎 Luxury</button>
    93                             <button type="button" class="jcodex-apgwco-preset-btn" data-preset="minimal" style="padding:4px 10px;border:2px solid #ddd;background:#fff;color:#333;border-radius:4px;cursor:pointer;font-size:11px;">⬜ Minimal</button>
     89                        <div class="jcodex-apgwco-style-presets">
     90                            <button type="button" class="jcodex-apgwco-preset-btn is-active" data-preset="ecommerce"><?php esc_html_e( 'E-commerce', 'jcodex-ai-product-generator-for-woocommerce' ); ?></button>
     91                            <button type="button" class="jcodex-apgwco-preset-btn" data-preset="lifestyle"><?php esc_html_e( 'Lifestyle', 'jcodex-ai-product-generator-for-woocommerce' ); ?></button>
     92                            <button type="button" class="jcodex-apgwco-preset-btn" data-preset="luxury"><?php esc_html_e( 'Luxury', 'jcodex-ai-product-generator-for-woocommerce' ); ?></button>
     93                            <button type="button" class="jcodex-apgwco-preset-btn" data-preset="minimal"><?php esc_html_e( 'Minimal', 'jcodex-ai-product-generator-for-woocommerce' ); ?></button>
    9494                        </div>
    9595                        <input type="hidden" id="jcodex-apgwco-style-preset" value="ecommerce" />
  • jcodex-ai-product-generator-for-woocommerce/trunk/includes/class-admin-menu.php

    r3724671 r3732385  
    122122                        }
    123123
     124                        $pro_url = defined('JCODEX_APGWCO_PRO_URL') ? JCODEX_APGWCO_PRO_URL : 'https://jcodex.com/plugins/jcodex-ai-product-generator-for-woocommerce/';
     125
    124126                        $tools = array(
     127                                array(
     128                                        'title' => __('AI Product Creator', 'jcodex-ai-product-generator-for-woocommerce'),
     129                                        'badge' => __('Pro', 'jcodex-ai-product-generator-for-woocommerce'),
     130                                        'desc' => __('Describe a new product in a few lines. Review the listing, then save it as a WooCommerce draft.', 'jcodex-ai-product-generator-for-woocommerce'),
     131                                        'meta' => __('Available in Pro.', 'jcodex-ai-product-generator-for-woocommerce'),
     132                                        'locked' => true,
     133                                ),
    125134                                array(
    126135                                        'title' => __('All Products Scanner', 'jcodex-ai-product-generator-for-woocommerce'),
    … …  
    139148                                        'button' => __('Open brand voice', 'jcodex-ai-product-generator-for-woocommerce'),
    140149                                ),
     150                                array(
     151                                        'title' => __('AI Bulk Generator', 'jcodex-ai-product-generator-for-woocommerce'),
     152                                        'badge' => __('Pro', 'jcodex-ai-product-generator-for-woocommerce'),
     153                                        'desc' => __('Generate descriptions, short descriptions, SEO titles, meta descriptions and tags for many products at once, or export the AI content as CSV.', 'jcodex-ai-product-generator-for-woocommerce'),
     154                                        'meta' => __('Available in Pro.', 'jcodex-ai-product-generator-for-woocommerce'),
     155                                        'locked' => true,
     156                                ),
    141157                        );
    142158
    … …  
    144160                         * Filter the tool cards shown on the plugin dashboard.
    145161                         *
    146                          * Each item: title, badge (optional), desc, meta (optional), url, button.
     162                         * Each item: title, badge (optional), desc, meta (optional), url, button, locked (optional).
    147163                         *
    148164                         * @param array $tools Tool cards.
    … …  
    161177                                        <?php foreach ((array) $tools as $tool): ?>
    162178                                                <?php
    163                                                 if (empty($tool['title']) || empty($tool['url'])) {
     179                                                $locked = !empty($tool['locked']);
     180                                                if (empty($tool['title']) || (!$locked && empty($tool['url']))) {
    164181                                                        continue;
    165182                                                }
     183                                                $badge_class = 'jcodex-apgwco-dashboard-badge';
     184                                                if ($locked || (isset($tool['badge']) && __('Pro', 'jcodex-ai-product-generator-for-woocommerce') === $tool['badge'])) {
     185                                                        $badge_class .= ' jcodex-apgwco-dashboard-badge-pro';
     186                                                }
     187                                                $card_class = 'jcodex-apgwco-dashboard-card';
     188                                                if ($locked) {
     189                                                        $card_class .= ' jcodex-apgwco-dashboard-card-locked';
     190                                                }
    166191                                                ?>
    167                                                 <div class="jcodex-apgwco-dashboard-card">
     192                                                <div class="<?php echo esc_attr($card_class); ?>">
    168193                                                        <h2>
    169194                                                                <?php echo esc_html($tool['title']); ?>
    170195                                                                <?php if (!empty($tool['badge'])): ?>
    171                                                                         <span class="jcodex-apgwco-dashboard-badge"><?php echo esc_html($tool['badge']); ?></span>
     196                                                                        <span class="<?php echo esc_attr($badge_class); ?>"><?php echo esc_html($tool['badge']); ?></span>
    172197                                                                <?php endif; ?>
    173198                                                        </h2>
    … …  
    178203                                                                <p class="jcodex-apgwco-dashboard-meta"><?php echo esc_html($tool['meta']); ?></p>
    179204                                                        <?php endif; ?>
    180                                                         <a class="button button-primary"
    181                                                                 href="<?php echo esc_url($tool['url']); ?>"><?php echo esc_html(!empty($tool['button']) ? $tool['button'] : __('Open', 'jcodex-ai-product-generator-for-woocommerce')); ?></a>
     205                                                        <?php if ($locked): ?>
     206                                                                <a class="button button-primary" href="<?php echo esc_url($pro_url); ?>" target="_blank" rel="noopener noreferrer"><?php esc_html_e('Get Pro', 'jcodex-ai-product-generator-for-woocommerce'); ?></a>
     207                                                        <?php else: ?>
     208                                                                <a class="button button-primary" href="<?php echo esc_url($tool['url']); ?>"><?php echo esc_html(!empty($tool['button']) ? $tool['button'] : __('Open', 'jcodex-ai-product-generator-for-woocommerce')); ?></a>
     209                                                        <?php endif; ?>
    182210                                                </div>
    183211                                        <?php endforeach; ?>
    … …  
    286314                {
    287315                        if ('' !== $this->dashboard_hook && $hook === $this->dashboard_hook) {
    288                                 wp_enqueue_style('jcodex-apgwco-dashboard-css', JCODEX_APGWCO_URL . 'assets/css/dashboard.css', array(), JCODEX_APGWCO_VERSION);
     316                                $dashboard_css = JCODEX_APGWCO_PATH . 'assets/css/dashboard.css';
     317                                wp_enqueue_style(
     318                                        'jcodex-apgwco-dashboard-css',
     319                                        JCODEX_APGWCO_URL . 'assets/css/dashboard.css',
     320                                        array(),
     321                                        file_exists($dashboard_css) ? (string) filemtime($dashboard_css) : JCODEX_APGWCO_VERSION
     322                                );
    289323                                return;
    290324                        }
  • jcodex-ai-product-generator-for-woocommerce/trunk/includes/class-admin.php

    r3724671 r3732385  
    7979
    8080        public function add_setting_links( $links ) {
    81             $pro_link = '<a href="' . esc_url( 'https://jcodex.com/plugins/jcodex-ai-product-generator-for-woocommerce/' ) . '" target="_blank" rel="noopener noreferrer" style="color:#16a34a;font-weight:600;">' . esc_html__( 'Get Pro', 'jcodex-ai-product-generator-for-woocommerce' ) . '</a>';
     81            $pro_link = '<a href="' . esc_url( 'https://jcodex.com/plugins/jcodex-ai-product-generator-for-woocommerce/' ) . '" target="_blank" rel="noopener noreferrer" style="color:#1e40af;font-weight:600;">' . esc_html__( 'Get Pro', 'jcodex-ai-product-generator-for-woocommerce' ) . '</a>';
    8282            $settings_link = '<a href="' . esc_url( admin_url( 'admin.php?page=' . Jcodex_Apgwco_Admin_Menu::MAIN_SLUG ) ) . '">' . esc_html__( 'Settings', 'jcodex-ai-product-generator-for-woocommerce' ) . '</a>';
    8383            array_unshift( $links, $settings_link );
    … …  
    106106                ? sprintf(
    107107                    /* translators: %s: active SEO plugin name. */
    108                     esc_html__( 'Review the draft above, then click "Apply to SEO Fields" to write it into your active SEO plugin (%s). Nothing is changed on your product until you do.', 'jcodex-ai-product-generator-for-woocommerce' ),
    109                     esc_html( $seo_plugin_name )
     108                    __( 'Review the draft above, then click "Apply to SEO Fields" to write it into your active SEO plugin (%s). Nothing is changed on your product until you do.', 'jcodex-ai-product-generator-for-woocommerce' ),
     109                    $seo_plugin_name
    110110                )
    111                 : esc_html__( 'No SEO plugin is active on this site. Click "Apply to SEO Fields" to copy the draft into the "Product SEO Fields" section below, where you can review or edit it — it’s only saved when you click Update/Publish, and it’s then used automatically on the product page.', 'jcodex-ai-product-generator-for-woocommerce' );
     111                : __( 'No SEO plugin is active on this site. Click "Apply to SEO Fields" to copy the draft into the "Product SEO Fields" section below, where you can review or edit it — it’s only saved when you click Update/Publish, and it’s then used automatically on the product page.', 'jcodex-ai-product-generator-for-woocommerce' );
    112112
    113113            $current_seo_title = get_post_meta( $post->ID, Jcodex_Apgwco_Product_Health::SEO_TITLE_META, true );
    … …  
    116116            $current_seo_desc  = is_string( $current_seo_desc ) ? trim( $current_seo_desc ) : '';
    117117            $has_seo_fields    = ( '' !== $current_seo_title || '' !== $current_seo_desc );
     118            $health_idle       = Jcodex_Apgwco_Product_Health::is_usable_title( get_the_title( $post->ID ) )
     119                ? __( 'Click Check to score this listing and see what is worth fixing.', 'jcodex-ai-product-generator-for-woocommerce' )
     120                : __( 'Name this product first. Health scores a real listing, not an empty draft.', 'jcodex-ai-product-generator-for-woocommerce' );
    118121            ?>
    119122
    … …  
    123126                <div class="jcodex-apgwco-header">
    124127                    <div class="jcodex-apgwco-header-left">
    125                         <span class="jcodex-apgwco-badge" aria-hidden="true">AI</span>
     128                        <span class="jcodex-apgwco-badge" aria-hidden="true">
     129                            <svg xmlns="http://www.w3.org/2000/svg" width="30" height="32" viewBox="0 0 30 32" fill="none"><path d="M6.45563 0H27.4409L19.7588 24.6342L8.97446 31.6569L0 19.2112L5.61801 14.6161L11.6548 23.1676L13.5604 21.7955L18.5622 5.77489H10.4911L6.45563 0Z" fill="#1B29AE"/></svg>
     130                        </span>
    126131                        <div>
    127                             <h2><?php esc_html_e( '✨ All-in-One AI Product Manager', 'jcodex-ai-product-generator-for-woocommerce' ); ?></h2>
     132                            <h2><?php esc_html_e( 'All-in-One AI Product Manager', 'jcodex-ai-product-generator-for-woocommerce' ); ?></h2>
    128133                            <p><?php esc_html_e( 'Generate all product content with AI in seconds', 'jcodex-ai-product-generator-for-woocommerce' ); ?></p>
    129134                        </div>
    130135                    </div>
    131136                    <a href="<?php echo esc_url( JCODEX_APGWCO_PRO_URL ); ?>" target="_blank" rel="noopener noreferrer" class="jcodex-apgwco-pro-link">
    132                         <?php esc_html_e( '⚡ Upgrade to Pro', 'jcodex-ai-product-generator-for-woocommerce' ); ?>
     137                        <?php esc_html_e( 'Upgrade to Pro', 'jcodex-ai-product-generator-for-woocommerce' ); ?>
    133138                    </a>
    134139                </div>
    … …  
    138143                    printf(
    139144                        /* translators: %d: number of days until the free monthly usage resets */
    140                         esc_html__( '🔄 Free plan: 10 generations per content type, every month. Resets in %d day(s). Try Regenerate with the same inputs — it won\'t use a credit.', 'jcodex-ai-product-generator-for-woocommerce' ),
     145                        esc_html__( 'Free plan: 10 generations per content type, every month. Resets in %d day(s). Try Regenerate with the same inputs — it won\'t use a credit.', 'jcodex-ai-product-generator-for-woocommerce' ),
    141146                        (int) $reset_days
    142147                    );
    … …  
    151156                            <div>
    152157                                <h3 id="jcodex-apgwco-health-title"><?php esc_html_e( 'AI Product Health', 'jcodex-ai-product-generator-for-woocommerce' ); ?></h3>
    153                                 <p id="jcodex-apgwco-health-summary"><?php esc_html_e( 'Review this product for practical content, SEO, image, and conversion improvements.', 'jcodex-ai-product-generator-for-woocommerce' ); ?></p>
     158                                <p id="jcodex-apgwco-health-summary"><?php echo esc_html( $health_idle ); ?></p>
    154159                            </div>
    155160                            <div id="jcodex-apgwco-health-score" class="jcodex-apgwco-health-score" aria-live="polite">--<small>/ 100</small></div>
    156161                        </div>
    157                         <button type="button" id="jcodex-apgwco-analyze-health" class="jcodex-apgwco-btn secondary"><?php esc_html_e( 'Analyze Product', 'jcodex-ai-product-generator-for-woocommerce' ); ?></button>
     162                        <div class="jcodex-apgwco-health-actions">
     163                            <button type="button" id="jcodex-apgwco-analyze-health" class="jcodex-apgwco-btn secondary"><?php esc_html_e( 'Check', 'jcodex-ai-product-generator-for-woocommerce' ); ?></button>
     164                            <a class="jcodex-apgwco-btn primary jcodex-apgwco-btn-premium" href="<?php echo esc_url( defined( 'JCODEX_APGWCO_PRO_URL' ) ? JCODEX_APGWCO_PRO_URL : 'https://jcodex.com/plugins/jcodex-ai-product-generator-for-woocommerce/' ); ?>" target="_blank" rel="noopener noreferrer" title="<?php esc_attr_e( 'Premium feature', 'jcodex-ai-product-generator-for-woocommerce' ); ?>" aria-disabled="true"><?php esc_html_e( 'Improve Product with AI', 'jcodex-ai-product-generator-for-woocommerce' ); ?></a>
     165                        </div>
    158166                        <div id="jcodex-apgwco-health-status" class="jcodex-apgwco-status" role="status" aria-live="polite"></div>
    159167                        <div id="jcodex-apgwco-health-results" hidden></div>
     168                        <div id="jcodex-apgwco-optimize-body" hidden></div>
    160169                    </section>
    161170
    … …  
    340349                                    <div id="jcodex-apgwco-seo-title-content" class="jcodex-apgwco-output-content jcodex-apgwco-plain-output"></div>
    341350                                    <div class="jcodex-apgwco-char-counter" id="jcodex-apgwco-seo-title-chars"></div>
    342                                     <p class="description" style="margin-top:6px;"><?php echo $seo_apply_help; ?></p>
     351                                    <p class="description" style="margin-top:6px;"><?php echo esc_html( $seo_apply_help ); ?></p>
    343352                                </div>
    344353                            </div>
    … …  
    363372                                    <div id="jcodex-apgwco-meta-desc-content" class="jcodex-apgwco-output-content jcodex-apgwco-plain-output"></div>
    364373                                    <div class="jcodex-apgwco-char-counter" id="jcodex-apgwco-meta-desc-chars"></div>
    365                                     <p class="description" style="margin-top:6px;"><?php echo $seo_apply_help; ?></p>
     374                                    <p class="description" style="margin-top:6px;"><?php echo esc_html( $seo_apply_help ); ?></p>
    366375                                </div>
    367376                            </div>
    … …  
    370379
    371380                        <?php if ( ! $seo_plugin_active ) : ?>
    372                         <div id="jcodex-apgwco-seo-fields-section" class="jcodex-apgwco-seo-fields-section" <?php echo $has_seo_fields ? '' : 'hidden'; ?>>
     381                        <div id="jcodex-apgwco-seo-fields-section" class="jcodex-apgwco-seo-fields-section"<?php echo $has_seo_fields ? '' : ' hidden'; ?>>
    373382                            <h4><?php esc_html_e( 'Product SEO Fields', 'jcodex-ai-product-generator-for-woocommerce' ); ?></h4>
    374383                            <p class="description">
    … …  
    434443                JCODEX_APGWCO_URL . 'assets/js/admin.js',
    435444                array( 'jquery', 'media-upload', 'media-views', 'set-post-thumbnail' ),
    436                 JCODEX_APGWCO_VERSION,
     445                file_exists( JCODEX_APGWCO_PATH . 'assets/js/admin.js' ) ? (string) filemtime( JCODEX_APGWCO_PATH . 'assets/js/admin.js' ) : JCODEX_APGWCO_VERSION,
    437446                true
    438447            );
    … …  
    446455                    'post_id'   => get_the_ID(),
    447456                    'remaining' => Jcodex_Apgwco_Limits::get_all_remaining(),
     457                    'needs_usage_sync' => ! Jcodex_Apgwco_Remote::has_usage_cache(),
    448458                    'health'    => array(
    449                         'cached' => ( new Jcodex_Apgwco_Product_Health() )->get_cached( get_the_ID() ),
     459                        'cached' => false,
    450460                    ),
    451461                    'i18n'      => array(
    … …  
    470480                        'describeFirst'    => esc_html__( 'Describe your product first to enable this AI tool.', 'jcodex-ai-product-generator-for-woocommerce' ),
    471481                        'fillFieldFirst'   => esc_html__( 'Please fill out this field first', 'jcodex-ai-product-generator-for-woocommerce' ),
    472                         'analyzing'        => esc_html__( 'Analyzing product...', 'jcodex-ai-product-generator-for-woocommerce' ),
    473                         'analysisComplete' => esc_html__( 'Analysis complete.', 'jcodex-ai-product-generator-for-woocommerce' ),
     482                        'healthNeedName'   => esc_html__( 'Name this product first. Health scores a real listing, not an empty draft.', 'jcodex-ai-product-generator-for-woocommerce' ),
     483                        'analyzing'        => esc_html__( 'Checking…', 'jcodex-ai-product-generator-for-woocommerce' ),
     484                        'healthCheck'      => esc_html__( 'Check', 'jcodex-ai-product-generator-for-woocommerce' ),
     485                        'healthRecheck'    => esc_html__( 'Re-check', 'jcodex-ai-product-generator-for-woocommerce' ),
     486                        'analysisComplete' => esc_html__( 'Updated.', 'jcodex-ai-product-generator-for-woocommerce' ),
    474487                        'analysisFailed'   => esc_html__( 'Product analysis could not be completed. Please try again.', 'jcodex-ai-product-generator-for-woocommerce' ),
    475                         'healthGood'       => esc_html__( 'Good — a few improvements can make this product stronger.', 'jcodex-ai-product-generator-for-woocommerce' ),
    476                         'healthNeedsWork'  => esc_html__( 'This product has important opportunities for improvement.', 'jcodex-ai-product-generator-for-woocommerce' ),
    477                         'priorityIssues'   => esc_html__( 'Priority Improvements', 'jcodex-ai-product-generator-for-woocommerce' ),
    478                         'quickWins'        => esc_html__( 'Quick wins', 'jcodex-ai-product-generator-for-woocommerce' ),
     488                        'healthGood'       => esc_html__( 'This listing looks solid.', 'jcodex-ai-product-generator-for-woocommerce' ),
     489                        /* translators: %d: number of health-score items still to fix. */
     490                        'toFix'            => esc_html__( '%d to fix', 'jcodex-ai-product-generator-for-woocommerce' ),
     491                        'allClear'         => esc_html__( 'Nothing obvious to fix.', 'jcodex-ai-product-generator-for-woocommerce' ),
     492                        'manualOnly'       => esc_html__( 'These need you: a focus keyword in your SEO plugin, extra photos, or a SKU. Improve with AI writes the rest.', 'jcodex-ai-product-generator-for-woocommerce' ),
    479493                        'categoryLabels'   => array(
    480494                            'product_information' => esc_html__( 'Product Information', 'jcodex-ai-product-generator-for-woocommerce' ),
  • jcodex-ai-product-generator-for-woocommerce/trunk/includes/class-api.php

    r3724671 r3732385  
    2525        /** @var string Usage bucket of the request currently being processed. */
    2626        private $current_type = 'text';
     27
     28        /** @var bool False when this click is a same-prompt free regenerate. */
     29        private $expect_charge = true;
    2730
    2831        public function __construct() {
    … …  
    3841            add_action( 'wp_ajax_jcodex_apgwco_generate_product_image', array( $this, 'generate_product_image' ) );
    3942            add_action( 'wp_ajax_jcodex_apgwco_analyze_product_health', array( $this, 'analyze_product_health' ) );
    40         }
    41 
    42         /**
    43          * Analyze a product only when an editor asks for it. Deterministic
    44          * checks are cached by a product-content checksum; the optional AI
    45          * review uses the existing text-generation allowance for free users.
     43            add_action( 'wp_ajax_jcodex_apgwco_sync_usage',             array( $this, 'ajax_sync_usage' ) );
     44        }
     45
     46        /**
     47         * Analyze a product only when an editor asks for it. The checklist
     48         * is deterministic and unlimited; it never consumes text credits.
    4649         */
    4750        public function analyze_product_health() {
    … …  
    5457
    5558            $health = new Jcodex_Apgwco_Product_Health();
    56             $cached = $health->get_cached( $product_id );
    57             if ( $cached ) {
    58                 wp_send_json_success( array( 'analysis' => $cached, 'cached' => true, 'remaining' => Jcodex_Apgwco_Limits::get_remaining( 'text' ) ) );
    59             }
    60 
    61             $analysis = $health->build( $product_id );
     59            $overrides = array();
     60            if ( isset( $_POST['title'] ) ) {
     61                $live_title = sanitize_text_field( wp_unslash( $_POST['title'] ) );
     62                if ( Jcodex_Apgwco_Product_Health::is_usable_title( $live_title ) ) {
     63                    $overrides['title'] = $live_title;
     64                }
     65            }
     66
     67            $analysis = $health->build( $product_id, $overrides );
    6268            if ( is_wp_error( $analysis ) ) {
    6369                wp_send_json_error( array( 'message' => esc_html( $analysis->get_error_message() ) ) );
    6470            }
    6571
    66             $charge_quota = $this->resolve_charge( 'text' );
    67             $context      = $analysis['product_context'];
    6872            unset( $analysis['product_context'] );
    6973
    70             $prompt = sprintf(
    71                 "Analyze this WooCommerce product for quality, SEO, customer clarity, persuasiveness and conversion readiness. Return JSON only: no Markdown, no code fences, no explanation. The JSON must exactly contain overall_score (integer 0-100), categories with product_information, content, seo, images, attributes and conversion; each category must contain score (integer 0-100), issues (array of short strings) and recommendations (array of short strings), plus priority_issues and quick_wins arrays of short strings. Do not invent product facts.\n\nProduct data:\n%s",
    72                 wp_json_encode( $context )
    73             );
    74             $res = $this->call_remote_api( '/generate-text', array( 'prompt' => $prompt ), false );
    75 
    76             if ( ! empty( $res['error'] ) ) {
    77                 // The deterministic result remains useful when the AI service is unavailable.
    78                 $analysis['ai_warning'] = esc_html__( 'AI insights are temporarily unavailable. Showing the standard product checks instead.', 'jcodex-ai-product-generator-for-woocommerce' );
    79                 wp_send_json_success( array( 'analysis' => $analysis, 'cached' => false, 'ai_warning' => true, 'remaining' => Jcodex_Apgwco_Limits::get_remaining( 'text' ) ) );
    80             }
    81 
    82             $ai = $this->parse_health_json( isset( $res['content'] ) ? $res['content'] : '' );
    83             $merged_analysis = is_wp_error( $ai ) ? false : $health->merge_ai_analysis( $analysis, $ai );
    84             if ( false === $merged_analysis ) {
    85                 $analysis['ai_warning'] = esc_html__( 'AI returned an invalid analysis. Showing the standard product checks instead.', 'jcodex-ai-product-generator-for-woocommerce' );
    86                 wp_send_json_success( array( 'analysis' => $analysis, 'cached' => false, 'ai_warning' => true, 'remaining' => Jcodex_Apgwco_Limits::get_remaining( 'text' ) ) );
    87             }
    88 
    89             $analysis = $merged_analysis;
    90             $this->finalize_charge( 'text', $charge_quota );
    91             $health->save( $product_id, $analysis );
     74            $db_title  = get_the_title( $product_id );
     75            $can_cache = empty( $overrides ) || ( isset( $overrides['title'] ) && $overrides['title'] === $db_title );
     76            if ( $can_cache ) {
     77                $health->save( $product_id, $analysis );
     78            }
     79
    9280            wp_send_json_success( array( 'analysis' => $analysis, 'cached' => false, 'remaining' => Jcodex_Apgwco_Limits::get_remaining( 'text' ) ) );
     81        }
     82
     83        /**
     84         * Refresh the usage badges after install/reinstall without blocking the editor render.
     85         */
     86        public function ajax_sync_usage() {
     87            $this->check_permission();
     88            Jcodex_Apgwco_Remote::sync_usage();
     89            wp_send_json_success( array( 'remaining' => Jcodex_Apgwco_Limits::get_all_remaining() ) );
    9390        }
    9491
    … …  
    128125            // Signed, per-site request. Quota is enforced on the Jcodex server.
    129126            $timeout = ( '/generate-image' === $endpoint ) ? 120 : 90;
    130             return Jcodex_Apgwco_Remote::request( $endpoint, $body, $this->current_type, $timeout );
     127            return Jcodex_Apgwco_Remote::request( $endpoint, $body, $this->current_type, $timeout, true, $this->expect_charge );
     128        }
     129
     130        /**
     131         * Require a non-empty text payload. Escapes the error once for JS .html().
     132         *
     133         * @param array $res Remote response.
     134         * @return string
     135         */
     136        private function require_text_content( $res ) {
     137            if ( ! is_array( $res ) ) {
     138                wp_send_json_error( array(
     139                    'message' => esc_html__( 'The AI service could not be reached. Please try again.', 'jcodex-ai-product-generator-for-woocommerce' ),
     140                ) );
     141            }
     142            if ( ! empty( $res['error'] ) ) {
     143                wp_send_json_error( array( 'message' => esc_html( (string) $res['error'] ) ) );
     144            }
     145            if ( ! isset( $res['content'] ) || ! is_string( $res['content'] ) || '' === trim( $res['content'] ) ) {
     146                wp_send_json_error( array(
     147                    'message' => esc_html__( 'The AI returned empty content. Please try again.', 'jcodex-ai-product-generator-for-woocommerce' ),
     148                ) );
     149            }
     150            return $res['content'];
    131151        }
    132152
    … …  
    137157            check_ajax_referer( 'jcodex_apgwco_nonce', 'nonce' );
    138158
    139             if ( ! current_user_can( 'edit_posts' ) ) {
     159            if ( ! current_user_can( 'edit_products' ) ) {
    140160                wp_send_json_error( array(
    141161                    'message' => esc_html__( 'You do not have permission to perform this action.', 'jcodex-ai-product-generator-for-woocommerce' ),
    … …  
    184204         */
    185205        private function resolve_charge( $type ) {
    186             $this->current_type = $type;
    187             $fingerprint = $this->get_request_fingerprint();
     206            $this->current_type  = $type;
     207            $this->expect_charge = true;
     208            $fingerprint         = $this->get_request_fingerprint();
    188209
    189210            if ( ! Jcodex_Apgwco_Limits::resolve_charge( $type, $fingerprint ) ) {
     211                $this->expect_charge = false;
    190212                return false; // Free regenerate — same inputs, within window, not yet used.
    191213            }
    … …  
    203225         */
    204226        private function finalize_charge( $type, $charge_quota ) {
    205             if ( $charge_quota ) {
     227            if ( $charge_quota && Jcodex_Apgwco_Remote::last_was_charged() ) {
    206228                Jcodex_Apgwco_Limits::increase_usage( $type );
    207229            }
    … …  
    335357            );
    336358
    337             $res = $this->call_remote_api( '/generate-text', array( 'prompt' => $final_prompt ) );
    338 
    339             if ( ! empty( $res['error'] ) ) {
    340                 wp_send_json_error( array( 'message' => esc_html( $res['error'] ) ) );
    341             }
     359            $content = $this->require_text_content( $this->call_remote_api( '/generate-text', array( 'prompt' => $final_prompt ) ) );
    342360
    343361            $this->finalize_charge( 'text', $charge_quota );
    … …  
    345363            wp_send_json_success( array(
    346364                'free_regenerate' => ! $charge_quota,
    347                 'content'   => wp_kses_post( $res['content'] ),
     365                'content'   => wp_kses_post( $content ),
    348366                'remaining' => Jcodex_Apgwco_Limits::get_remaining( 'text' ),
    349367            ) );
    … …  
    367385            );
    368386
    369             $res = $this->call_remote_api( '/generate-text', array( 'prompt' => $final_prompt ) );
    370 
    371             if ( ! empty( $res['error'] ) ) {
    372                 wp_send_json_error( array( 'message' => esc_html( $res['error'] ) ) );
    373             }
     387            $content = $this->require_text_content( $this->call_remote_api( '/generate-text', array( 'prompt' => $final_prompt ) ) );
    374388
    375389            $this->finalize_charge( 'short_desc', $charge_quota );
    … …  
    377391            wp_send_json_success( array(
    378392                'free_regenerate' => ! $charge_quota,
    379                 'content'   => wp_kses_post( $res['content'] ),
     393                'content'   => wp_kses_post( $content ),
    380394                'remaining' => Jcodex_Apgwco_Limits::get_remaining( 'short_desc' ),
    381395            ) );
    … …  
    397411            );
    398412
    399             $res = $this->call_remote_api( '/generate-text', array( 'prompt' => $final_prompt ) );
    400 
    401             if ( ! empty( $res['error'] ) ) {
    402                 wp_send_json_error( array( 'message' => esc_html( $res['error'] ) ) );
    403             }
     413            $content = $this->require_text_content( $this->call_remote_api( '/generate-text', array( 'prompt' => $final_prompt ) ) );
    404414
    405415            $this->finalize_charge( 'features', $charge_quota );
    … …  
    407417            wp_send_json_success( array(
    408418                'free_regenerate' => ! $charge_quota,
    409                 'content'   => wp_kses_post( $res['content'] ),
     419                'content'   => wp_kses_post( $content ),
    410420                'remaining' => Jcodex_Apgwco_Limits::get_remaining( 'features' ),
    411421            ) );
    … …  
    428438            );
    429439
    430             $res = $this->call_remote_api( '/generate-text', array( 'prompt' => $final_prompt ) );
    431 
    432             if ( ! empty( $res['error'] ) ) {
    433                 wp_send_json_error( array( 'message' => esc_html( $res['error'] ) ) );
    434             }
     440            $content = $this->require_text_content( $this->call_remote_api( '/generate-text', array( 'prompt' => $final_prompt ) ) );
    435441
    436442            $this->finalize_charge( 'benefits', $charge_quota );
    … …  
    438444            wp_send_json_success( array(
    439445                'free_regenerate' => ! $charge_quota,
    440                 'content'   => wp_kses_post( $res['content'] ),
     446                'content'   => wp_kses_post( $content ),
    441447                'remaining' => Jcodex_Apgwco_Limits::get_remaining( 'benefits' ),
    442448            ) );
    … …  
    458464            );
    459465
    460             $res = $this->call_remote_api( '/generate-text', array( 'prompt' => $final_prompt ) );
    461 
    462             if ( ! empty( $res['error'] ) ) {
    463                 wp_send_json_error( array( 'message' => esc_html( $res['error'] ) ) );
    464             }
     466            $content = $this->require_text_content( $this->call_remote_api( '/generate-text', array( 'prompt' => $final_prompt ) ) );
    465467
    466468            // Strip any HTML from tags response
    467             $content = wp_strip_all_tags( $res['content'] );
     469            $content = wp_strip_all_tags( $content );
    468470
    469471            $this->finalize_charge( 'tags', $charge_quota );
    … …  
    491493            );
    492494
    493             $res = $this->call_remote_api( '/generate-text', array( 'prompt' => $final_prompt ) );
    494 
    495             if ( ! empty( $res['error'] ) ) {
    496                 wp_send_json_error( array( 'message' => esc_html( $res['error'] ) ) );
    497             }
    498 
    499             $content = sanitize_text_field( wp_strip_all_tags( $res['content'] ) );
     495            $content = sanitize_text_field( wp_strip_all_tags( $this->require_text_content( $this->call_remote_api( '/generate-text', array( 'prompt' => $final_prompt ) ) ) ) );
    500496
    501497            $this->finalize_charge( 'seo_title', $charge_quota );
    … …  
    523519            );
    524520
    525             $res = $this->call_remote_api( '/generate-text', array( 'prompt' => $final_prompt ) );
    526 
    527             if ( ! empty( $res['error'] ) ) {
    528                 wp_send_json_error( array( 'message' => esc_html( $res['error'] ) ) );
    529             }
    530 
    531             $content = sanitize_text_field( wp_strip_all_tags( $res['content'] ) );
     521            $content = sanitize_text_field( wp_strip_all_tags( $this->require_text_content( $this->call_remote_api( '/generate-text', array( 'prompt' => $final_prompt ) ) ) ) );
    532522
    533523            $this->finalize_charge( 'meta_desc', $charge_quota );
    … …  
    555545            );
    556546
    557             $res = $this->call_remote_api( '/generate-text', array( 'prompt' => $final_prompt ) );
    558 
    559             if ( ! empty( $res['error'] ) ) {
    560                 wp_send_json_error( array( 'message' => esc_html( $res['error'] ) ) );
    561             }
    562 
    563             $content = sanitize_textarea_field( wp_strip_all_tags( $res['content'] ) );
     547            $content = sanitize_textarea_field( wp_strip_all_tags( $this->require_text_content( $this->call_remote_api( '/generate-text', array( 'prompt' => $final_prompt ) ) ) ) );
    564548
    565549            $this->finalize_charge( 'name_improve', $charge_quota );
    … …  
    577561
    578562        public function generate_product_image() {
    579             check_ajax_referer( 'jcodex_apgwco_nonce', 'nonce' );
    580 
    581             if ( ! current_user_can( 'edit_posts' ) ) {
    582                 wp_send_json_error( array(
    583                     'message' => esc_html__( 'You do not have permission to perform this action.', 'jcodex-ai-product-generator-for-woocommerce' ),
    584                 ) );
    585             }
     563            $this->check_permission();
    586564
    587565            $charge_quota = $this->resolve_charge( 'image' );
    … …  
    618596
    619597            if ( $source_attachment_id > 0 ) {
    620                 if ( ! current_user_can( 'read_post', $source_attachment_id ) ) {
     598                $can_use_source = current_user_can( 'edit_post', $source_attachment_id )
     599                    || ( current_user_can( 'upload_files' ) && ( $post_id < 1 || current_user_can( 'edit_post', $post_id ) ) );
     600                if ( ! $can_use_source ) {
    621601                    wp_send_json_error( array( 'message' => esc_html__( 'You do not have permission to use this image.', 'jcodex-ai-product-generator-for-woocommerce' ) ) );
    622602                }
    … …  
    652632            }
    653633            $alt_text = wp_strip_all_tags( $alt_text );
    654             if ( strlen( $alt_text ) > 125 ) {
     634            if ( class_exists( 'Jcodex_Apgwco_Language' ) && Jcodex_Apgwco_Language::text_len( $alt_text ) > 125 ) {
     635                $alt_text = Jcodex_Apgwco_Language::text_cut( $alt_text, 125 );
     636            } elseif ( strlen( $alt_text ) > 125 ) {
    655637                $alt_text = substr( $alt_text, 0, 125 );
    656638            }
    … …  
    753735        private function create_optimized_source_file( $original_path, $attachment_id ) {
    754736            require_once ABSPATH . 'wp-admin/includes/image.php';
     737            $upload = wp_get_upload_dir();
     738            if ( ! empty( $upload['error'] ) || empty( $upload['basedir'] ) || ! is_dir( $upload['basedir'] ) ) {
     739                return new WP_Error( 'source_too_large', esc_html__( 'Could not prepare the source image.', 'jcodex-ai-product-generator-for-woocommerce' ) );
     740            }
     741
    755742            $dimensions = array( self::SOURCE_MAX_DIMENSION, 1600, 1280, 1024, 800 );
    756743
    757744            foreach ( $dimensions as $max_dim ) {
    758745                $editor = wp_get_image_editor( $original_path );
    759                 if ( is_wp_error( $editor ) ) continue;
     746                if ( is_wp_error( $editor ) ) {
     747                    continue;
     748                }
    760749
    761750                $editor->resize( $max_dim, $max_dim, false );
    762751                $editor->set_quality( 82 );
    763                 $saved = $editor->save();
    764 
    765                 if ( is_wp_error( $saved ) || empty( $saved['path'] ) || ! is_readable( $saved['path'] ) ) continue;
     752                $dest  = trailingslashit( $upload['basedir'] ) . wp_unique_filename( $upload['basedir'], 'jcodex-apgwco-src-' . wp_generate_password( 8, false ) . '.jpg' );
     753                $saved = $editor->save( $dest, 'image/jpeg' );
     754
     755                if ( is_wp_error( $saved ) || empty( $saved['path'] ) || ! is_readable( $saved['path'] ) ) {
     756                    continue;
     757                }
    766758
    767759                $filesize = filesize( $saved['path'] );
    768760                if ( false !== $filesize && $filesize > 0 && $filesize <= self::SOURCE_MAX_BYTES ) {
    769                     $mime = ! empty( $saved['mime-type'] ) ? $saved['mime-type'] : $this->get_image_mime_type( $saved['path'], $attachment_id );
     761                    $mime = ! empty( $saved['mime-type'] ) ? $saved['mime-type'] : 'image/jpeg';
    770762                    return array( 'path' => $saved['path'], 'mime' => $mime, 'temp' => true );
    771763                }
    … …  
    784776            }
    785777
    786             $allowed = array( 'image/jpeg', 'image/png', 'image/webp', 'image/gif' );
     778            $allowed = array( 'image/jpeg', 'image/png', 'image/webp' );
    787779            if ( ! in_array( $resolved['mime'], $allowed, true ) ) {
    788780                return array( 'error' => esc_html__( 'Please upload a JPEG, PNG, or WebP image.', 'jcodex-ai-product-generator-for-woocommerce' ) );
    … …  
    836828            // 2. PHP getimagesizefromstring
    837829            if ( empty( $mime ) && function_exists( 'getimagesizefromstring' ) ) {
    838                 $img_info = @getimagesizefromstring( $image_data );
     830                $img_info = getimagesizefromstring( $image_data );
    839831                if ( ! empty( $img_info['mime'] ) ) {
    840832                    $mime = $img_info['mime'];
  • jcodex-ai-product-generator-for-woocommerce/trunk/includes/class-brand-voice.php

    r3724671 r3732385  
    451451
    452452                                $text = trim( preg_replace( '/\s+/u', ' ', wp_strip_all_tags( strip_shortcodes( $post->post_content ) ) ) );
    453                                 if ( str_word_count( $text ) < 30 ) {
     453                                if ( Jcodex_Apgwco_Language::word_count( $text ) < 30 ) {
    454454                                        continue;
    455455                                }
    … …  
    457457                                $samples[] = array(
    458458                                        'title' => wp_strip_all_tags( get_the_title( $product_id ) ),
    459                                         'text'  => wp_trim_words( $text, 90, '' ),
     459                                        'text'  => Jcodex_Apgwco_Language::trim_words( $text, 90 ),
    460460                                );
    461461                        }
  • jcodex-ai-product-generator-for-woocommerce/trunk/includes/class-content-scanner.php

    r3724671 r3732385  
    652652                        }
    653653
    654                         if ( ! $has_faq && preg_match( '/faq|frequently\s+asked\s+questions/i', wp_strip_all_tags( $raw_content ) ) ) {
     654                        if ( ! $has_faq && class_exists( 'Jcodex_Apgwco_Language' ) ) {
     655                                $plain = wp_strip_all_tags( $raw_content );
     656                                foreach ( Jcodex_Apgwco_Language::faq_text_markers() as $marker ) {
     657                                        if ( Jcodex_Apgwco_Language::text_contains( $plain, $marker ) ) {
     658                                                $has_faq = true;
     659                                                break;
     660                                        }
     661                                }
     662                        } elseif ( ! $has_faq && preg_match( '/faq|frequently\s+asked\s+questions/i', wp_strip_all_tags( $raw_content ) ) ) {
    655663                                $has_faq = true;
    656664                        }
    … …  
    969977                        }
    970978
     979                        $owner = isset( $state['user_id'] ) ? (int) $state['user_id'] : 0;
     980                        if ( $owner && $owner !== get_current_user_id() && ! current_user_can( 'manage_woocommerce' ) ) {
     981                                wp_send_json_error( array( 'message' => esc_html__( 'This scan is no longer active. Please start a new scan.', 'jcodex-ai-product-generator-for-woocommerce' ) ) );
     982                        }
     983
    971984                        return $state;
    972985                }
  • jcodex-ai-product-generator-for-woocommerce/trunk/includes/class-language.php

    r3724671 r3732385  
    101101                                'fr_CA'  => 'Canadian French',
    102102                                'de_CH'  => 'Swiss German',
     103                                'pa'     => 'Punjabi',
     104                                'pa_IN'  => 'Punjabi (India)',
     105                                'sw'     => 'Swahili',
     106                        );
     107                }
     108
     109                /**
     110                 * Character length that treats å, ä, ö, Urdu, and Arabic as one character each.
     111                 *
     112                 * @param string $text Text.
     113                 * @return int
     114                 */
     115                public static function text_len( $text ) {
     116                        $text = (string) $text;
     117                        if ( function_exists( 'mb_strlen' ) ) {
     118                                return (int) mb_strlen( $text, 'UTF-8' );
     119                        }
     120                        if ( function_exists( 'iconv_strlen' ) ) {
     121                                $len = @iconv_strlen( $text, 'UTF-8' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
     122                                if ( false !== $len ) {
     123                                        return (int) $len;
     124                                }
     125                        }
     126                        return strlen( $text );
     127                }
     128
     129                /**
     130                 * Lowercase that understands non-ASCII letters.
     131                 *
     132                 * @param string $text Text.
     133                 * @return string
     134                 */
     135                public static function text_lower( $text ) {
     136                        $text = (string) $text;
     137                        if ( function_exists( 'mb_strtolower' ) ) {
     138                                return mb_strtolower( $text, 'UTF-8' );
     139                        }
     140                        return strtolower( $text );
     141                }
     142
     143                /**
     144                 * Cut a string to a character count without splitting a multibyte glyph.
     145                 *
     146                 * @param string $text Text.
     147                 * @param int    $max  Max characters.
     148                 * @return string
     149                 */
     150                public static function text_cut( $text, $max ) {
     151                        $text = (string) $text;
     152                        $max  = (int) $max;
     153                        if ( $max < 1 || self::text_len( $text ) <= $max ) {
     154                                return $text;
     155                        }
     156                        if ( function_exists( 'mb_substr' ) ) {
     157                                return mb_substr( $text, 0, $max, 'UTF-8' );
     158                        }
     159                        return substr( $text, 0, $max );
     160                }
     161
     162                /**
     163                 * Case-insensitive substring match for UTF-8 text.
     164                 *
     165                 * @param string $haystack Haystack.
     166                 * @param string $needle   Needle.
     167                 * @return bool
     168                 */
     169                public static function text_contains( $haystack, $needle ) {
     170                        $haystack = self::text_lower( $haystack );
     171                        $needle   = self::text_lower( $needle );
     172                        if ( '' === $needle ) {
     173                                return false;
     174                        }
     175                        if ( function_exists( 'mb_strpos' ) ) {
     176                                return false !== mb_strpos( $haystack, $needle, 0, 'UTF-8' );
     177                        }
     178                        return false !== strpos( $haystack, $needle );
     179                }
     180
     181                /**
     182                 * Word count that works for Latin, Arabic, Urdu, and CJK.
     183                 *
     184                 * @param string $text Text.
     185                 * @return int
     186                 */
     187                public static function word_count( $text ) {
     188                        $text = trim( (string) $text );
     189                        if ( '' === $text ) {
     190                                return 0;
     191                        }
     192                        if ( preg_match( '/[\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}]/u', $text ) ) {
     193                                $copy = preg_replace( '/[^\p{L}\p{N}]+/u', '', $text );
     194                                $len  = self::text_len( is_string( $copy ) ? $copy : $text );
     195                                return (int) max( 1, (int) floor( $len / 2 ) );
     196                        }
     197                        if ( preg_match_all( '/[\p{L}\p{N}]+/u', $text, $matches ) ) {
     198                                return count( $matches[0] );
     199                        }
     200                        return 0;
     201                }
     202
     203                /**
     204                 * Trim to N words (or ~2N CJK characters) without str_word_count().
     205                 *
     206                 * @param string $text Text.
     207                 * @param int    $num  Word limit.
     208                 * @return string
     209                 */
     210                public static function trim_words( $text, $num ) {
     211                        $text = trim( (string) $text );
     212                        $num  = max( 1, (int) $num );
     213                        if ( '' === $text ) {
     214                                return '';
     215                        }
     216                        if ( preg_match( '/[\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}]/u', $text ) ) {
     217                                return self::text_cut( $text, $num * 2 );
     218                        }
     219                        if ( ! preg_match_all( '/[\p{L}\p{N}]+/u', $text, $matches, PREG_OFFSET_CAPTURE ) ) {
     220                                return $text;
     221                        }
     222                        if ( count( $matches[0] ) <= $num ) {
     223                                return $text;
     224                        }
     225                        $last = $matches[0][ $num - 1 ];
     226                        $end  = (int) $last[1] + strlen( $last[0] );
     227                        return rtrim( substr( $text, 0, $end ) );
     228                }
     229
     230                /**
     231                 * Language-agnostic FAQ heading / phrase markers.
     232                 *
     233                 * @return string[]
     234                 */
     235                public static function faq_text_markers() {
     236                        return array(
     237                                'faq',
     238                                'frequently asked',
     239                                '"FAQPage"',
     240                                '<details',
     241                                'vanliga frågor',
     242                                'frågor och svar',
     243                                'häufige fragen',
     244                                'häufig gestellte',
     245                                'preguntas frecuentes',
     246                                'questions fréquentes',
     247                                'foire aux questions',
     248                                'perguntas frequentes',
     249                                'domande frequenti',
     250                                'veelgestelde vragen',
     251                                'często zadawane',
     252                                'sık sorulan',
     253                                'часто задаваем',
     254                                'الأسئلة الشائعة',
     255                                'الاسئلة الشائعة',
     256                                'سوالات متداول',
     257                                'عمومی سوالات',
     258                                'اکثر پوچھے گئے',
     259                                '常见问题',
     260                                '常見問題',
     261                                'よくある質問',
     262                                '자주 묻는',
     263                                'คำถามที่พบบ่อย',
     264                        );
     265                }
     266
     267                /**
     268                 * Language-agnostic benefit / "why buy" markers.
     269                 *
     270                 * @return string[]
     271                 */
     272                public static function benefits_text_markers() {
     273                        return array(
     274                                'benefit',
     275                                'benefits',
     276                                "you'll love",
     277                                'you will love',
     278                                'why you',
     279                                'fördel',
     280                                'fördelar',
     281                                'vorteil',
     282                                'vorteile',
     283                                'avantage',
     284                                'avantages',
     285                                'ventaja',
     286                                'ventajas',
     287                                'voordeel',
     288                                'voordelen',
     289                                'fordel',
     290                                'fordeler',
     291                                'vantaggi',
     292                                'korzyść',
     293                                'korzyści',
     294                                'avantaj',
     295                                'преимуществ',
     296                                'выгод',
     297                                'فوائد',
     298                                'فائدة',
     299                                'فائدے',
     300                                'ประโยชน์',
     301                                '优点',
     302                                '好處',
     303                                '好处',
     304                                'メリット',
     305                                '利点',
     306                                '장점',
     307                                '혜택',
     308                                'hyöty',
     309                                'hyödyt',
    103310                        );
    104311                }
  • jcodex-ai-product-generator-for-woocommerce/trunk/includes/class-limits.php

    r3724671 r3732385  
    4040        }
    4141
    42         /** Usage is counted on the server; nothing to do here. */
     42        /** Decrement the local badge cache after a chargeable generate. */
    4343        public static function increase_usage( $type = 'text' ) {
    44             return;
     44            Jcodex_Apgwco_Remote::ensure_type_decremented( $type );
    4545        }
    4646
    … …  
    107107                return;
    108108            }
    109             set_transient( self::get_regen_transient_key( $type ), array( 'hash' => $fingerprint ), self::REGENERATE_WINDOW_SECONDS );
     109            $key    = self::get_regen_transient_key( $type );
     110            $cached = get_transient( $key );
     111            if ( is_array( $cached ) && ! empty( $cached['hash'] ) && $cached['hash'] === $fingerprint ) {
     112                $started = isset( $cached['started'] ) ? (int) $cached['started'] : 0;
     113                if ( $started > 0 ) {
     114                    $left = self::REGENERATE_WINDOW_SECONDS - ( time() - $started );
     115                    if ( $left > 0 ) {
     116                        set_transient( $key, array( 'hash' => $fingerprint, 'started' => $started ), $left );
     117                        return;
     118                    }
     119                } else {
     120                    return;
     121                }
     122            }
     123            set_transient( $key, array( 'hash' => $fingerprint, 'started' => time() ), self::REGENERATE_WINDOW_SECONDS );
    110124        }
    111125    }
  • jcodex-ai-product-generator-for-woocommerce/trunk/includes/class-product-health.php

    r3701858 r3732385  
    6666                }
    6767
    68                 public function build( $product_id ) {
     68                /**
     69                 * Score a product.
     70                 *
     71                 * @param int   $product_id Product ID.
     72                 * @param array $overrides  Optional replacement values, used by the
     73                 *                          optimizer to score content that has been
     74                 *                          proposed but not saved yet. Accepts title,
     75                 *                          description, short_description, seo_title,
     76                 *                          meta_description and tags.
     77                 */
     78                public function build( $product_id, $overrides = array() ) {
    6979                        $product = wc_get_product( $product_id );
    7080                        if ( ! $product ) {
    … …  
    7383
    7484                        $post          = get_post( $product_id );
     85                        $raw_content   = $post ? (string) $post->post_content : '';
    7586                        $title         = trim( get_the_title( $product_id ) );
    76                         $description   = trim( wp_strip_all_tags( $post ? $post->post_content : '' ) );
     87                        if ( ! self::is_usable_title( $title ) ) {
     88                                $title = '';
     89                        }
     90                        $description   = trim( wp_strip_all_tags( $raw_content ) );
    7791                        $short_desc    = trim( wp_strip_all_tags( $post ? $post->post_excerpt : '' ) );
    7892                        $categories    = wp_get_post_terms( $product_id, 'product_cat', array( 'fields' => 'ids' ) );
    … …  
    8195                        $image_ids     = array_filter( array_merge( array( $product->get_image_id() ), $gallery_ids ) );
    8296                        $seo           = $this->get_seo_meta( $product_id );
     97                        $focus_keyword = $this->get_focus_keyword( $product_id );
     98                        $has_faq       = $this->product_has_faq( $product_id, $raw_content );
     99                        $has_benefits  = $this->product_has_benefits( $description );
     100
     101                        if ( ! empty( $overrides ) && is_array( $overrides ) ) {
     102                                if ( isset( $overrides['title'] ) ) {
     103                                        $title = trim( wp_strip_all_tags( (string) $overrides['title'] ) );
     104                                        if ( ! self::is_usable_title( $title ) ) {
     105                                                $title = '';
     106                                        }
     107                                }
     108                                if ( isset( $overrides['description'] ) ) {
     109                                        $description  = trim( wp_strip_all_tags( (string) $overrides['description'] ) );
     110                                        $has_benefits = $this->product_has_benefits( $description );
     111                                }
     112                                if ( isset( $overrides['short_description'] ) ) {
     113                                        $short_desc = trim( wp_strip_all_tags( (string) $overrides['short_description'] ) );
     114                                }
     115                                if ( isset( $overrides['seo_title'] ) ) {
     116                                        $seo['title'] = trim( wp_strip_all_tags( (string) $overrides['seo_title'] ) );
     117                                }
     118                                if ( isset( $overrides['meta_description'] ) ) {
     119                                        $seo['description'] = trim( wp_strip_all_tags( (string) $overrides['meta_description'] ) );
     120                                }
     121                                if ( isset( $overrides['tags'] ) ) {
     122                                        $tags = array_filter( (array) $overrides['tags'] );
     123                                }
     124                                if ( isset( $overrides['has_faq'] ) ) {
     125                                        $has_faq = (bool) $overrides['has_faq'];
     126                                }
     127                                if ( isset( $overrides['has_benefits'] ) ) {
     128                                        $has_benefits = (bool) $overrides['has_benefits'];
     129                                }
     130                                if ( isset( $overrides['focus_keyword'] ) ) {
     131                                        $focus_keyword = trim( wp_strip_all_tags( (string) $overrides['focus_keyword'] ) );
     132                                }
     133                        }
     134
     135                        $keyword_used = $this->keyword_is_used(
     136                                $focus_keyword,
     137                                array( $title, $description, $short_desc, $seo['title'], $seo['description'] )
     138                        );
     139
    83140                        $categories_out = array();
    84141
    85142                        $categories_out['product_information'] = $this->score_product_information( $product, $title, $categories );
    86                         $categories_out['content']             = $this->score_content( $description, $short_desc );
    87                         $categories_out['seo']                 = $this->score_seo( $seo, $tags );
     143                        $categories_out['content']             = $this->score_content( $description, $short_desc, $has_benefits, $has_faq );
     144                        $categories_out['seo']                 = $this->score_seo( $seo, $tags, $focus_keyword, $keyword_used );
    88145                        $categories_out['images']              = $this->score_images( $product->get_image_id(), $image_ids );
    89                         $categories_out['attributes']          = $this->score_attributes( $product );
    90                         $categories_out['conversion']          = $this->score_conversion( $product, $short_desc );
     146                        $categories_out['attributes']          = ( ! empty( $overrides['has_attributes'] ) )
     147                                ? $this->category( 100 )
     148                                : $this->score_attributes( $product );
     149                        $categories_out['conversion']          = $this->score_conversion( $product, $short_desc, $title, $has_benefits, $has_faq );
    91150
    92151                        return array(
    93                                 'overall_score'   => $this->get_overall_score( $categories_out ),
     152                                'overall_score'   => $this->get_overall_score( $this->visible_categories( $categories_out ) ),
    94153                                'categories'      => $categories_out,
    95154                                'priority_issues' => $this->get_priority_issues( $categories_out ),
    96155                                'quick_wins'      => $this->get_quick_wins( $categories_out ),
     156                                'checklist'       => $this->build_checklist( $title, $description, $short_desc, $seo, $focus_keyword, $keyword_used, $has_benefits, $has_faq, $product ),
    97157                                'product_context' => array(
    98158                                        'title'             => $title,
    … …  
    101161                                        'seo_title'         => $seo['title'],
    102162                                        'meta_description'  => $seo['description'],
     163                                        'focus_keyword'     => $focus_keyword,
    103164                                ),
    104165                        );
    … …  
    129190                        }
    130191
    131                         $analysis['overall_score']   = $this->get_overall_score( $analysis['categories'] );
     192                        $analysis['overall_score']   = $this->get_overall_score( $this->visible_categories( $analysis['categories'] ) );
    132193                        $analysis['priority_issues'] = $this->get_priority_issues( $analysis['categories'] );
    133194                        $analysis['quick_wins']      = $this->get_quick_wins( $analysis['categories'] );
    … …  
    138199                        $product = wc_get_product( $product_id );
    139200                        $post    = get_post( $product_id );
    140                         if ( ! $product || ! $post ) return '';
    141                         return md5( wp_json_encode( array(
    142                                 $post->post_title, $post->post_content, $post->post_excerpt, $product->get_sku(), $product->get_price(),
    143                                 $product->get_stock_status(), $product->get_image_id(), $product->get_gallery_image_ids(), $product->get_attributes(),
    144                                 wp_get_post_terms( $product_id, 'product_cat', array( 'fields' => 'ids' ) ), wp_get_post_terms( $product_id, 'product_tag', array( 'fields' => 'ids' ) ), $this->get_seo_meta( $product_id ),
    145                         ) ) );
     201                        if ( ! $product || ! $post ) {
     202                                return '';
     203                        }
     204
     205                        $image_ids = array_filter( array_merge( array( $product->get_image_id() ), $product->get_gallery_image_ids() ) );
     206                        $alts      = array();
     207                        foreach ( $image_ids as $image_id ) {
     208                                $alts[ (int) $image_id ] = (string) get_post_meta( $image_id, '_wp_attachment_image_alt', true );
     209                        }
     210
     211                        $attributes = array();
     212                        foreach ( (array) $product->get_attributes() as $key => $attribute ) {
     213                                if ( is_object( $attribute ) && method_exists( $attribute, 'get_data' ) ) {
     214                                        $data = $attribute->get_data();
     215                                        $attributes[ (string) $key ] = array(
     216                                                'name'      => isset( $data['name'] ) ? (string) $data['name'] : '',
     217                                                'options'   => isset( $data['options'] ) ? array_values( (array) $data['options'] ) : array(),
     218                                                'visible'   => ! empty( $data['visible'] ),
     219                                                'variation' => ! empty( $data['variation'] ),
     220                                        );
     221                                } elseif ( is_array( $attribute ) ) {
     222                                        $attributes[ (string) $key ] = $attribute;
     223                                } else {
     224                                        $attributes[ (string) $key ] = (string) $attribute;
     225                                }
     226                        }
     227                        ksort( $attributes );
     228
     229                        return md5(
     230                                wp_json_encode(
     231                                        array(
     232                                                $post->post_title,
     233                                                $post->post_content,
     234                                                $post->post_excerpt,
     235                                                $product->get_sku(),
     236                                                $product->get_price(),
     237                                                $product->get_stock_status(),
     238                                                $product->get_image_id(),
     239                                                $product->get_gallery_image_ids(),
     240                                                $attributes,
     241                                                $alts,
     242                                                wp_get_post_terms( $product_id, 'product_cat', array( 'fields' => 'ids' ) ),
     243                                                wp_get_post_terms( $product_id, 'product_tag', array( 'fields' => 'ids' ) ),
     244                                                $this->get_seo_meta( $product_id ),
     245                                                get_post_meta( $product_id, '_jcodex_apgwco_faq_schema', true ),
     246                                                $this->get_focus_keyword( $product_id ),
     247                                        )
     248                                )
     249                        );
     250                }
     251
     252                /**
     253                 * WooCommerce auto-drafts are titled "AUTO-DRAFT". Scoring or
     254                 * generating copy from that string produces junk listings.
     255                 *
     256                 * @param string $title Raw product title.
     257                 * @return bool
     258                 */
     259                public static function is_usable_title( $title ) {
     260                        $title = trim( wp_strip_all_tags( (string) $title ) );
     261                        if ( '' === $title ) {
     262                                return false;
     263                        }
     264
     265                        $norm = strtolower( $title );
     266                        $norm = str_replace( array( '-', '_' ), ' ', $norm );
     267                        $norm = preg_replace( '/\s+/', ' ', $norm );
     268                        $norm = is_string( $norm ) ? trim( $norm ) : '';
     269
     270                        if ( '' === $norm || in_array( $norm, array( 'auto draft', 'draft' ), true ) ) {
     271                                return false;
     272                        }
     273
     274                        return 0 !== strpos( $norm, 'auto draft' );
     275                }
     276
     277                /**
     278                 * Cached score, or a fresh build, for the product editor.
     279                 * Auto-drafts and empty titles return false so the panel stays empty.
     280                 *
     281                 * @param int $product_id Product ID.
     282                 * @return array|false
     283                 */
     284                public function get_or_build( $product_id ) {
     285                        $product_id = absint( $product_id );
     286                        if ( $product_id < 1 || ! self::is_usable_title( get_the_title( $product_id ) ) ) {
     287                                return false;
     288                        }
     289
     290                        $cached = $this->get_cached( $product_id );
     291                        if ( $cached ) {
     292                                return $cached;
     293                        }
     294
     295                        $analysis = $this->build( $product_id );
     296                        if ( is_wp_error( $analysis ) ) {
     297                                return false;
     298                        }
     299
     300                        unset( $analysis['product_context'] );
     301                        $this->save( $product_id, $analysis );
     302                        return $analysis;
    146303                }
    147304
    … …  
    151308                        $score = 100; $issues = array();
    152309                        if ( '' === $title ) { $score -= 40; $issues[] = $this->issue( __( 'Product title is missing.', 'jcodex-ai-product-generator-for-woocommerce' ), 'name' ); }
    153                         elseif ( strlen( $title ) < 15 ) { $score -= 15; $issues[] = $this->issue( __( 'Product title is very short.', 'jcodex-ai-product-generator-for-woocommerce' ), 'name' ); }
     310                        elseif ( $this->text_len( $title ) < 15 ) { $score -= 15; $issues[] = $this->issue( __( 'Product title is very short.', 'jcodex-ai-product-generator-for-woocommerce' ), 'name' ); }
    154311                        if ( '' === $product->get_sku() ) { $score -= 15; $issues[] = $this->issue( __( 'SKU is missing.', 'jcodex-ai-product-generator-for-woocommerce' ), 'sku' ); }
    155312                        if ( empty( $categories ) || is_wp_error( $categories ) ) { $score -= 20; $issues[] = $this->issue( __( 'Product category is missing.', 'jcodex-ai-product-generator-for-woocommerce' ) ); }
    … …  
    157314                        return $this->category( $score, $issues );
    158315                }
    159                 private function score_content( $description, $short_desc ) {
    160                         $score = 100; $issues = array();
    161                         if ( '' === $description ) { $score -= 55; $issues[] = $this->issue( __( 'Product description is missing.', 'jcodex-ai-product-generator-for-woocommerce' ), 'description' ); }
    162                         elseif ( strlen( $description ) < 250 ) { $score -= 25; $issues[] = $this->issue( __( 'Product description is too short.', 'jcodex-ai-product-generator-for-woocommerce' ), 'description' ); }
    163                         if ( '' === $short_desc ) { $score -= 25; $issues[] = $this->issue( __( 'Short description is missing.', 'jcodex-ai-product-generator-for-woocommerce' ), 'short_desc' ); }
     316                private function score_content( $description, $short_desc, $has_benefits, $has_faq ) {
     317                        $score  = 100;
     318                        $issues = array();
     319                        if ( '' === $description ) {
     320                                $score -= 40;
     321                                $issues[] = $this->issue( __( 'Product description is missing.', 'jcodex-ai-product-generator-for-woocommerce' ), 'description' );
     322                        } elseif ( $this->text_len( $description ) < 250 ) {
     323                                $score -= 20;
     324                                $issues[] = $this->issue( __( 'Product description is too short.', 'jcodex-ai-product-generator-for-woocommerce' ), 'description' );
     325                        }
     326                        if ( '' === $short_desc ) {
     327                                $score -= 15;
     328                                $issues[] = $this->issue( __( 'Short description is missing.', 'jcodex-ai-product-generator-for-woocommerce' ), 'short_desc' );
     329                        }
     330                        if ( ! $has_benefits ) {
     331                                $score -= 15;
     332                                $issues[] = $this->issue( __( 'Benefits are missing from the description.', 'jcodex-ai-product-generator-for-woocommerce' ), 'description' );
     333                        }
     334                        if ( ! $has_faq ) {
     335                                $score -= 10;
     336                                $issues[] = $this->issue( __( 'FAQ is missing.', 'jcodex-ai-product-generator-for-woocommerce' ), 'faq' );
     337                        }
    164338                        return $this->category( $score, $issues );
    165339                }
    166                 private function score_seo( $seo, $tags ) {
    167                         $score = 100; $issues = array();
    168                         if ( '' === $seo['title'] ) { $score -= 35; $issues[] = $this->issue( __( 'SEO title is missing.', 'jcodex-ai-product-generator-for-woocommerce' ), 'seo_title' ); }
    169                         if ( '' === $seo['description'] ) { $score -= 40; $issues[] = $this->issue( __( 'Meta description is missing.', 'jcodex-ai-product-generator-for-woocommerce' ), 'meta_desc' ); }
    170                         if ( empty( $tags ) || is_wp_error( $tags ) ) { $score -= 20; $issues[] = $this->issue( __( 'Product tags are missing.', 'jcodex-ai-product-generator-for-woocommerce' ), 'tags' ); }
     340                private function score_seo( $seo, $tags, $focus_keyword, $keyword_used ) {
     341                        $score  = 100;
     342                        $issues = array();
     343                        if ( '' === $seo['title'] ) {
     344                                $score -= 25;
     345                                $issues[] = $this->issue( __( 'SEO title is missing.', 'jcodex-ai-product-generator-for-woocommerce' ), 'seo_title' );
     346                        }
     347                        $meta_len = $this->text_len( $seo['description'] );
     348                        if ( 0 === $meta_len ) {
     349                                $score -= 25;
     350                                $issues[] = $this->issue( __( 'Meta description is missing.', 'jcodex-ai-product-generator-for-woocommerce' ), 'meta_desc' );
     351                        } elseif ( $meta_len < 120 || $meta_len > 160 ) {
     352                                $score -= 10;
     353                                $issues[] = $this->issue( __( 'Meta description is outside the 120–160 character range.', 'jcodex-ai-product-generator-for-woocommerce' ), 'meta_desc' );
     354                        }
     355                        if ( '' === $focus_keyword ) {
     356                                $score -= 15;
     357                                $issues[] = $this->issue( __( 'Focus keyword is missing.', 'jcodex-ai-product-generator-for-woocommerce' ), 'seo_title' );
     358                        } elseif ( ! $keyword_used ) {
     359                                $score -= 10;
     360                                $issues[] = $this->issue( __( 'Focus keyword is not used in the listing.', 'jcodex-ai-product-generator-for-woocommerce' ), 'description' );
     361                        }
    171362                        return $this->category( $score, $issues );
    172363                }
    … …  
    180371                }
    181372                private function score_attributes( $product ) { $attributes = $product->get_attributes(); return empty( $attributes ) ? $this->category( 45, array( $this->issue( __( 'Product attributes are missing.', 'jcodex-ai-product-generator-for-woocommerce' ), 'attributes' ) ) ) : $this->category( 100 ); }
    182                 private function score_conversion( $product, $short_desc ) { $score = 100; $issues = array(); if ( '' === $product->get_price() && ! $product->is_type( 'variable' ) ) { $score -= 35; $issues[] = $this->issue( __( 'A price is needed before customers can buy this product.', 'jcodex-ai-product-generator-for-woocommerce' ) ); } if ( 'outofstock' === $product->get_stock_status() ) { $score -= 25; $issues[] = $this->issue( __( 'Product is out of stock.', 'jcodex-ai-product-generator-for-woocommerce' ) ); } if ( '' === $short_desc ) { $score -= 20; $issues[] = $this->issue( __( 'Add a short description to communicate the key buying benefit.', 'jcodex-ai-product-generator-for-woocommerce' ), 'short_desc' ); } return $this->category( $score, $issues ); }
     373                private function score_conversion( $product, $short_desc, $title, $has_benefits, $has_faq ) {
     374                        $score  = 100;
     375                        $issues = array();
     376                        if ( '' === $product->get_price() && ! $product->is_type( 'variable' ) ) {
     377                                $score -= 25;
     378                                $issues[] = $this->issue( __( 'Product price is missing.', 'jcodex-ai-product-generator-for-woocommerce' ), 'price' );
     379                        }
     380                        if ( $this->text_len( $title ) < 15 ) {
     381                                $score -= 25;
     382                                $issues[] = $this->issue( __( 'Product title is too weak to convert.', 'jcodex-ai-product-generator-for-woocommerce' ), 'name' );
     383                        }
     384                        unset( $short_desc, $has_benefits, $has_faq );
     385                        return $this->category( $score, $issues );
     386                }
     387
     388                private function get_focus_keyword( $product_id ) {
     389                        $keys = array(
     390                                '_yoast_wpseo_focuskw',
     391                                'rank_math_focus_keyword',
     392                                '_seopress_analysis_target_kw',
     393                                '_wds_focus-keywords',
     394                                'wds_focus_keywords',
     395                                '_tsf_focus_keyword',
     396                                'focus_keyword',
     397                        );
     398
     399                        foreach ( $keys as $meta_key ) {
     400                                $value = trim( (string) get_post_meta( $product_id, $meta_key, true ) );
     401                                if ( '' === $value ) {
     402                                        continue;
     403                                }
     404                                $parts = explode( ',', $value );
     405                                $first = trim( (string) $parts[0] );
     406                                if ( '' !== $first ) {
     407                                        return $first;
     408                                }
     409                        }
     410
     411                        $aioseo = $this->get_aioseo_focus_keyword( $product_id );
     412                        if ( '' !== $aioseo ) {
     413                                return $aioseo;
     414                        }
     415
     416                        return '';
     417                }
     418
     419                /**
     420                 * AIOSEO stores the focus keyphrase on its post model, not in post meta.
     421                 *
     422                 * @param int $product_id Product ID.
     423                 * @return string
     424                 */
     425                private function get_aioseo_focus_keyword( $product_id ) {
     426                        $class = '\\AIOSEO\\Plugin\\Common\\Models\\Post';
     427                        if ( ! class_exists( $class ) || ! method_exists( $class, 'getPost' ) ) {
     428                                return '';
     429                        }
     430
     431                        try {
     432                                $model = $class::getPost( absint( $product_id ) );
     433                        } catch ( Exception $exception ) {
     434                                return '';
     435                        }
     436
     437                        if ( ! is_object( $model ) || ! isset( $model->keyphrases ) ) {
     438                                return '';
     439                        }
     440
     441                        $data = $model->keyphrases;
     442                        if ( is_string( $data ) ) {
     443                                $data = json_decode( $data, true );
     444                        } elseif ( is_object( $data ) ) {
     445                                $data = (array) $data;
     446                        }
     447
     448                        if ( ! is_array( $data ) ) {
     449                                return '';
     450                        }
     451
     452                        $focus = '';
     453                        if ( isset( $data['focus']['keyphrase'] ) ) {
     454                                $focus = (string) $data['focus']['keyphrase'];
     455                        } elseif ( isset( $data['focus'] ) && is_object( $data['focus'] ) && isset( $data['focus']->keyphrase ) ) {
     456                                $focus = (string) $data['focus']->keyphrase;
     457                        }
     458
     459                        return trim( wp_strip_all_tags( $focus ) );
     460                }
     461
     462                private function keyword_is_used( $keyword, $haystacks ) {
     463                        $keyword = class_exists( 'Jcodex_Apgwco_Language' )
     464                                ? Jcodex_Apgwco_Language::text_lower( trim( (string) $keyword ) )
     465                                : strtolower( trim( (string) $keyword ) );
     466                        if ( '' === $keyword ) {
     467                                return false;
     468                        }
     469                        foreach ( (array) $haystacks as $haystack ) {
     470                                if ( class_exists( 'Jcodex_Apgwco_Language' ) ) {
     471                                        if ( Jcodex_Apgwco_Language::text_contains( (string) $haystack, $keyword ) ) {
     472                                                return true;
     473                                        }
     474                                } elseif ( false !== strpos( strtolower( (string) $haystack ), $keyword ) ) {
     475                                        return true;
     476                                }
     477                        }
     478                        return false;
     479                }
     480
     481                private function product_has_benefits( $description ) {
     482                        $text = (string) $description;
     483                        if ( '' === trim( wp_strip_all_tags( $text ) ) ) {
     484                                return false;
     485                        }
     486
     487                        if ( preg_match_all( '/<li\b/i', $text ) >= 3 ) {
     488                                return true;
     489                        }
     490
     491                        $plain = wp_strip_all_tags( $text );
     492                        if ( class_exists( 'Jcodex_Apgwco_Language' ) ) {
     493                                foreach ( Jcodex_Apgwco_Language::benefits_text_markers() as $marker ) {
     494                                        if ( Jcodex_Apgwco_Language::text_contains( $plain, $marker ) ) {
     495                                                return true;
     496                                        }
     497                                }
     498                        } elseif ( preg_match( '/\b(benefit|benefits|why you.?ll|why you will|you.?ll love)\b/i', $plain ) ) {
     499                                return true;
     500                        }
     501
     502                        return (bool) apply_filters( 'jcodex_apgwco_product_has_benefits', false, $text );
     503                }
     504
     505                private function product_has_faq( $product_id, $raw_content ) {
     506                        if ( class_exists( 'Jcodex_Apgwco_Optimizer' ) ) {
     507                                return Jcodex_Apgwco_Optimizer::has_faq( $product_id, $raw_content );
     508                        }
     509
     510                        $saved = get_post_meta( $product_id, '_jcodex_apgwco_faq_schema', true );
     511                        if ( is_array( $saved ) && ! empty( $saved ) ) {
     512                                return true;
     513                        }
     514
     515                        return (bool) preg_match( '/faq|frequently\s+asked/i', (string) $raw_content );
     516                }
     517
     518                private function build_checklist( $title, $description, $short_desc, $seo, $focus_keyword, $keyword_used, $has_benefits, $has_faq, $product ) {
     519                        $featured_id = $product ? $product->get_image_id() : 0;
     520                        $image_ids   = $product ? array_filter( array_merge( array( $featured_id ), $product->get_gallery_image_ids() ) ) : array();
     521                        $meta_len    = $this->text_len( $seo['description'] );
     522                        $missing_alt = false;
     523                        foreach ( (array) $image_ids as $image_id ) {
     524                                if ( '' === trim( (string) get_post_meta( $image_id, '_wp_attachment_image_alt', true ) ) ) {
     525                                        $missing_alt = true;
     526                                        break;
     527                                }
     528                        }
     529
     530                        $item = function( $label, $status, $fixable = true ) {
     531                                return array(
     532                                        'label'   => $label,
     533                                        'status'  => $status,
     534                                        'fixable' => (bool) $fixable,
     535                                );
     536                        };
     537
     538                        $title_status = '' === $title ? 'missing' : ( $this->text_len( $title ) < 15 ? 'weak' : 'ok' );
     539                        $desc_status  = '' === $description ? 'missing' : ( $this->text_len( $description ) < 250 ? 'weak' : 'ok' );
     540                        $meta_status  = 0 === $meta_len ? 'missing' : ( $meta_len < 120 || $meta_len > 160 ? 'weak' : 'ok' );
     541                        $image_status = empty( $image_ids ) ? 'missing' : ( count( $image_ids ) < 2 ? 'weak' : 'ok' );
     542
     543                        $desc_label = 'weak' === $desc_status
     544                                ? __( 'Long description too short', 'jcodex-ai-product-generator-for-woocommerce' )
     545                                : __( 'Long description', 'jcodex-ai-product-generator-for-woocommerce' );
     546
     547                        $meta_label = 'weak' === $meta_status
     548                                ? ( $meta_len > 160
     549                                        ? __( 'Meta description too long', 'jcodex-ai-product-generator-for-woocommerce' )
     550                                        : __( 'Meta description too short', 'jcodex-ai-product-generator-for-woocommerce' ) )
     551                                : __( 'Meta description', 'jcodex-ai-product-generator-for-woocommerce' );
     552
     553                        $keyword_label = ( '' !== $focus_keyword && ! $keyword_used )
     554                                ? __( 'Keyword usage low', 'jcodex-ai-product-generator-for-woocommerce' )
     555                                : __( 'Keyword usage', 'jcodex-ai-product-generator-for-woocommerce' );
     556
     557                        $images_label = 'weak' === $image_status
     558                                ? __( 'Only 1 product image', 'jcodex-ai-product-generator-for-woocommerce' )
     559                                : __( 'Product images', 'jcodex-ai-product-generator-for-woocommerce' );
     560
     561                        $title_conv_label = 'weak' === $title_status
     562                                ? __( 'Weak product title', 'jcodex-ai-product-generator-for-woocommerce' )
     563                                : __( 'Product title', 'jcodex-ai-product-generator-for-woocommerce' );
     564
     565                        return array(
     566                                'content' => array(
     567                                        $item( __( 'Product title', 'jcodex-ai-product-generator-for-woocommerce' ), $title_status ),
     568                                        $item( __( 'Short description', 'jcodex-ai-product-generator-for-woocommerce' ), '' === $short_desc ? 'missing' : 'ok' ),
     569                                        $item( $desc_label, $desc_status ),
     570                                        $item( __( 'Benefits', 'jcodex-ai-product-generator-for-woocommerce' ), $has_benefits ? 'ok' : 'missing' ),
     571                                        $item( __( 'FAQ', 'jcodex-ai-product-generator-for-woocommerce' ), $has_faq ? 'ok' : 'missing' ),
     572                                ),
     573                                'seo' => array(
     574                                        $item( __( 'SEO title', 'jcodex-ai-product-generator-for-woocommerce' ), '' === $seo['title'] ? 'missing' : 'ok' ),
     575                                        $item( $meta_label, $meta_status ),
     576                                        $item( __( 'Focus keyword', 'jcodex-ai-product-generator-for-woocommerce' ), '' === $focus_keyword ? 'missing' : 'ok', false ),
     577                                        $item( $keyword_label, '' === $focus_keyword ? 'missing' : ( $keyword_used ? 'ok' : 'weak' ), false ),
     578                                ),
     579                                'images' => array(
     580                                        $item( __( 'Featured image', 'jcodex-ai-product-generator-for-woocommerce' ), $featured_id ? 'ok' : 'missing', false ),
     581                                        $item( __( 'Image alt text', 'jcodex-ai-product-generator-for-woocommerce' ), empty( $image_ids ) ? 'missing' : ( $missing_alt ? 'missing' : 'ok' ) ),
     582                                        $item( $images_label, $image_status, false ),
     583                                ),
     584                                'conversion' => array(
     585                                        $item( __( 'FAQ', 'jcodex-ai-product-generator-for-woocommerce' ), $has_faq ? 'ok' : 'missing' ),
     586                                        $item( __( 'Clear benefits', 'jcodex-ai-product-generator-for-woocommerce' ), $has_benefits ? 'ok' : 'missing' ),
     587                                        $item( $title_conv_label, $title_status ),
     588                                ),
     589                        );
     590                }
    183591                private function get_seo_meta( $product_id ) {
    184592                        $title = ''; $description = '';
    … …  
    198606                }
    199607                private function get_overall_score( $categories ) { $scores = wp_list_pluck( $categories, 'score' ); return (int) round( array_sum( $scores ) / max( 1, count( $scores ) ) ); }
     608                private function visible_categories( $categories ) {
     609                        $out = array();
     610                        foreach ( array( 'content', 'seo', 'images', 'conversion' ) as $key ) {
     611                                if ( isset( $categories[ $key ] ) ) {
     612                                        $out[ $key ] = $categories[ $key ];
     613                                }
     614                        }
     615                        return $out;
     616                }
    200617                private function get_priority_issues( $categories ) { $issues = array(); foreach ( $categories as $category ) foreach ( $category['issues'] as $issue ) $issues[] = $issue; return array_slice( $issues, 0, 6 ); }
    201618                private function get_quick_wins( $categories ) { $wins = array(); foreach ( $categories as $category ) foreach ( $category['issues'] as $issue ) if ( ! empty( $issue['target'] ) ) $wins[] = $issue; return array_slice( $wins, 0, 4 ); }
     619
     620                /**
     621                 * UTF-8 character count for scoring thresholds.
     622                 *
     623                 * @param string $text Text.
     624                 * @return int
     625                 */
     626                private function text_len( $text ) {
     627                        if ( class_exists( 'Jcodex_Apgwco_Language' ) ) {
     628                                return Jcodex_Apgwco_Language::text_len( $text );
     629                        }
     630                        return strlen( (string) $text );
     631                }
    202632        }
    203633}
  • jcodex-ai-product-generator-for-woocommerce/trunk/includes/class-remote.php

    r3724671 r3732385  
    2323        const OPT_USAGE    = 'jcodex_apgwco_usage';
    2424        const OPT_SKEW     = 'jcodex_apgwco_time_skew';
     25        const OPT_INSTALL  = 'jcodex_apgwco_install_id';
     26        const OPT_USAGE_REQ = 'jcodex_apgwco_usage_req';
    2527        const T_CHALLENGE  = 'jcodex_apgwco_reg_challenge';
    2628        const T_REG_FAIL   = 'jcodex_apgwco_reg_fail';
     29        const T_REG_LOCK   = 'jcodex_apgwco_reg_lock';
    2730        const T_LEGACY     = 'jcodex_apgwco_legacy_mode';
    28         const T_USAGE_SYNC = 'jcodex_apgwco_usage_sync';
     31        const T_USAGE_SYNC = 'jcodex_apgwco_usage_sync_v2';
     32
     33        /**
     34         * Usage snapshot from the in-flight API response (this PHP request only).
     35         *
     36         * @var array<string,array>
     37         */
     38        private static $last_usage = array();
     39
     40        /**
     41         * Types whose remaining was already dropped in this request.
     42         *
     43         * @var array<string,bool>
     44         */
     45        private static $decremented = array();
     46
     47        /**
     48         * Whether the last generate-text/image reply said this request was charged.
     49         *
     50         * @var bool
     51         */
     52        private static $last_charged = false;
    2953
    3054        public static function init() {
    … …  
    7195        }
    7296
     97        /**
     98         * Stable ID for this WordPress install. Localhost cannot be verified
     99         * from the API server, so this keeps each local site's quota separate.
     100         */
     101        private static function install_id() {
     102            $id = get_option( self::OPT_INSTALL, '' );
     103            if ( is_string( $id ) && 32 === strlen( $id ) && ctype_xdigit( $id ) ) {
     104                return strtolower( $id );
     105            }
     106            $id = bin2hex( random_bytes( 16 ) );
     107            update_option( self::OPT_INSTALL, $id, false );
     108            return $id;
     109        }
     110
    73111        /* --------------------------------------------------------------
    74112         * Registration
    … …  
    77115            $creds = get_option( self::OPT_CREDS );
    78116            if ( is_array( $creds ) && ! empty( $creds['site_key'] ) && ! empty( $creds['secret'] ) && isset( $creds['host'] ) && self::current_host() === $creds['host'] ) {
     117                if ( empty( $creds['install_bound'] ) ) {
     118                    delete_option( self::OPT_CREDS );
     119                    delete_option( self::OPT_USAGE );
     120                    return self::register();
     121                }
    79122                return $creds;
    80123            }
    … …  
    89132                return false;
    90133            }
     134
     135            if ( get_transient( self::T_REG_LOCK ) ) {
     136                $waited = self::wait_for_stored_creds( 22 );
     137                if ( $waited ) {
     138                    return $waited;
     139                }
     140            }
     141
     142            set_transient( self::T_REG_LOCK, 1, 30 );
    91143
    92144            $challenge = bin2hex( random_bytes( 16 ) );
    … …  
    98150                'headers'   => array( 'Content-Type' => 'application/json' ),
    99151                'body'      => wp_json_encode( array(
    100                     'site_url'  => get_site_url(),
    101                     'challenge' => $challenge,
    102                     'version'   => defined( 'JCODEX_APGWCO_VERSION' ) ? JCODEX_APGWCO_VERSION : '',
     152                    'site_url'    => get_site_url(),
     153                    'challenge'   => $challenge,
     154                    'install_id'  => self::install_id(),
     155                    'version'     => defined( 'JCODEX_APGWCO_VERSION' ) ? JCODEX_APGWCO_VERSION : '',
    103156                ) ),
    104157            ) );
    … …  
    107160
    108161            if ( is_wp_error( $response ) ) {
     162                delete_transient( self::T_REG_LOCK );
    109163                set_transient( self::T_REG_FAIL, 1, MINUTE_IN_SECONDS );
    110164                return false;
    … …  
    116170            if ( 404 === $code ) {
    117171                // Service has not been upgraded yet: keep working in legacy (unsigned) mode for a while.
     172                delete_transient( self::T_REG_LOCK );
    118173                set_transient( self::T_LEGACY, 1, HOUR_IN_SECONDS );
    119174                set_transient( self::T_REG_FAIL, 1, 10 * MINUTE_IN_SECONDS );
    … …  
    122177
    123178            if ( 200 !== $code || ! is_array( $data ) || empty( $data['site_key'] ) || empty( $data['secret'] ) ) {
     179                delete_transient( self::T_REG_LOCK );
    124180                set_transient( self::T_REG_FAIL, 1, MINUTE_IN_SECONDS );
    125181                return false;
    … …  
    131187
    132188            $creds = array(
    133                 'site_key' => sanitize_text_field( $data['site_key'] ),
    134                 'secret'   => sanitize_text_field( $data['secret'] ),
    135                 'host'     => self::current_host(),
    136                 'verified' => ! empty( $data['verified'] ),
     189                'site_key'      => sanitize_text_field( $data['site_key'] ),
     190                'secret'        => sanitize_text_field( $data['secret'] ),
     191                'host'          => self::current_host(),
     192                'verified'      => ! empty( $data['verified'] ),
     193                'install_bound' => true,
    137194            );
     195            $previous = get_option( self::OPT_CREDS );
     196            $same_key = is_array( $previous ) && ! empty( $previous['site_key'] ) && $previous['site_key'] === $creds['site_key'];
     197            if ( ! $same_key ) {
     198                delete_option( self::OPT_USAGE );
     199            }
    138200            update_option( self::OPT_CREDS, $creds, false );
     201            delete_transient( self::T_REG_LOCK );
    139202            return $creds;
     203        }
     204
     205        /**
     206         * Another request is already registering. Wait for stored creds instead of a second /register.
     207         *
     208         * @param int $seconds Max seconds to wait.
     209         * @return array|false
     210         */
     211        private static function wait_for_stored_creds( $seconds ) {
     212            $seconds = max( 1, (int) $seconds );
     213            for ( $i = 0; $i < $seconds; $i++ ) {
     214                $creds = self::stored_creds();
     215                if ( $creds ) {
     216                    return $creds;
     217                }
     218                if ( ! get_transient( self::T_REG_LOCK ) ) {
     219                    break;
     220                }
     221                sleep( 1 );
     222            }
     223            return self::stored_creds();
    140224        }
    141225
    … …  
    146230         * @param array  $data     payload for the endpoint
    147231         * @param string $type     usage bucket (text, short_desc, image, faq ...)
    148          * @param int    $timeout  seconds
     232         * @param int    $timeout       seconds
     233         * @param bool   $allow_register Whether this call may register the site.
     234         * @param bool   $expect_charge  False for a plugin-side free regenerate.
    149235         * @return array decoded response, or array( 'error' => msg, 'code' => code )
    150236         * ------------------------------------------------------------ */
    151         public static function request( $endpoint, $data, $type = 'text', $timeout = 60 ) {
    152             $result = self::send( $endpoint, $data, $type, $timeout );
     237        public static function request( $endpoint, $data, $type = 'text', $timeout = 60, $allow_register = true, $expect_charge = true ) {
     238            $result = self::send( $endpoint, $data, $type, $timeout, $allow_register, $expect_charge );
    153239
    154240            // Self-heal: stale credentials or clock skew -> fix once and retry.
    … …  
    157243                    delete_option( self::OPT_CREDS );
    158244                    delete_transient( self::T_REG_FAIL );
    159                     $result = self::send( $endpoint, $data, $type, $timeout );
     245                    $result = self::send( $endpoint, $data, $type, $timeout, $allow_register, $expect_charge );
    160246                } elseif ( 'bad_timestamp' === $result['code'] && ! empty( $result['server_time'] ) ) {
    161247                    update_option( self::OPT_SKEW, (int) $result['server_time'] - time(), false );
    162                     $result = self::send( $endpoint, $data, $type, $timeout );
     248                    $result = self::send( $endpoint, $data, $type, $timeout, $allow_register, $expect_charge );
     249                } elseif ( 'replay' === $result['code'] ) {
     250                    $result = self::send( $endpoint, $data, $type, $timeout, $allow_register, $expect_charge );
    163251                }
    164252            }
    … …  
    167255        }
    168256
    169         private static function send( $endpoint, $data, $type, $timeout ) {
     257        /**
     258         * Credentials already stored for this host. Does not call register().
     259         *
     260         * @return array|false
     261         */
     262        private static function stored_creds() {
     263            $creds = get_option( self::OPT_CREDS );
     264            if ( is_array( $creds ) && ! empty( $creds['site_key'] ) && ! empty( $creds['secret'] ) && isset( $creds['host'] ) && self::current_host() === $creds['host'] ) {
     265                return $creds;
     266            }
     267            return false;
     268        }
     269
     270        public static function last_was_charged() {
     271            return self::$last_charged;
     272        }
     273
     274        private static function send( $endpoint, $data, $type, $timeout, $allow_register = true, $expect_charge = true ) {
     275            $started = microtime( true );
    170276            $payload = array(
    171277                'site_url' => get_site_url(),
    172278                'type'     => $type,
    173279                'data'     => $data,
     280                'nonce'    => bin2hex( random_bytes( 8 ) ),
    174281            );
    175282            $license = self::license_key();
    … …  
    179286
    180287            $headers = array( 'Content-Type' => 'application/json' );
    181             $creds   = get_transient( self::T_LEGACY ) ? false : self::get_creds();
    182             $body    = wp_json_encode( $payload );
     288            $legacy  = (bool) get_transient( self::T_LEGACY );
     289            if ( $legacy ) {
     290                $creds = false;
     291            } elseif ( $allow_register ) {
     292                $creds = self::get_creds();
     293            } else {
     294                $creds = self::stored_creds();
     295            }
     296
     297            if ( ! $creds && ! $legacy ) {
     298                return array(
     299                    'error' => __( 'Could not register this site with the AI service. Please try again in a minute.', 'jcodex-ai-product-generator-for-woocommerce' ),
     300                    'code'  => 'registration_failed',
     301                );
     302            }
     303
     304            $body = wp_json_encode( $payload );
    183305
    184306            if ( $creds ) {
    185                 $ts                   = self::time_now();
     307                $ts                    = self::time_now();
    186308                $headers['X-APG-Key']  = $creds['site_key'];
    187309                $headers['X-APG-Time'] = (string) $ts;
    … …  
    197319
    198320            if ( is_wp_error( $response ) ) {
    199                 return array( 'error' => esc_html( $response->get_error_message() ), 'code' => 'http_error_local' );
     321                return array( 'error' => $response->get_error_message(), 'code' => 'http_error_local' );
    200322            }
    201323
    … …  
    203325            $decoded = json_decode( wp_remote_retrieve_body( $response ), true );
    204326
     327            $expect_charge    = (bool) $expect_charge;
     328            $before           = self::cached_remaining( $type );
     329            $has_charge_flag  = is_array( $decoded ) && array_key_exists( 'charged', $decoded );
     330            $charged          = $has_charge_flag ? (bool) $decoded['charged'] : false;
     331            self::$last_charged = $charged;
     332
    205333            if ( is_array( $decoded ) && ! empty( $decoded['usage'] ) && is_array( $decoded['usage'] ) ) {
    206                 self::store_usage( $decoded['usage'] );
     334                self::$last_usage = $decoded['usage'];
     335                self::store_usage( $decoded['usage'], $started );
     336                if ( $has_charge_flag && ( ! $expect_charge || ! $charged ) ) {
     337                    self::restore_type_remaining( $type, $before );
     338                } elseif ( $has_charge_flag && $charged && ! self::is_pro_active() ) {
     339                    $after = self::cached_remaining( $type );
     340                    if ( $after < $before ) {
     341                        self::$decremented[ sanitize_key( $type ) ] = true;
     342                    } else {
     343                        self::ensure_type_decremented( $type );
     344                    }
     345                }
    207346            }
    208347
    … …  
    215354                        $status
    216355                    );
    217                 $out = array( 'error' => esc_html( $msg ), 'code' => ( is_array( $decoded ) && ! empty( $decoded['code'] ) ) ? (string) $decoded['code'] : 'http_' . $status );
     356                $out = array( 'error' => $msg, 'code' => ( is_array( $decoded ) && ! empty( $decoded['code'] ) ) ? (string) $decoded['code'] : 'http_' . $status );
    218357                if ( is_array( $decoded ) && ! empty( $decoded['server_time'] ) ) {
    219358                    $out['server_time'] = (int) $decoded['server_time'];
    … …  
    223362
    224363            if ( ! is_array( $decoded ) ) {
    225                 return array( 'error' => esc_html__( 'Invalid JSON returned from API.', 'jcodex-ai-product-generator-for-woocommerce' ), 'code' => 'bad_json' );
     364                return array( 'error' => __( 'Invalid JSON returned from API.', 'jcodex-ai-product-generator-for-woocommerce' ), 'code' => 'bad_json' );
    226365            }
    227366
    … …  
    232371         * Usage cache (display + UX only - the server is the source of truth)
    233372         * ------------------------------------------------------------ */
    234         private static function store_usage( $usage ) {
    235             $clean = array();
     373        /**
     374         * @param array $row Raw usage row.
     375         * @return array{remaining:int,limit:int,reset_at:int}|null
     376         */
     377        private static function normalize_usage_row( $row ) {
     378            if ( ! is_array( $row ) || ! isset( $row['remaining'], $row['limit'] ) ) {
     379                return null;
     380            }
     381            return array(
     382                'remaining' => (int) $row['remaining'],
     383                'limit'     => (int) $row['limit'],
     384                'reset_at'  => isset( $row['reset_at'] ) ? (int) $row['reset_at'] : 0,
     385            );
     386        }
     387
     388        /**
     389         * Merge a server snapshot into the local cache. Remaining for the same
     390         * period is never allowed to go up — that is how a late /usage reply
     391         * was putting badges back to 10 after generate had already charged.
     392         */
     393        private static function store_usage( $usage, $request_started = 0.0 ) {
     394            $existing = get_option( self::OPT_USAGE );
     395            if ( ! is_array( $existing ) ) {
     396                $existing = array();
     397            }
     398
     399            $changed = false;
    236400            foreach ( $usage as $type => $row ) {
    237                 if ( is_array( $row ) && isset( $row['remaining'], $row['limit'] ) ) {
    238                     $clean[ sanitize_key( $type ) ] = array(
    239                         'remaining' => (int) $row['remaining'],
    240                         'limit'     => (int) $row['limit'],
    241                         'reset_at'  => isset( $row['reset_at'] ) ? (int) $row['reset_at'] : 0,
    242                     );
    243                 }
    244             }
    245             if ( $clean ) {
    246                 update_option( self::OPT_USAGE, $clean, false );
     401                $incoming = self::normalize_usage_row( $row );
     402                if ( ! $incoming ) {
     403                    continue;
     404                }
     405                $type = sanitize_key( $type );
     406                if ( isset( $existing[ $type ] ) ) {
     407                    $old = self::normalize_usage_row( $existing[ $type ] );
     408                    if ( $old ) {
     409                        if ( $incoming['reset_at'] < $old['reset_at'] ) {
     410                            continue;
     411                        }
     412                        if ( $incoming['reset_at'] === $old['reset_at'] && $incoming['remaining'] > $old['remaining'] ) {
     413                            continue;
     414                        }
     415                    }
     416                }
     417                if ( ! isset( $existing[ $type ] ) || $existing[ $type ] !== $incoming ) {
     418                    $existing[ $type ] = $incoming;
     419                    $changed          = true;
     420                }
     421            }
     422
     423            if ( $changed ) {
     424                update_option( self::OPT_USAGE, $existing, false );
     425            }
     426
     427            $request_started = (float) $request_started;
     428            $last_started    = (float) get_option( self::OPT_USAGE_REQ, 0 );
     429            if ( $request_started > 0 && $request_started >= $last_started ) {
     430                update_option( self::OPT_USAGE_REQ, (string) $request_started, false );
    247431            }
    248432        }
    … …  
    252436         */
    253437        public static function get_usage( $type ) {
     438            $row = null;
     439            if ( isset( self::$last_usage[ $type ] ) ) {
     440                $row = self::normalize_usage_row( self::$last_usage[ $type ] );
     441            }
     442            if ( ! $row ) {
     443                $all = get_option( self::OPT_USAGE );
     444                if ( is_array( $all ) && isset( $all[ $type ] ) ) {
     445                    $row = self::normalize_usage_row( $all[ $type ] );
     446                }
     447            }
     448            if ( ! $row ) {
     449                return null;
     450            }
     451            // If the period already ended, the server will have reset it.
     452            if ( $row['reset_at'] > 0 && $row['reset_at'] <= time() ) {
     453                return array( 'remaining' => $row['limit'], 'limit' => $row['limit'], 'reset_at' => 0 );
     454            }
     455            return $row;
     456        }
     457
     458        /**
     459         * Remaining for a type from this response, then the option cache, else 10.
     460         *
     461         * @param string $type Usage bucket.
     462         * @return int
     463         */
     464        private static function cached_remaining( $type ) {
     465            $row = self::get_usage( $type );
     466            return $row ? max( 0, (int) $row['remaining'] ) : 10;
     467        }
     468
     469        /**
     470         * Put this type's remaining back after a free regenerate so the badge
     471         * cannot fall when the success text says no credit was used.
     472         *
     473         * @param string $type      Usage bucket.
     474         * @param int    $remaining Value from before this request.
     475         */
     476        private static function restore_type_remaining( $type, $remaining ) {
     477            $type = sanitize_key( $type );
     478            if ( '' === $type ) {
     479                return;
     480            }
     481            $row     = self::get_usage( $type );
     482            $updated = array(
     483                'remaining' => max( 0, (int) $remaining ),
     484                'limit'     => $row ? (int) $row['limit'] : 10,
     485                'reset_at'  => $row ? (int) $row['reset_at'] : 0,
     486            );
     487            self::$last_usage[ $type ] = $updated;
    254488            $all = get_option( self::OPT_USAGE );
    255             if ( is_array( $all ) && isset( $all[ $type ] ) ) {
    256                 $row = $all[ $type ];
    257                 // If the period already ended, the server will have reset it.
    258                 if ( $row['reset_at'] > 0 && $row['reset_at'] <= time() ) {
    259                     return array( 'remaining' => $row['limit'], 'limit' => $row['limit'], 'reset_at' => 0 );
    260                 }
    261                 return $row;
    262             }
    263             return null;
     489            if ( ! is_array( $all ) ) {
     490                $all = array();
     491            }
     492            $all[ $type ] = $updated;
     493            update_option( self::OPT_USAGE, $all, false );
     494        }
     495
     496        /**
     497         * Drop one unit on the local display cache when the server charged but
     498         * the snapshot for this tab type did not change (legacy_text pool, etc.).
     499         *
     500         * @param string $type Usage bucket.
     501         */
     502        public static function ensure_type_decremented( $type ) {
     503            $type = sanitize_key( $type );
     504            if ( '' === $type || ! empty( self::$decremented[ $type ] ) ) {
     505                return;
     506            }
     507            $row       = self::get_usage( $type );
     508            $limit     = $row ? (int) $row['limit'] : 10;
     509            $reset     = $row ? (int) $row['reset_at'] : 0;
     510            $remaining = $row ? (int) $row['remaining'] : 10;
     511            $updated   = array(
     512                'remaining' => max( 0, $remaining - 1 ),
     513                'limit'     => $limit,
     514                'reset_at'  => $reset,
     515            );
     516            self::$last_usage[ $type ]    = $updated;
     517            self::$decremented[ $type ]   = true;
     518            $all = get_option( self::OPT_USAGE );
     519            if ( ! is_array( $all ) ) {
     520                $all = array();
     521            }
     522            $all[ $type ] = $updated;
     523            update_option( self::OPT_USAGE, $all, false );
     524        }
     525
     526        /**
     527         * True when this site already has a usage snapshot from the server.
     528         *
     529         * @return bool
     530         */
     531        public static function has_usage_cache() {
     532            $all = get_option( self::OPT_USAGE );
     533            return is_array( $all ) && ! empty( $all );
    264534        }
    265535
    266536        /**
    267537         * Pull fresh numbers from the server (at most once per 6 hours, short timeout).
     538         * Never registers the site from a product-editor page render.
    268539         */
    269540        public static function maybe_sync_usage() {
    … …  
    271542                return;
    272543            }
     544            if ( get_transient( self::T_REG_FAIL ) ) {
     545                return;
     546            }
     547            if ( ! get_transient( self::T_LEGACY ) && ! self::stored_creds() ) {
     548                return;
     549            }
    273550            set_transient( self::T_USAGE_SYNC, 1, 6 * HOUR_IN_SECONDS );
    274             self::request( '/usage', new stdClass(), 'text', 4 );
     551            self::request( '/usage', new stdClass(), 'text', 4, false );
     552        }
     553
     554        /**
     555         * Fetch usage for the editor badges. Safe to call from AJAX; may register the site.
     556         *
     557         * @return bool
     558         */
     559        public static function sync_usage() {
     560            if ( self::is_pro_active() ) {
     561                return true;
     562            }
     563            $result = self::request( '/usage', new stdClass(), 'text', 15, true );
     564            if ( ! empty( $result['error'] ) ) {
     565                return false;
     566            }
     567            set_transient( self::T_USAGE_SYNC, 1, 6 * HOUR_IN_SECONDS );
     568            return true;
    275569        }
    276570    }
  • jcodex-ai-product-generator-for-woocommerce/trunk/readme.txt

    r3724671 r3732385  
    33Tags: woocommerce ai, ai product description, ai image generator, product content, seo
    44Requires at least: 5.8
    5 Tested up to: 7.1.2
     5Tested up to: 7.1.3
    66Requires PHP: 7.4
    7 Stable tag: 1.2.5
     7Stable tag: 1.2.6
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
Note: See TracChangeset for help on using the changeset viewer.