<?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();