PiterVolt

0

No products in the cart.

8 (812) 922-72-68
7 (951) 675-02-53

0

No products in the cart.

<?php
/**
 * Plugin Name: Tire Price Monitor (Demo)
 * Description: Мониторинг цен на автошины по маркетплейсам. Шорткод: [tire_price_monitor]. Демо-данные + места для вставки API-запросов.
 * Version: 1.0.0
 * Author: Your Name
 */

if (!defined('ABSPATH')) exit;

class Tire_Price_Monitor_Demo {
    const AJAX_ACTION = 'tpm_fetch_prices';

    public function __construct() {
        add_shortcode('tire_price_monitor', [$this, 'shortcode']);

        add_action('wp_enqueue_scripts', [$this, 'register_assets']);

        add_action('wp_ajax_' . self::AJAX_ACTION, [$this, 'ajax_fetch_prices']);
        add_action('wp_ajax_nopriv_' . self::AJAX_ACTION, [$this, 'ajax_fetch_prices']);
    }

    /**
     * Регистрируем пустые "виртуальные" скрипт/стиль и добавляем inline-код.
     * Так удобнее: один файл плагина, без внешних файлов.
     */
    public function register_assets() {
        // JS
        wp_register_script('tpm-script', '', ['jquery'], '1.0.0', true);
        wp_enqueue_script('tpm-script');

        wp_localize_script('tpm-script', 'TPM', [
            'ajax_url' => admin_url('admin-ajax.php'),
            'action'   => self::AJAX_ACTION,
            'nonce'    => wp_create_nonce('tpm_nonce'),
        ]);

        wp_add_inline_script('tpm-script', $this->get_inline_js());

        // CSS
        wp_register_style('tpm-style', '', [], '1.0.0');
        wp_enqueue_style('tpm-style');
        wp_add_inline_style('tpm-style', $this->get_inline_css());
    }

    public function shortcode($atts) {
        $atts = shortcode_atts([
            'default_brand'  => '',
            'default_size'   => '',
            'default_season' => '',
        ], $atts, 'tire_price_monitor');

        // Чтобы выпадающие списки выглядели "реальными" — возьмём варианты из демо-каталога.
        $catalog = $this->get_demo_catalog(); // Только список моделей (без цен)
        $filters = $this->build_filter_options($catalog);

        ob_start();
        ?>
        <div class="tpm-wrap" data-default-brand="<?php echo esc_attr($atts['default_brand']); ?>"
             data-default-size="<?php echo esc_attr($atts['default_size']); ?>"
             data-default-season="<?php echo esc_attr($atts['default_season']); ?>">

            <div class="tpm-header">
                <h3 class="tpm-title">Мониторинг цен на шины</h3>
                <div class="tpm-subtitle">Демо-версия: данные берутся из массива. Можно заменить на API.</div>
            </div>

            <div class="tpm-filters">
                <div class="tpm-field">
                    <label>Бренд</label>
                    <select class="tpm-filter" name="brand">
                        <option value="">Все</option>
                        <?php foreach ($filters['brands'] as $brand): ?>
                            <option value="<?php echo esc_attr($brand); ?>"><?php echo esc_html($brand); ?></option>
                        <?php endforeach; ?>
                    </select>
                </div>

                <div class="tpm-field">
                    <label>Размер</label>
                    <select class="tpm-filter" name="size">
                        <option value="">Все</option>
                        <?php foreach ($filters['sizes'] as $size): ?>
                            <option value="<?php echo esc_attr($size); ?>"><?php echo esc_html($size); ?></option>
                        <?php endforeach; ?>
                    </select>
                </div>

                <div class="tpm-field">
                    <label>Сезон</label>
                    <select class="tpm-filter" name="season">
                        <option value="">Все</option>
                        <?php foreach ($filters['seasons'] as $season): ?>
                            <option value="<?php echo esc_attr($season); ?>"><?php echo esc_html($season); ?></option>
                        <?php endforeach; ?>
                    </select>
                </div>

                <div class="tpm-field tpm-field-search">
                    <label>Поиск</label>
                    <input class="tpm-filter" type="text" name="q" placeholder="Напр.: Michelin 205/55 R16">
                </div>

                <div class="tpm-actions">
                    <button class="tpm-btn tpm-btn-primary" type="button" data-role="apply">Показать</button>
                    <button class="tpm-btn" type="button" data-role="reset">Сброс</button>
                </div>
            </div>

            <div class="tpm-status" aria-live="polite"></div>

            <div class="tpm-table-wrap">
                <table class="tpm-table">
                    <thead>
                    <tr>
                        <th style="width: 28%;">Модель шины</th>
                        <th>Маркетплейс</th>
                        <th style="width: 12%;">Цена</th>
                        <th style="width: 16%;">Средняя по модели</th>
                        <th style="width: 24%;">Лучшая цена</th>
                    </tr>
                    </thead>
                    <tbody data-role="tbody">
                    <tr>
                        <td colspan="5" class="tpm-muted">Нажмите «Показать» для загрузки данных…</td>
                    </tr>
                    </tbody>
                </table>
            </div>

            <div class="tpm-footnote">
                <strong>Важно:</strong> Демо-данные внутри плагина. Места для API отмечены комментариями
                <code>TODO: REAL API</code>.
            </div>
        </div>
        <?php
        return ob_get_clean();
    }

    /**
     * AJAX: возвращаем HTML таблицы (tbody) по фильтрам
     */
    public function ajax_fetch_prices() {
        check_ajax_referer('tpm_nonce', 'nonce');

        $brand  = isset($_POST['brand']) ? sanitize_text_field($_POST['brand']) : '';
        $size   = isset($_POST['size']) ? sanitize_text_field($_POST['size']) : '';
        $season = isset($_POST['season']) ? sanitize_text_field($_POST['season']) : '';
        $q      = isset($_POST['q']) ? sanitize_text_field($_POST['q']) : '';

        // 1) Получаем данные от маркетплейсов (в демо — фейковые массивы)
        $items = $this->get_prices_aggregated([
            'brand'  => $brand,
            'size'   => $size,
            'season' => $season,
            'q'      => $q,
        ]);

        // 2) Рендерим tbody
        $html = $this->render_tbody($items);

        wp_send_json_success([
            'html'  => $html,
            'count' => count($items),
        ]);
    }

    /**
     * Главная функция агрегации:
     * - берёт товары с разных маркетплейсов
     * - группирует по "модели шины" (brand+model+size+season)
     * - считает среднюю и лучшую цену
     *
     * Возвращает массив моделей:
     * [
     *   [
     *     'key' => '...',
     *     'brand' => '',
     *     'model' => '',
     *     'size' => '',
     *     'season' => '',
     *     'title' => 'Michelin X-Ice 205/55 R16 (зимние)',
     *     'offers' => [
     *        ['marketplace'=>'Ozon','price'=>..., 'url'=>'...'],
     *        ...
     *     ],
     *     'avg_price' => 0,
     *     'best' => ['marketplace'=>'...', 'price'=>..., 'url'=>'...']
     *   ],
     * ]
     */
    private function get_prices_aggregated(array $filters): array {
        // --- НАСТРОЙКА СПИСКА МАРКЕТПЛЕЙСОВ ---
        // Здесь добавляете/убираете источники.
        $marketplaces = [
            'Ozon'         => 'ozon',
            'Wildberries'  => 'wb',
            'YandexMarket' => 'ym',
        ];

        // --- 1) Получаем "сырые" офферы по каждому маркетплейсу ---
        // Каждый оффер должен иметь поля: brand, model, size, season, price, url, marketplace
        $allOffers = [];
        foreach ($marketplaces as $mpName => $mpCode) {

            // ВАРИАНТ A: демо-данные (работает сразу)
            $offers = $this->fetch_offers_demo($mpCode);

            // ВАРИАНТ B: реальный API (выключено по умолчанию)
            // TODO: REAL API — раскомментируйте и реализуйте нужный метод
            /*
            if ($mpCode === 'wb') {
                $offers = $this->fetch_offers_wildberries_api($filters);
            } elseif ($mpCode === 'ozon') {
                $offers = $this->fetch_offers_ozon_api($filters);
            } else {
                $offers = [];
            }
            */

            // Проставим marketplace (в демо уже есть, но на всякий случай)
            foreach ($offers as &$o) {
                $o['marketplace'] = $o['marketplace'] ?? $mpName;
            }
            unset($o);

            $allOffers = array_merge($allOffers, $offers);
        }

        // --- 2) Применяем фильтры (brand/size/season/q) ---
        $allOffers = array_values(array_filter($allOffers, function($o) use ($filters) {
            if (!empty($filters['brand']) && strcasecmp($o['brand'], $filters['brand']) !== 0) return false;
            if (!empty($filters['size']) && strcasecmp($o['size'], $filters['size']) !== 0) return false;
            if (!empty($filters['season']) && strcasecmp($o['season'], $filters['season']) !== 0) return false;

            if (!empty($filters['q'])) {
                $hay = mb_strtolower($o['brand'] . ' ' . $o['model'] . ' ' . $o['size'] . ' ' . $o['season']);
                $needle = mb_strtolower($filters['q']);
                if (mb_strpos($hay, $needle) === false) return false;
            }

            // Цена должна быть валидной
            if (!isset($o['price']) || !is_numeric($o['price']) || $o['price'] <= 0) return false;

            return true;
        }));

        // --- 3) Группировка по модели ---
        $grouped = [];
        foreach ($allOffers as $o) {
            $key = $this->model_key($o['brand'], $o['model'], $o['size'], $o['season']);
            if (!isset($grouped[$key])) {
                $grouped[$key] = [
                    'key'    => $key,
                    'brand'  => $o['brand'],
                    'model'  => $o['model'],
                    'size'   => $o['size'],
                    'season' => $o['season'],
                    'title'  => sprintf('%s %s %s (%s)', $o['brand'], $o['model'], $o['size'], $o['season']),
                    'offers' => [],
                    'avg_price' => 0,
                    'best' => null,
                ];
            }
            $grouped[$key]['offers'][] = [
                'marketplace' => $o['marketplace'],
                'price'       => (float)$o['price'],
                'url'         => $o['url'] ?? '',
            ];
        }

        // --- 4) Расчёты avg и best ---
        foreach ($grouped as &$g) {
            $prices = array_map(fn($x) => $x['price'], $g['offers']);
            $g['avg_price'] = !empty($prices) ? array_sum($prices) / count($prices) : 0;

            $bestOffer = null;
            foreach ($g['offers'] as $offer) {
                if ($bestOffer === null || $offer['price'] < $bestOffer['price']) {
                    $bestOffer = $offer;
                }
            }
            $g['best'] = $bestOffer;
        }
        unset($g);

        // --- 5) Сортировка: сначала по лучшей цене (возрастание) ---
        $items = array_values($grouped);
        usort($items, function($a, $b) {
            $ap = $a['best']['price'] ?? PHP_FLOAT_MAX;
            $bp = $b['best']['price'] ?? PHP_FLOAT_MAX;
            return $ap <=> $bp;
        });

        return $items;
    }

    private function render_tbody(array $items): string {
        if (empty($items)) {
            return '<tr><td colspan="5" class="tpm-muted">Ничего не найдено по заданным фильтрам.</td></tr>';
        }

        $out = '';
        foreach ($items as $item) {
            $offers = $item['offers'];
            if (empty($offers)) continue;

            // Средняя цена формат
            $avg = $this->format_price($item['avg_price']);

            // "Модель" выводим один раз (rowspan)
            $rowspan = count($offers);
            $first = true;

            // лучшая цена
            $best = $item['best'];
            $bestText = '';
            if ($best) {
                $bestText = sprintf(
                    '<span class="tpm-best-badge">Лучшая</span> %s на <strong>%s</strong> — <a href="%s" target="_blank" rel="nofollow noopener">ссылка</a>',
                    $this->format_price($best['price']),
                    esc_html($best['marketplace']),
                    esc_url($best['url'])
                );
            }

            foreach ($offers as $offer) {
                $isBest = ($best && $offer['price'] == $best['price'] && $offer['marketplace'] === $best['marketplace']);

                $out .= '<tr class="'.($isBest ? 'tpm-row-best' : '').'">';

                if ($first) {
                    $out .= '<td rowspan="'.intval($rowspan).'">
                                <div class="tpm-model-title">'.esc_html($item['title']).'</div>
                                <div class="tpm-model-meta">'
                                    .esc_html($item['brand']).' • '.esc_html($item['size']).' • '.esc_html($item['season']).
                                '</div>
                             </td>';
                    $first = false;
                }

                $out .= '<td>'.esc_html($offer['marketplace']).'</td>';

                $priceCell = $this->format_price($offer['price']);
                $url = !empty($offer['url'])
                    ? '<a class="tpm-link" href="'.esc_url($offer['url']).'" target="_blank" rel="nofollow noopener">товар</a>'
                    : '';

                $out .= '<td class="tpm-price">'. $priceCell .' '.$url.'</td>';

                // avg и best выводим один раз (rowspan) рядом с первой строкой
                if ($offer === $offers[0]) {
                    $out .= '<td rowspan="'.intval($rowspan).'" class="tpm-avg">'.$avg.'</td>';
                    $out .= '<td rowspan="'.intval($rowspan).'" class="tpm-best">'.$bestText.'</td>';
                }

                $out .= '</tr>';
            }
        }

        return $out ?: '<tr><td colspan="5" class="tpm-muted">Нет данных.</td></tr>';
    }

    private function format_price($price): string {
        $price = (float)$price;
        return number_format($price, 0, '.', ' ') . ' ₽';
    }

    private function model_key(string $brand, string $model, string $size, string $season): string {
        return mb_strtolower(trim($brand).'|'.trim($model).'|'.trim($size).'|'.trim($season));
    }

    /**
     * ДЕМО-КАТАЛОГ (без цен). Нужен, чтобы наполнить фильтры.
     */
    private function get_demo_catalog(): array {
        return [
            ['brand'=>'Michelin', 'model'=>'X-Ice',            'size'=>'205/55 R16', 'season'=>'зимние'],
            ['brand'=>'Nokian',   'model'=>'Hakkapeliitta 9',  'size'=>'205/55 R16', 'season'=>'зимние'],
            ['brand'=>'Pirelli',  'model'=>'Cinturato P7',     'size'=>'205/55 R16', 'season'=>'летние'],
            ['brand'=>'Continental','model'=>'PremiumContact 6','size'=>'225/45 R17','season'=>'летние'],
            ['brand'=>'Yokohama', 'model'=>'BluEarth',         'size'=>'195/65 R15', 'season'=>'всесезонные'],
        ];
    }

    private function build_filter_options(array $catalog): array {
        $brands = $sizes = $seasons = [];

        foreach ($catalog as $c) {
            $brands[]  = $c['brand'];
            $sizes[]   = $c['size'];
            $seasons[] = $c['season'];
        }

        $brands = array_values(array_unique($brands));
        $sizes = array_values(array_unique($sizes));
        $seasons = array_values(array_unique($seasons));

        sort($brands);
        sort($sizes);
        sort($seasons);

        return compact('brands', 'sizes', 'seasons');
    }

    /**
     * ДЕМО-ИСТОЧНИК: отдаёт офферы как будто это маркетплейс.
     * Здесь вы видите структуру, которую должны отдавать реальные API-функции.
     *
     * TODO: REAL API — когда будете подключать API, замените вызовы fetch_offers_demo()
     * на реальные функции fetch_offers_*_api().
     */
    private function fetch_offers_demo(string $mpCode): array {
        // В этом массиве вы можете править цены/товары, чтобы проверить расчёты.
        $data = [
            'ozon' => [
                ['marketplace'=>'Ozon', 'brand'=>'Michelin', 'model'=>'X-Ice',            'size'=>'205/55 R16', 'season'=>'зимние',     'price'=>6990, 'url'=>'https://example.com/ozon/michelin-x-ice-205-55-r16'],
                ['marketplace'=>'Ozon', 'brand'=>'Nokian',   'model'=>'Hakkapeliitta 9',  'size'=>'205/55 R16', 'season'=>'зимние',     'price'=>8190, 'url'=>'https://example.com/ozon/nokian-hakka9-205-55-r16'],
                ['marketplace'=>'Ozon', 'brand'=>'Pirelli',  'model'=>'Cinturato P7',     'size'=>'205/55 R16', 'season'=>'летние',     'price'=>5890, 'url'=>'https://example.com/ozon/pirelli-p7-205-55-r16'],
            ],
            'wb' => [
                ['marketplace'=>'Wildberries', 'brand'=>'Michelin', 'model'=>'X-Ice',           'size'=>'205/55 R16', 'season'=>'зимние',     'price'=>6790, 'url'=>'https://example.com/wb/michelin-x-ice-205-55-r16'],
                ['marketplace'=>'Wildberries', 'brand'=>'Pirelli',  'model'=>'Cinturato P7',    'size'=>'205/55 R16', 'season'=>'летние',     'price'=>5990, 'url'=>'https://example.com/wb/pirelli-p7-205-55-r16'],
                ['marketplace'=>'Wildberries', 'brand'=>'Continental','model'=>'PremiumContact 6','size'=>'225/45 R17','season'=>'летние',     'price'=>8990, 'url'=>'https://example.com/wb/conti-pc6-225-45-r17'],
            ],
            'ym' => [
                ['marketplace'=>'YandexMarket', 'brand'=>'Michelin', 'model'=>'X-Ice',           'size'=>'205/55 R16', 'season'=>'зимние',       'price'=>7150, 'url'=>'https://example.com/ym/michelin-x-ice-205-55-r16'],
                ['marketplace'=>'YandexMarket', 'brand'=>'Nokian',   'model'=>'Hakkapeliitta 9', 'size'=>'205/55 R16', 'season'=>'зимние',       'price'=>7990, 'url'=>'https://example.com/ym/nokian-hakka9-205-55-r16'],
                ['marketplace'=>'YandexMarket', 'brand'=>'Yokohama', 'model'=>'BluEarth',        'size'=>'195/65 R15', 'season'=>'всесезонные',  'price'=>4490, 'url'=>'https://example.com/ym/yokohama-bluearth-195-65-r15'],
            ],
        ];

        return $data[$mpCode] ?? [];
    }

    /**
     * ============================
     * Примеры: КАК ВСТАВИТЬ РЕАЛЬНЫЕ API
     * ============================
     *
     * Ниже — шаблоны (упрощённые). ВАЖНО:
     * 1) У каждого API свои условия/эндпоинты/ограничения.
     * 2) Многие маркетплейсы не дают публичный "поиск цен" без кабинета продавца/партнёра.
     * 3) Поэтому здесь: рабочий каркас WordPress + места для реальных запросов.
     */

    /**
     * Пример Wildberries (иллюстративно):
     * TODO: REAL API
     *
     * Где указать ключ:
     * - вставьте токен в $token (лучше хранить в wp-config.php или в настройках плагина)
     *
     * Реальные эндпоинты WB могут отличаться (WB часто меняет API и доступность).
     */
    private function fetch_offers_wildberries_api(array $filters): array {
        // === ВАШ ТОКЕН/КЛЮЧ WB ===
        // Лучше: define('TPM_WB_TOKEN', '...') в wp-config.php, а здесь читать constant.
        $token = defined('TPM_WB_TOKEN') ? TPM_WB_TOKEN : 'PUT_WB_TOKEN_HERE';

        // Пример: вы сами подставляете нужный endpoint поиска/цен
        $endpoint = 'https://example-wb-api/search'; // TODO: заменить на реальный

        $query = [
            'q' => trim(($filters['brand'] ?? '') . ' ' . ($filters['size'] ?? '') . ' ' . ($filters['season'] ?? '')),
        ];

        $url = add_query_arg($query, $endpoint);

        $resp = wp_remote_get($url, [
            'timeout' => 15,
            'headers' => [
                'Authorization' => $token, // или "Bearer $token" — зависит от API
                'Accept'        => 'application/json',
            ],
        ]);

        if (is_wp_error($resp)) return [];

        $code = wp_remote_retrieve_response_code($resp);
        if ($code < 200 || $code >= 300) return [];

        $body = wp_remote_retrieve_body($resp);
        $json = json_decode($body, true);
        if (!is_array($json)) return [];

        // TODO: распарсить json и привести к единому формату offers
        // return [
        //   ['marketplace'=>'Wildberries','brand'=>'...','model'=>'...','size'=>'...','season'=>'...','price'=>1234,'url'=>'...'],
        // ];
        return [];
    }

    /**
     * Пример Ozon (иллюстративно):
     * TODO: REAL API
     *
     * Где указать ключи:
     * - Client-Id и Api-Key
     * - параметры магазина/склада/региона — зависят от используемого метода Ozon API
     */
    private function fetch_offers_ozon_api(array $filters): array {
        $clientId = defined('TPM_OZON_CLIENT_ID') ? TPM_OZON_CLIENT_ID : 'PUT_OZON_CLIENT_ID';
        $apiKey   = defined('TPM_OZON_API_KEY') ? TPM_OZON_API_KEY : 'PUT_OZON_API_KEY';

        // В Ozon API чаще POST + JSON (примерно так):
        $endpoint = 'https://api-seller.ozon.ru/v1/product/list'; // пример endpoint (может не подходить под поиск цен)

        $payload = [
            // TODO: тут ваши параметры запроса (фильтры, лимиты, и т.д.)
            'filter' => [
                'offer_id' => [],
                'product_id' => [],
                'visibility' => 'ALL',
            ],
            'limit' => 50,
        ];

        $resp = wp_remote_post($endpoint, [
            'timeout' => 15,
            'headers' => [
                'Client-Id'    => $clientId,
                'Api-Key'      => $apiKey,
                'Content-Type' => 'application/json',
                'Accept'       => 'application/json',
            ],
            'body' => wp_json_encode($payload),
        ]);

        if (is_wp_error($resp)) return [];
        $code = wp_remote_retrieve_response_code($resp);
        if ($code < 200 || $code >= 300) return [];

        $body = wp_remote_retrieve_body($resp);
        $json = json_decode($body, true);
        if (!is_array($json)) return [];

        // TODO: распарсить json и привести к единому формату offers
        return [];
    }

    private function get_inline_js(): string {
        return <<<JS
jQuery(function($){
  function setStatus(msg, type){
    var $s = $('.tpm-wrap .tpm-status');
    $s.removeClass('is-error is-ok is-loading');
    if(type) $s.addClass('is-' + type);
    $s.html(msg || '');
  }

  function collectFilters($wrap){
    var data = {
      action: TPM.action,
      nonce: TPM.nonce,
      brand:  $wrap.find('[name="brand"]').val() || '',
      size:   $wrap.find('[name="size"]').val() || '',
      season: $wrap.find('[name="season"]').val() || '',
      q:      $wrap.find('[name="q"]').val() || ''
    };
    return data;
  }

  function loadTable($wrap){
    var data = collectFilters($wrap);

    setStatus('Загрузка…', 'loading');
    $wrap.find('tbody[data-role="tbody"]').html('<tr><td colspan="5" class="tpm-muted">Загрузка…</td></tr>');

    $.ajax({
      url: TPM.ajax_url,
      method: 'POST',
      dataType: 'json',
      data: data
    }).done(function(resp){
      if(resp && resp.success){
        $wrap.find('tbody[data-role="tbody"]').html(resp.data.html);
        setStatus('Найдено моделей: ' + resp.data.count, 'ok');
      } else {
        setStatus('Ошибка получения данных.', 'error');
      }
    }).fail(function(){
      setStatus('AJAX ошибка. Проверьте консоль и настройки.', 'error');
    });
  }

  // Инициализация: применяем дефолты из data-атрибутов (если заданы в шорткоде)
  $('.tpm-wrap').each(function(){
    var $wrap = $(this);
    var db = $wrap.data('default-brand') || '';
    var ds = $wrap.data('default-size') || '';
    var dse = $wrap.data('default-season') || '';
    if(db) $wrap.find('[name="brand"]').val(db);
    if(ds) $wrap.find('[name="size"]').val(ds);
    if(dse) $wrap.find('[name="season"]').val(dse);
  });

  // Кнопки
  $(document).on('click', '.tpm-wrap [data-role="apply"]', function(){
    var $wrap = $(this).closest('.tpm-wrap');
    loadTable($wrap);
  });

  $(document).on('click', '.tpm-wrap [data-role="reset"]', function(){
    var $wrap = $(this).closest('.tpm-wrap');
    $wrap.find('[name="brand"]').val('');
    $wrap.find('[name="size"]').val('');
    $wrap.find('[name="season"]').val('');
    $wrap.find('[name="q"]').val('');
    loadTable($wrap);
  });

  // Авто-обновление при смене select
  $(document).on('change', '.tpm-wrap select.tpm-filter', function(){
    var $wrap = $(this).closest('.tpm-wrap');
    loadTable($wrap);
  });

  // Поиск: обновление с debounce
  var t = null;
  $(document).on('input', '.tpm-wrap input[name="q"]', function(){
    var $wrap = $(this).closest('.tpm-wrap');
    clearTimeout(t);
    t = setTimeout(function(){ loadTable($wrap); }, 400);
  });
});
JS;
    }

    private function get_inline_css(): string {
        return <<<CSS
.tpm-wrap{
  border: 1px solid #e5e7eb;
  border-radius: 10px;
  padding: 14px;
  background: #fff;
  max-width: 1100px;
}
.tpm-header{display:flex; flex-direction:column; gap:4px; margin-bottom:12px;}
.tpm-title{margin:0; font-size: 18px;}
.tpm-subtitle{color:#6b7280; font-size: 13px;}

.tpm-filters{
  display:flex; flex-wrap:wrap; gap:10px;
  align-items:flex-end;
  padding: 10px;
  background:#f9fafb;
  border:1px solid #eef2f7;
  border-radius:10px;
  margin-bottom: 10px;
}
.tpm-field{display:flex; flex-direction:column; gap:6px;}
.tpm-field label{font-size:12px; color:#374151;}
.tpm-field select, .tpm-field input{
  min-width: 180px;
  padding: 8px 10px;
  border: 1px solid #d1d5db;
  border-radius: 8px;
  background:#fff;
}
.tpm-field-search input{min-width: 260px;}

.tpm-actions{display:flex; gap:8px; margin-left:auto;}
.tpm-btn{
  padding: 9px 12px;
  border:1px solid #d1d5db;
  border-radius: 8px;
  background:#fff;
  cursor:pointer;
}
.tpm-btn-primary{
  background:#111827;
  color:#fff;
  border-color:#111827;
}

.tpm-status{
  margin: 10px 0;
  font-size: 13px;
  color:#374151;
}
.tpm-status.is-loading{color:#6b7280;}
.tpm-status.is-error{color:#b91c1c;}
.tpm-status.is-ok{color:#065f46;}

.tpm-table-wrap{overflow:auto;}
.tpm-table{
  width:100%;
  border-collapse: collapse;
  border:1px solid #e5e7eb;
  border-radius: 10px;
  overflow:hidden;
}
.tpm-table th, .tpm-table td{
  padding: 10px;
  border-bottom: 1px solid #eef2f7;
  vertical-align: top;
  font-size: 14px;
}
.tpm-table th{
  background: #f3f4f6;
  text-align:left;
  font-size: 13px;
  color:#374151;
}
.tpm-muted{color:#6b7280; text-align:center; padding: 18px !important;}
.tpm-model-title{font-weight:600; margin-bottom:4px;}
.tpm-model-meta{font-size:12px; color:#6b7280;}
.tpm-price{white-space:nowrap;}
.tpm-link{margin-left:8px; font-size: 12px;}
.tpm-avg{white-space:nowrap; font-weight:600;}
.tpm-best{font-size: 13px;}
.tpm-best-badge{
  display:inline-block;
  padding: 2px 8px;
  border-radius: 999px;
  background: #dcfce7;
  color:#166534;
  font-size: 12px;
  margin-right: 6px;
  border: 1px solid #bbf7d0;
}
.tpm-row-best td{
  background: #fffbeb;
}
.tpm-footnote{
  margin-top:10px;
  font-size: 12px;
  color:#6b7280;
}
CSS;
    }
}

new Tire_Price_Monitor_Demo();