{% extends 'base.html.twig' %}
{% block title %}Каталог — PlayPapa{% endblock %}
{% block body_class %}preloader-active catalog-page{% endblock %}
{% block head_scripts %}
<script>
window.__preloaderStart = performance.now();
try {
if ("scrollRestoration" in history) history.scrollRestoration = "manual";
if (!window.location.hash || window.location.hash === "#") {
window.addEventListener("load", () => requestAnimationFrame(() => window.scrollTo(0, 0)), { once: true });
}
} catch {}
</script>
<script>
window.playpapaCatalog = {{ catalog_payload_json|default('{"products":[],"total":0}')|raw }};
window.playpapaCatalogState = {
category: "{{ catalog_active_category|default('all')|e('js') }}",
query: "{{ catalog_query|default('')|e('js') }}",
sort: "{{ catalog_active_sort|default('popular')|e('js') }}"
};
</script>
<script>
tailwind = {
config: {
theme: {
extend: {
colors: {
papa: "#268bf3",
ink: "#202838",
soft: "#f2f4f8",
},
},
},
},
};
</script>
<script src="https://cdn.tailwindcss.com" defer></script>
{% endblock %}
{% block stylesheets %}
<link rel="stylesheet" href="/catalog.css" />
{% endblock %}
{% block body_start %}
<div class="page-preloader" id="pagePreloader" role="status" aria-label="Загрузка">
<div class="preloader-inner">
<div class="preloader-dots" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
<div class="preloader-text">Загрузка...</div>
</div>
</div>
{% endblock %}
{% block content %}
<section class="shell catalog-official" aria-labelledby="catalogOfficialTitle">
<div class="catalog-official-head">
<span class="catalog-shield" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none">
<path d="M12 3.2 19 6v5.4c0 4.4-2.8 7.9-7 9.4-4.2-1.5-7-5-7-9.4V6l7-2.8Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
<path d="m9.3 12.1 1.8 1.8 3.9-4.1" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
<h1 id="catalogOfficialTitle">Официально от PlayPapa</h1>
<div class="catalog-official-actions" aria-label="Навигация по подборке">
<button class="catalog-official-arrow" type="button" data-official-scroll="prev" aria-label="Предыдущие товары">
<img class="catalog-arrow-icon catalog-arrow-left" src="./assets/catalog-arrow.svg" alt="" aria-hidden="true" />
</button>
<button class="catalog-official-arrow" type="button" data-official-scroll="next" aria-label="Следующие товары">
<img class="catalog-arrow-icon catalog-arrow-right" src="./assets/catalog-arrow.svg" alt="" aria-hidden="true" />
</button>
</div>
</div>
<div class="product-grid catalog-feature-grid" data-products="catalog-featured" data-count="10"></div>
</section>
<section class="shell catalog-page-layout" aria-label="Каталог товаров">
<aside class="catalog-filter-panel" id="catalogFilterPanel" aria-label="Фильтры каталога">
<div class="catalog-filter-head">
<img src="./assets/filter-candle.svg" alt="" />
<b>Фильтр</b>
<button class="catalog-mobile-filter-close" type="button" aria-label="Закрыть фильтр">
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="m4 4 8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" />
</svg>
</button>
</div>
<section class="filter-group" hidden aria-hidden="true">
<h2><button class="filter-group-toggle" type="button" aria-expanded="true">Способ получения <img class="catalog-arrow-icon" src="./assets/catalog-arrow.svg" alt="" aria-hidden="true" /></button></h2>
<div class="filter-group-body">
<label class="filter-check"><input type="checkbox" /> <span>Без входа в аккаунт</span></label>
<label class="filter-check"><input type="checkbox" /> <span>Со входом в аккаунт</span></label>
<label class="filter-check"><input type="checkbox" /> <span>Аккаунт с лицензией</span></label>
</div>
</section>
<section class="filter-group">
<h2><button class="filter-group-toggle" type="button" aria-expanded="true">Тип <img class="catalog-arrow-icon" src="./assets/catalog-arrow.svg" alt="" aria-hidden="true" /></button></h2>
<div class="filter-group-body">
{% for typeFilter in catalog_type_filters|default([]) %}
<label class="filter-check"><input type="checkbox" value="{{ typeFilter.id }}" data-filter-type="type" /> <span>{{ typeFilter.label }}</span></label>
{% endfor %}
</div>
</section>
<section class="filter-group">
<h2><button class="filter-group-toggle" type="button" aria-expanded="true">Жанр <img class="catalog-arrow-icon" src="./assets/catalog-arrow.svg" alt="" aria-hidden="true" /></button></h2>
<div class="filter-group-body">
{% for genreFilter in catalog_genre_filters|default([]) %}
<label class="filter-check"><input type="checkbox" value="{{ genreFilter.id }}" data-filter-type="genre" /> <span>{{ genreFilter.label }}</span></label>
{% endfor %}
</div>
</section>
<section class="filter-group filter-price-group" data-price-filter data-min-price="1" data-max-price="10000" data-price-step="100">
<h2><button class="filter-group-toggle" type="button" aria-expanded="true">Цена <img class="catalog-arrow-icon" src="./assets/catalog-arrow.svg" alt="" aria-hidden="true" /></button></h2>
<div class="filter-group-body">
<div class="filter-price-row">
<label><span>от</span><input type="text" inputmode="numeric" value="1" data-price-min-input /><b>₽</b></label>
<label><span>до</span><input type="text" inputmode="numeric" value="10000" data-price-max-input /><b>₽</b></label>
</div>
<div class="filter-range">
<span></span>
<i></i>
<b></b>
</div>
<div class="filter-range-labels"><span>1</span><span>10000</span></div>
</div>
</section>
<section class="filter-group" hidden aria-hidden="true">
<h2><button class="filter-group-toggle" type="button" aria-expanded="true">Сервисы <img class="catalog-arrow-icon" src="./assets/catalog-arrow.svg" alt="" aria-hidden="true" /></button></h2>
<div class="filter-group-body">
<label class="filter-check"><input type="checkbox" /> <span>Steam</span></label>
<label class="filter-check"><input type="checkbox" /> <span>xBox</span></label>
</div>
</section>
<div class="filter-actions">
<button class="filter-apply" type="button">Применить <span hidden>0</span></button>
<button class="filter-reset" type="button">Сбросить</button>
</div>
</aside>
<section class="catalog-results" aria-label="Список товаров">
<div class="catalog-controls">
<nav class="catalog-category-tabs" aria-label="Категории товаров">
{% for categoryFilter in catalog_category_filters|default([]) %}
<a
class="chip {{ categoryFilter.active ? 'is-active' : '' }}"
href="{{ categoryFilter.url }}"
data-catalog-category="{{ categoryFilter.id }}"
aria-current="{{ categoryFilter.active ? 'true' : 'false' }}"
>
<img src="./assets/{{ categoryFilter.icon }}" alt="" />{{ categoryFilter.label }}
</a>
{% endfor %}
</nav>
<div class="catalog-toolbar">
<label class="catalog-search">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="m21 21-4.35-4.35m2.35-5.65a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" />
</svg>
<input type="search" placeholder="Поиск по названию" value="{{ catalog_query|default('') }}" data-catalog-search />
</label>
<button class="catalog-pill catalog-pill-green" type="button"><img src="./assets/catalog-sale.svg" alt="" />Скидка</button>
<button class="catalog-pill catalog-pill-orange" type="button"><img src="./assets/catalog-flash.svg" alt="" />Хит продаж</button>
<button class="catalog-pill catalog-pill-blue" type="button"><img src="./assets/catalog-reviews.svg" alt="" />Лучшие отзывы</button>
</div>
</div>
<div class="catalog-mobile-filterbar" aria-label="Фильтры и сортировка">
<div class="catalog-mobile-filter-row">
<button class="catalog-mobile-filter-btn" type="button" aria-expanded="false" aria-controls="catalogFilterPanel">
<img src="./assets/filter-candle.svg" alt="" />
<span>Фильтр</span>
<b {{ catalog_active_category|default('all') == 'all' ? 'hidden' : '' }}>{{ catalog_active_category|default('all') == 'all' ? '0' : '1' }}</b>
</button>
<button class="catalog-mobile-sort" type="button" aria-expanded="false" aria-controls="catalogMobileSortPanel">
<img class="catalog-sort-direction-icon" src="./assets/sort-arrow-3.svg" alt="" aria-hidden="true" />
<span class="catalog-mobile-sort-label">Популярные</span>
</button>
</div>
<div class="catalog-mobile-tags" aria-label="Активные фильтры">
{% if catalog_active_category|default('all') == 'all' %}
<button type="button" disabled>Все товары</button>
{% else %}
{% set activeCategoryLabel = 'Категория' %}
{% for categoryFilter in catalog_category_filters|default([]) %}
{% if categoryFilter.id == catalog_active_category|default('all') %}
{% set activeCategoryLabel = categoryFilter.label %}
{% endif %}
{% endfor %}
<a href="{{ path('catalog', catalog_query|default('') ? {'q': catalog_query} : {}) }}">{{ activeCategoryLabel }} <span aria-hidden="true">×</span></a>
<a href="{{ path('catalog') }}">Сбросить</a>
{% endif %}
</div>
<div class="catalog-mobile-sort-panel" id="catalogMobileSortPanel" aria-hidden="true" inert>
<button class="is-selected" type="button" data-sort-value="popular" data-sort-label="Популярные">Популярные <span aria-hidden="true">✓</span></button>
<button type="button" data-sort-value="price_asc" data-sort-label="Сначала дешёвые">Сначала дешёвые <span aria-hidden="true">✓</span></button>
<button type="button" data-sort-value="price_desc" data-sort-label="Сначала дорогие">Сначала дорогие <span aria-hidden="true">✓</span></button>
<button type="button" data-sort-value="new" data-sort-label="Сначала новинки">Сначала новинки <span aria-hidden="true">✓</span></button>
<button type="button" data-sort-value="rating" data-sort-label="По рейтингу">По рейтингу <span aria-hidden="true">✓</span></button>
</div>
</div>
<div class="catalog-meta-row">
<p class="catalog-total">Всего {{ catalog_total|default(catalog_products|default([])|length) }} товаров</p>
<div class="catalog-sort-row">
<button class="catalog-sort-icon" type="button" aria-label="Сортировать по цене">
<img class="catalog-sort-direction-icon" src="./assets/sort-arrow-3.svg" alt="" aria-hidden="true" />
</button>
<button class="catalog-sort-icon" type="button" aria-label="Фильтр сортировки">
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M4 5h12M6 10h10M9 15h7" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
</button>
<button class="catalog-sort" type="button">Популярные</button>
</div>
</div>
<div class="product-grid catalog-product-grid catalog-product-grid-primary" data-products="catalog-results-grid-primary" data-count="12"></div>
<section class="topup-row catalog-topup-row" aria-label="Пополнение Steam">
<div class="topup-info">
<img src="./assets/steam-topup.png" alt="" />
<div>
<div class="topup-title">
Пополнение Steam
<span>5%</span>
</div>
<button class="promo-small" type="button" aria-expanded="false" aria-controls="topupPromoSection">
<span class="promo-small-label">Ввести промокод</span>
<svg viewBox="0 0 16 16" fill="none"><path d="m4 6 4 4 4-4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" /></svg>
</button>
</div>
</div>
<label class="form-field login-field">
<img src="./assets/profile-gray.svg" alt="" />
<input type="text" name="steam-login" placeholder="Логин Steam" />
<span class="field-info" aria-hidden="true"><img src="./assets/info-i.svg" alt="" /></span>
</label>
<div class="form-field amount-field">
<div class="amount-field-shell">
<span class="currency-mark" aria-hidden="true">
<svg class="currency-svg currency-svg-ruble" viewBox="0 0 12 12" fill="none">
<path d="M1.55153 11.2002V0.00019455H6.39953C7.89287 0.00019455 9.07687 0.314861 9.95153 0.944195C10.8262 1.57353 11.2635 2.51753 11.2635 3.7762C11.2635 5.0242 10.8262 5.96286 9.95153 6.5922C9.07687 7.22153 7.89287 7.5362 6.39953 7.5362H2.99153L4.15953 6.38419V11.2002H1.55153ZM-0.000468731 9.7602V8.4322H7.61553V9.7602H-0.000468731ZM4.15953 6.6562L2.99153 5.5042H6.38353C7.0982 5.5042 7.65287 5.3602 8.04753 5.0722C8.4422 4.77353 8.63953 4.34686 8.63953 3.79219C8.63953 3.2482 8.4422 2.8322 8.04753 2.5442C7.65287 2.2562 7.0982 2.1122 6.38353 2.1122H2.99153L4.15953 0.880195V6.6562Z" fill="currentColor" />
</svg>
</span>
<label class="amount-control">
<small>Сумма</small>
<span class="amount-value">
<input type="text" name="amount" inputmode="numeric" value="500" maxlength="6" aria-label="Сумма" aria-expanded="false" aria-controls="topupQuickPopover" />
<svg class="currency-svg currency-svg-ruble" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M1.55153 11.2002V0.00019455H6.39953C7.89287 0.00019455 9.07687 0.314861 9.95153 0.944195C10.8262 1.57353 11.2635 2.51753 11.2635 3.7762C11.2635 5.0242 10.8262 5.96286 9.95153 6.5922C9.07687 7.22153 7.89287 7.5362 6.39953 7.5362H2.99153L4.15953 6.38419V11.2002H1.55153ZM-0.000468731 9.7602V8.4322H7.61553V9.7602H-0.000468731ZM4.15953 6.6562L2.99153 5.5042H6.38353C7.0982 5.5042 7.65287 5.3602 8.04753 5.0722C8.4422 4.77353 8.63953 4.34686 8.63953 3.79219C8.63953 3.2482 8.4422 2.8322 8.04753 2.5442C7.65287 2.2562 7.0982 2.1122 6.38353 2.1122H2.99153L4.15953 0.880195V6.6562Z" fill="currentColor" />
</svg>
</span>
</label>
<span class="currency-tabs" role="group" aria-label="Валюта">
<button type="button" aria-label="Доллар">
<svg class="currency-svg currency-svg-dollar" viewBox="0 0 10 16" fill="none" aria-hidden="true">
<path d="M4.56073 13.3121C3.66473 13.3121 2.80607 13.1948 1.98473 12.9601C1.1634 12.7148 0.502068 12.4001 0.000734389 12.0161L0.880734 10.0641C1.36073 10.4055 1.92607 10.6881 2.57673 10.9121C3.23807 11.1255 3.90473 11.2321 4.57673 11.2321C5.08873 11.2321 5.4994 11.1841 5.80873 11.0881C6.12873 10.9815 6.3634 10.8375 6.51273 10.6561C6.66207 10.4748 6.73673 10.2668 6.73673 10.0321C6.73673 9.73345 6.6194 9.49878 6.38473 9.32812C6.15007 9.14678 5.84073 9.00278 5.45673 8.89612C5.07273 8.77878 4.64607 8.67212 4.17673 8.57612C3.71807 8.46945 3.25407 8.34145 2.78473 8.19212C2.32607 8.04278 1.90473 7.85078 1.52073 7.61612C1.13673 7.38145 0.822068 7.07212 0.576734 6.68812C0.342068 6.30412 0.224734 5.81345 0.224734 5.21612C0.224734 4.57612 0.395401 3.99478 0.736734 3.47212C1.08873 2.93878 1.6114 2.51745 2.30473 2.20812C3.00873 1.88812 3.88873 1.72812 4.94473 1.72812C5.64873 1.72812 6.34207 1.81345 7.02473 1.98412C7.7074 2.14412 8.31007 2.38945 8.83274 2.72012L8.03273 4.68812C7.51007 4.38945 6.9874 4.17078 6.46473 4.03212C5.94207 3.88278 5.43007 3.80812 4.92873 3.80812C4.4274 3.80812 4.01673 3.86678 3.69673 3.98412C3.37673 4.10145 3.1474 4.25612 3.00873 4.44812C2.87007 4.62945 2.80073 4.84278 2.80073 5.08812C2.80073 5.37612 2.91807 5.61078 3.15273 5.79212C3.3874 5.96278 3.69673 6.10145 4.08073 6.20812C4.46473 6.31478 4.88607 6.42145 5.34473 6.52812C5.81407 6.63478 6.27807 6.75745 6.73673 6.89612C7.20607 7.03478 7.63273 7.22145 8.01674 7.45612C8.40073 7.69078 8.71007 8.00012 8.94473 8.38412C9.19007 8.76812 9.31273 9.25345 9.31273 9.84012C9.31273 10.4695 9.13673 11.0455 8.78473 11.5681C8.43273 12.0908 7.90473 12.5121 7.20073 12.8321C6.5074 13.1521 5.6274 13.3121 4.56073 13.3121ZM3.95273 15.0401V0.000116348H5.55273V15.0401H3.95273Z" fill="currentColor" />
</svg>
</button>
<button type="button" aria-label="Тенге">
<svg class="currency-svg currency-svg-tenge" viewBox="0 0 10 12" fill="none" aria-hidden="true">
<path d="M3.56819 11.2002V3.61619H0.000187516V2.27219H9.74419V3.61619H6.16019V11.2002H3.56819ZM0.000187516 1.34419V0.00019455H9.74419V1.34419H0.000187516Z" fill="currentColor" />
</svg>
</button>
<button class="is-active" type="button" aria-label="Рубль">
<svg class="currency-svg currency-svg-ruble" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M1.55153 11.2002V0.00019455H6.39953C7.89287 0.00019455 9.07687 0.314861 9.95153 0.944195C10.8262 1.57353 11.2635 2.51753 11.2635 3.7762C11.2635 5.0242 10.8262 5.96286 9.95153 6.5922C9.07687 7.22153 7.89287 7.5362 6.39953 7.5362H2.99153L4.15953 6.38419V11.2002H1.55153ZM-0.000468731 9.7602V8.4322H7.61553V9.7602H-0.000468731ZM4.15953 6.6562L2.99153 5.5042H6.38353C7.0982 5.5042 7.65287 5.3602 8.04753 5.0722C8.4422 4.77353 8.63953 4.34686 8.63953 3.79219C8.63953 3.2482 8.4422 2.8322 8.04753 2.5442C7.65287 2.2562 7.0982 2.1122 6.38353 2.1122H2.99153L4.15953 0.880195V6.6562Z" fill="currentColor" />
</svg>
</button>
</span>
</div>
<div class="topup-quick-popover" id="topupQuickPopover" aria-hidden="true" inert>
<span class="topup-quick-popover-label">Быстрый выбор суммы</span>
<div class="topup-quick-popover-buttons" aria-label="Быстрый выбор суммы">
<button class="is-active" type="button" data-quick-amount="500">500 ₽</button>
<button type="button" data-quick-amount="1000">1 000 ₽</button>
<button type="button" data-quick-amount="2500">2 500 ₽</button>
<button type="button" data-quick-amount="5000">5 000 ₽</button>
</div>
</div>
</div>
<button class="pay-btn" type="button" aria-expanded="false" aria-controls="topupPaymentSection">Оплатить 500<svg class="currency-svg currency-svg-ruble pay-currency-icon" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M1.55153 11.2002V0.00019455H6.39953C7.89287 0.00019455 9.07687 0.314861 9.95153 0.944195C10.8262 1.57353 11.2635 2.51753 11.2635 3.7762C11.2635 5.0242 10.8262 5.96286 9.95153 6.5922C9.07687 7.22153 7.89287 7.5362 6.39953 7.5362H2.99153L4.15953 6.38419V11.2002H1.55153ZM-0.000468731 9.7602V8.4322H7.61553V9.7602H-0.000468731ZM4.15953 6.6562L2.99153 5.5042H6.38353C7.0982 5.5042 7.65287 5.3602 8.04753 5.0722C8.4422 4.77353 8.63953 4.34686 8.63953 3.79219C8.63953 3.2482 8.4422 2.8322 8.04753 2.5442C7.65287 2.2562 7.0982 2.1122 6.38353 2.1122H2.99153L4.15953 0.880195V6.6562Z" fill="currentColor" /></svg></button>
<div class="topup-expand" id="topupDetails" aria-hidden="true" inert>
<div class="topup-expand-clip">
<div class="topup-expand-content">
<section class="topup-expand-section topup-promo-section" id="topupPromoSection" aria-hidden="true" inert>
<div class="topup-expand-section-inner">
<label class="topup-detail-label" for="topupPromoInput">Промокод</label>
<div class="topup-promo-row">
<label class="topup-promo-input">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 10h16v10H4V10Zm0-4h16v4H4V6Zm8 0v14M12 6c-1.7-3.1-5.2-2.8-5.2-.5C6.8 7.2 9 7.5 12 6Zm0 0c1.7-3.1 5.2-2.8 5.2-.5C17.2 7.2 15 7.5 12 6Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
</svg>
<input id="topupPromoInput" type="text" placeholder="Введите промокод" autocomplete="off" />
</label>
<button class="topup-promo-apply" type="button">Применить</button>
</div>
</div>
</section>
<div class="topup-promo-benefit" aria-live="polite" aria-hidden="true">
<img src="./assets/discount-percent.png" alt="" />
<strong>Скидка <span>5%</span> - вы экономите <b data-promo-saving>25 ₽</b></strong>
<small><span>Вы платите примерно на 12% меньше,</span><span>чем на других площадках</span></small>
</div>
<section class="topup-expand-section topup-payment-section" id="topupPaymentSection" aria-hidden="true" inert>
<div class="topup-expand-section-inner">
<h3 class="topup-detail-label topup-payment-title">Способ оплаты</h3>
<div class="topup-payment-methods" role="radiogroup" aria-label="Способ оплаты">
<button class="topup-payment-method is-selected" type="button" role="radio" aria-checked="true" data-fee="0">
<span class="topup-payment-icon"><img src="./assets/payment-sbp.svg" alt="" /></span>
<span class="topup-payment-copy"><b>СБП</b><small>Без комиссии</small></span>
<span class="topup-payment-check" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><path d="m5 10 3 3 7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg></span>
</button>
<button class="topup-payment-method" type="button" role="radio" aria-checked="false" data-fee="2">
<span class="topup-payment-icon"><img src="./assets/payment-mir-option.svg" alt="" /></span>
<span class="topup-payment-copy"><b>Карта МИР</b><small>+2% комиссия</small></span>
<span class="topup-payment-check" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><path d="m5 10 3 3 7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg></span>
</button>
<button class="topup-payment-method" type="button" role="radio" aria-checked="false" data-fee="0">
<span class="topup-payment-icon"><img src="./assets/payment-btc.svg" alt="" /></span>
<span class="topup-payment-copy"><b>Криптовалюта</b><small>Без комиссии</small></span>
<span class="topup-payment-check" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><path d="m5 10 3 3 7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg></span>
</button>
</div>
<div class="topup-total-row">
<div class="topup-total-copy">
<small>К зачислению на Steam</small>
<span><strong data-topup-credit>500 ₽</strong><b data-topup-fee-badge>Без комиссии</b></span>
</div>
<button class="topup-final-pay" type="button">Оплатить 500₽</button>
</div>
</div>
</section>
</div>
</div>
</div>
</section>
<div class="product-grid catalog-product-grid catalog-product-grid-secondary" data-products="catalog-results-grid-secondary" data-count="8"></div>
<div class="catalog-loader" role="status">
<div class="catalog-loader-dots" aria-hidden="true"><span></span><span></span><span></span></div>
<strong>Подгружаем ещё товары...</strong>
</div>
</section>
</section>
{% endblock %}
{% block body_extras %}
{% include 'components/mobile_search.html.twig' %}
<div data-mobile-panel></div>
{% endblock %}
{% block scripts %}
<script src="/assets/mobile-panel.js"></script>
<script>
const root = document.documentElement;
const themeSwitch = document.querySelector(".theme-switch");
const themeButtons = document.querySelectorAll(".theme-btn[data-theme]");
const preloader = document.getElementById("pagePreloader");
if (preloader) {
const preloaderStart = window.__preloaderStart || performance.now();
const minPreloaderTime = 600;
const maxPreloaderTime = 3000;
let preloaderHidden = false;
const hidePreloader = () => {
if (preloaderHidden) return;
preloaderHidden = true;
preloader.classList.add("is-hiding");
document.body.classList.remove("preloader-active");
window.setTimeout(() => preloader.remove(), 260);
};
const requestPreloaderHide = () => {
const elapsed = performance.now() - preloaderStart;
window.setTimeout(hidePreloader, Math.max(minPreloaderTime - elapsed, 0));
};
window.setTimeout(hidePreloader, Math.max(maxPreloaderTime - (performance.now() - preloaderStart), 0));
if (document.readyState === "complete") {
requestPreloaderHide();
} else {
window.addEventListener("load", requestPreloaderHide, { once: true });
}
}
const setTheme = (theme, animate = false) => {
if (animate) {
document.body.classList.remove("theme-animating");
void document.body.offsetWidth;
document.body.classList.add("theme-animating");
}
root.dataset.theme = theme;
try {
localStorage.setItem("playpapa-theme", theme);
} catch {}
themeButtons.forEach((button) => {
const active = button.dataset.theme === theme;
button.classList.toggle("is-active", active);
button.setAttribute("aria-pressed", active ? "true" : "false");
});
if (animate) window.setTimeout(() => document.body.classList.remove("theme-animating"), 560);
};
setTheme(root.dataset.theme === "dark" ? "dark" : "light");
themeButtons.forEach((button) => {
button.addEventListener("click", () => setTheme(button.dataset.theme, true));
});
themeSwitch?.addEventListener("click", (event) => {
if (event.target.closest(".theme-btn")) return;
setTheme(root.dataset.theme === "dark" ? "light" : "dark", true);
});
const mobileSearchLayer = document.getElementById("mobileSearch");
const mobileSearchTrigger = document.querySelector("[data-mobile-search-trigger]");
const mobileSearchBack = document.getElementById("mobileSearchBack");
const setMobileSearchOpen = (open) => {
if (!mobileSearchLayer) return;
mobileSearchLayer.classList.toggle("is-open", open);
mobileSearchLayer.setAttribute("aria-hidden", open ? "false" : "true");
mobileSearchLayer.toggleAttribute("inert", !open);
mobileSearchTrigger?.setAttribute("aria-expanded", open ? "true" : "false");
document.body.classList.toggle("mobile-search-open", open);
if (open) mobileSearchLayer.scrollTop = 0;
};
mobileSearchTrigger?.addEventListener("click", (event) => {
event.preventDefault();
setProfileOpen(false);
setCatalogOpen(false);
setMobileSearchOpen(true);
});
mobileSearchBack?.addEventListener("click", () => {
setMobileSearchOpen(false);
mobileSearchTrigger?.focus();
});
mobileSearchLayer?.querySelector(".mobile-search-form")?.addEventListener("submit", (event) => event.preventDefault());
mobileSearchLayer?.querySelectorAll('a[href="#"]').forEach((link) => {
link.addEventListener("click", (event) => event.preventDefault());
});
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape" || !mobileSearchLayer?.classList.contains("is-open")) return;
setMobileSearchOpen(false);
mobileSearchTrigger?.focus();
});
const profileMenu = document.querySelector(".profile-menu");
const profileButton = profileMenu?.querySelector(".profile-btn");
const profilePopover = document.getElementById("profilePopover");
const setProfileOpen = (open, returnFocus = false) => {
if (!profileMenu || !profilePopover || !profileButton) return;
profileMenu.classList.toggle("is-open", open);
profileButton.setAttribute("aria-expanded", open ? "true" : "false");
profilePopover.setAttribute("aria-hidden", open ? "false" : "true");
profilePopover.toggleAttribute("inert", !open);
document.body.classList.toggle("profile-open", open);
if (returnFocus) profileButton.focus();
};
profileButton?.addEventListener("click", (event) => {
event.stopPropagation();
const shouldOpen = !profileMenu?.classList.contains("is-open");
setProfileOpen(shouldOpen);
});
profilePopover?.querySelectorAll(".profile-menu-item:not(:disabled)").forEach((item) => {
item.addEventListener("click", () => setProfileOpen(false));
});
document.addEventListener("pointerdown", (event) => {
if (!profileMenu?.classList.contains("is-open") || profileMenu.contains(event.target)) return;
setProfileOpen(false);
});
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape" || !profileMenu?.classList.contains("is-open")) return;
setProfileOpen(false, true);
});
const catalogLayer = document.getElementById("catalogMegaMenu");
const catalogButton = document.querySelector(".catalog-btn:not([href])");
const catalogTriggers = [catalogButton, ...document.querySelectorAll("[data-catalog-trigger]")].filter(Boolean);
const catalogSideLinks = document.querySelectorAll(".catalog-side-link");
const catalogFeaturedItems = [
["service-steam.png", "Пополнение Steam"],
["service-brawl.png", "Game Pass"],
["service-steam.png", "Пополнение PSN"],
["service-brawl.png", "PS Plus"],
["service-pubg.png", "Grand Theft Auto V"],
["service-roblox.png", "Minecraft"],
["service-playstation.png", "Diablo 4"],
["service-mobile.png", "ARC Raiders"],
["service-chatgpt.png", "Dead by Daylight"],
];
const catalogNewItems = [
["article-paradise.png", "Gothic 1 Remake"],
["article-sale.png", "Forza Horizon 6"],
["product-pubg.png", "Assassin's Creed: Black..."],
["article-mobile.png", "007 First Light"],
["article-sale.png", "Call of Duty: Modern..."],
["product-pubg.png", "Marvel's Wolverine"],
["article-paradise.png", "UFC 6"],
["article-sale.png", "Subnautica 2"],
["product-pubg.png", "SILENT HILL: Townfall"],
];
const catalogItemMarkup = ([image, label]) => `
<a class="catalog-showcase-item" href="#" aria-label="${label}">
<img src="./assets/${image}" alt="">
<span>${label}</span>
</a>
`;
const catalogFeatured = document.getElementById("catalogFeatured");
const catalogNew = document.getElementById("catalogNew");
if (catalogFeatured) catalogFeatured.innerHTML = catalogFeaturedItems.map(catalogItemMarkup).join("");
if (catalogNew) catalogNew.innerHTML = catalogNewItems.map(catalogItemMarkup).join("");
const setCatalogOpen = (open) => {
if (!catalogLayer) return;
catalogLayer.classList.toggle("is-open", open);
catalogLayer.setAttribute("aria-hidden", open ? "false" : "true");
catalogLayer.toggleAttribute("inert", !open);
document.body.classList.toggle("catalog-open", open);
catalogTriggers.forEach((trigger) => trigger.setAttribute("aria-expanded", open ? "true" : "false"));
};
catalogTriggers.forEach((trigger) => {
trigger.addEventListener("click", (event) => {
event.preventDefault();
setProfileOpen(false);
setMobileSearchOpen(false);
setCatalogOpen(!catalogLayer?.classList.contains("is-open"));
});
});
catalogSideLinks.forEach((link) => {
link.addEventListener("click", () => {
catalogSideLinks.forEach((item) => item.classList.toggle("is-active", item === link));
});
});
catalogLayer?.querySelectorAll('a[href="#"]').forEach((link) => {
link.addEventListener("click", (event) => event.preventDefault());
});
document.addEventListener("pointerdown", (event) => {
if (!catalogLayer?.classList.contains("is-open")) return;
if (catalogLayer.contains(event.target) || catalogTriggers.some((trigger) => trigger.contains(event.target))) return;
setCatalogOpen(false);
});
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape" || !catalogLayer?.classList.contains("is-open")) return;
setCatalogOpen(false);
catalogButton?.focus();
});
const authModal = document.getElementById("authModal");
const authDialog = authModal?.querySelector(".auth-modal-dialog");
const authScreens = new Set(["login", "register", "recover", "success", "error"]);
let authReturnFocus = null;
const syncAuthScreenState = (screen = "login") => {
if (!authModal || !authScreens.has(screen)) return;
authModal.dataset.authScreen = screen;
authModal.querySelectorAll(".auth-tab").forEach((tab) => {
const active = tab.dataset.authScreenTarget === screen;
tab.setAttribute("aria-selected", active ? "true" : "false");
tab.tabIndex = active ? 0 : -1;
});
authModal.querySelectorAll(".auth-view").forEach((view) => {
const active = view.classList.contains(`auth-view-${screen}`);
view.setAttribute("aria-hidden", active ? "false" : "true");
});
};
const setAuthScreen = (screen = "login") => {
if (!authModal || !authScreens.has(screen)) return;
syncAuthScreenState(screen);
};
const setAuthOpen = (open, screen = "login", trigger = null) => {
if (!authModal) return;
if (open) {
authReturnFocus = trigger || document.activeElement;
setProfileOpen(false);
setCatalogOpen(false);
setMobileSearchOpen(false);
setAuthScreen(screen);
}
authModal.classList.toggle("is-open", open);
authModal.setAttribute("aria-hidden", open ? "false" : "true");
authModal.toggleAttribute("inert", !open);
document.body.classList.toggle("auth-modal-open", open);
if (open) {
window.setTimeout(() => authModal.querySelector("[data-auth-close]")?.focus(), 80);
} else if (authReturnFocus && typeof authReturnFocus.focus === "function") {
authReturnFocus.focus();
authReturnFocus = null;
}
};
document.querySelectorAll("[data-auth-open]").forEach((trigger) => {
trigger.addEventListener("click", (event) => {
event.preventDefault();
setAuthOpen(true, trigger.dataset.authOpen || "login", trigger);
});
});
authModal?.querySelectorAll("[data-auth-screen-target]").forEach((control) => {
control.addEventListener("click", () => setAuthScreen(control.dataset.authScreenTarget || "login"));
});
authModal?.querySelectorAll("[data-auth-close]").forEach((control) => {
control.addEventListener("click", () => setAuthOpen(false));
});
authModal?.addEventListener("pointerdown", (event) => {
if (event.target !== authModal || authDialog?.contains(event.target)) return;
setAuthOpen(false);
});
authModal?.querySelectorAll(".auth-form").forEach((form) => {
form.addEventListener("submit", (event) => {
event.preventDefault();
if (form.dataset.authForm === "recover") {
const email = form.querySelector('input[type="email"]')?.value.trim().toLowerCase() || "";
setAuthScreen(email.includes("error") || email.includes("fail") ? "error" : "success");
}
});
});
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape" || !authModal?.classList.contains("is-open")) return;
setAuthOpen(false);
});
const initialAuthScreen = (() => {
const param = new URLSearchParams(window.location.search).get("auth");
const hash = window.location.hash.startsWith("#auth-") ? window.location.hash.replace("#auth-", "") : "";
return authScreens.has(param) ? param : authScreens.has(hash) ? hash : "";
})();
setAuthScreen("login");
if (initialAuthScreen) setAuthOpen(true, initialAuthScreen);
if (window.matchMedia("(pointer: coarse)").matches) {
let lastTouchEnd = 0;
const stopZoom = (event) => {
event.preventDefault();
};
const stopMultiTouch = (event) => {
if (event.touches && event.touches.length > 1) event.preventDefault();
};
document.addEventListener("gesturestart", stopZoom, { passive: false });
document.addEventListener("gesturechange", stopZoom, { passive: false });
document.addEventListener("gestureend", stopZoom, { passive: false });
document.addEventListener("touchstart", stopMultiTouch, { passive: false });
document.addEventListener("touchmove", stopMultiTouch, { passive: false });
document.addEventListener(
"touchend",
(event) => {
const now = Date.now();
if (now - lastTouchEnd < 320) event.preventDefault();
lastTouchEnd = now;
},
{ passive: false }
);
}
const amountInput = document.querySelector('input[name="amount"]');
const amountField = document.querySelector(".amount-field");
const topupRow = document.querySelector(".topup-row");
const servicesCard = document.querySelector(".services-card") || topupRow;
const topupQuickPopover = document.getElementById("topupQuickPopover");
const payButton = document.querySelector(".pay-btn");
const payCurrencyIcon = payButton?.querySelector(".pay-currency-icon");
const promoToggleButton = document.querySelector(".promo-small");
const promoToggleLabel = promoToggleButton?.querySelector(".promo-small-label");
const topupDetails = document.getElementById("topupDetails");
const topupPromoSection = document.getElementById("topupPromoSection");
const topupPaymentSection = document.getElementById("topupPaymentSection");
const topupPromoInput = document.getElementById("topupPromoInput");
const topupPromoApply = document.querySelector(".topup-promo-apply");
const topupPromoBenefit = document.querySelector(".topup-promo-benefit");
const topupPromoSaving = document.querySelector("[data-promo-saving]");
const topupQuickAmounts = document.querySelectorAll("[data-quick-amount]");
const topupPaymentMethods = document.querySelectorAll(".topup-payment-method");
const topupCredit = document.querySelector("[data-topup-credit]");
const topupFeeBadge = document.querySelector("[data-topup-fee-badge]");
const topupFinalPay = document.querySelector(".topup-final-pay");
const topupRequiresAuth = {{ user ? 'false' : 'true' }};
const maxTopupAmount = 100000;
let promoSectionOpen = false;
let paymentSectionOpen = false;
let promoApplied = false;
let selectedPaymentFee = 0;
let quickPopoverOpen = false;
const formatRubles = (value) => new Intl.NumberFormat("ru-RU").format(Math.max(0, Math.round(value)));
const openTopupAuth = (trigger = topupFinalPay) => {
const returnPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (typeof setAuthOpen === "function") {
window.PlayPapaAuth?.setReturnPath?.(returnPath);
setAuthOpen(true, "login", trigger);
return;
}
if (window.PlayPapaAuth?.openModal?.("login", trigger, returnPath)) return;
const url = new URL("/", window.location.origin);
url.searchParams.set("auth", "login");
url.searchParams.set("redirect", returnPath);
url.searchParams.set("reason", "checkout");
window.location.assign(`${url.pathname}${url.search}`);
};
const syncQuickPopover = () => {
topupQuickPopover?.classList.toggle("is-open", quickPopoverOpen);
topupQuickPopover?.setAttribute("aria-hidden", quickPopoverOpen ? "false" : "true");
topupQuickPopover?.toggleAttribute("inert", !quickPopoverOpen);
amountInput?.setAttribute("aria-expanded", quickPopoverOpen ? "true" : "false");
servicesCard?.classList.toggle("has-quick-popover", quickPopoverOpen);
};
const syncTopupExpansion = () => {
const detailsOpen = promoSectionOpen || paymentSectionOpen;
topupDetails?.classList.toggle("is-open", detailsOpen);
topupDetails?.setAttribute("aria-hidden", detailsOpen ? "false" : "true");
topupDetails?.toggleAttribute("inert", !detailsOpen);
topupPromoSection?.classList.toggle("is-visible", promoSectionOpen);
topupPromoSection?.setAttribute("aria-hidden", promoSectionOpen ? "false" : "true");
topupPromoSection?.toggleAttribute("inert", !promoSectionOpen);
promoToggleButton?.setAttribute("aria-expanded", promoSectionOpen ? "true" : "false");
if (promoToggleLabel) promoToggleLabel.textContent = promoSectionOpen ? "Скрыть промокод" : "Ввести промокод";
topupPaymentSection?.classList.toggle("is-visible", paymentSectionOpen);
topupPaymentSection?.setAttribute("aria-hidden", paymentSectionOpen ? "false" : "true");
topupPaymentSection?.toggleAttribute("inert", !paymentSectionOpen);
payButton?.setAttribute("aria-expanded", paymentSectionOpen ? "true" : "false");
servicesCard?.classList.toggle("is-payment-open", paymentSectionOpen);
};
const syncAmount = () => {
if (!amountInput) return;
let value = amountInput.value.replace(/\D/g, "");
if (value) value = String(Math.min(Number(value), maxTopupAmount));
amountInput.value = value;
amountInput.style.setProperty("--amount-digits", String(Math.max(value.length, 3)));
const numericAmount = Number(value || 0);
if (payButton && payCurrencyIcon) {
payButton.textContent = `Оплатить ${value || 0}`;
payButton.append(payCurrencyIcon);
}
topupQuickAmounts.forEach((button) => {
button.classList.toggle("is-active", Number(button.dataset.quickAmount) === numericAmount);
});
if (topupCredit) topupCredit.textContent = `${formatRubles(numericAmount)} ₽`;
const amountToPay = numericAmount * (1 + selectedPaymentFee / 100);
if (topupFinalPay) topupFinalPay.textContent = `Оплатить ${formatRubles(amountToPay)}₽`;
if (topupFeeBadge) {
topupFeeBadge.textContent = selectedPaymentFee ? `+${selectedPaymentFee}% комиссия` : "Без комиссии";
topupFeeBadge.classList.toggle("has-fee", selectedPaymentFee > 0);
}
if (topupPromoSaving) topupPromoSaving.textContent = `${formatRubles(numericAmount * 0.05)} ₽`;
};
amountInput?.addEventListener("input", syncAmount);
amountInput?.addEventListener("focus", () => {
quickPopoverOpen = true;
syncQuickPopover();
});
amountInput?.addEventListener("click", () => {
quickPopoverOpen = true;
syncQuickPopover();
});
amountInput?.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
quickPopoverOpen = false;
syncQuickPopover();
amountInput.blur();
});
amountInput?.addEventListener("blur", () => {
if (!amountInput.value) amountInput.value = "500";
syncAmount();
});
promoToggleButton?.addEventListener("click", () => {
promoSectionOpen = !promoSectionOpen;
syncTopupExpansion();
});
payButton?.addEventListener("click", () => {
quickPopoverOpen = false;
syncQuickPopover();
paymentSectionOpen = true;
syncTopupExpansion();
});
topupQuickAmounts.forEach((button) => {
button.addEventListener("click", () => {
if (!amountInput) return;
amountInput.value = button.dataset.quickAmount || "500";
syncAmount();
if (button.closest(".topup-quick-popover") && (!paymentSectionOpen || window.matchMedia("(min-width: 768px)").matches)) {
quickPopoverOpen = false;
syncQuickPopover();
}
});
});
document.addEventListener("pointerdown", (event) => {
if (!quickPopoverOpen || amountField?.contains(event.target)) return;
if (paymentSectionOpen && window.matchMedia("(max-width: 767px)").matches) return;
quickPopoverOpen = false;
syncQuickPopover();
});
topupPaymentMethods.forEach((method) => {
method.addEventListener("click", () => {
selectedPaymentFee = Number(method.dataset.fee || 0);
topupPaymentMethods.forEach((item) => {
const selected = item === method;
item.classList.toggle("is-selected", selected);
item.setAttribute("aria-checked", selected ? "true" : "false");
});
syncAmount();
});
});
topupPromoApply?.addEventListener("click", () => {
if (!topupPromoInput?.value.trim()) {
topupPromoInput?.focus();
return;
}
promoApplied = true;
topupPromoBenefit?.classList.add("is-visible");
topupPromoBenefit?.setAttribute("aria-hidden", "false");
topupPromoApply.textContent = "Применено";
syncAmount();
});
topupPromoInput?.addEventListener("input", () => {
if (!promoApplied) return;
promoApplied = false;
topupPromoBenefit?.classList.remove("is-visible");
topupPromoBenefit?.setAttribute("aria-hidden", "true");
if (topupPromoApply) topupPromoApply.textContent = "Применить";
});
topupFinalPay?.addEventListener("click", () => {
if (topupRequiresAuth) {
openTopupAuth(topupFinalPay);
}
});
syncTopupExpansion();
syncQuickPopover();
syncAmount();
document.querySelectorAll(".filter-group-toggle").forEach((button) => {
const group = button.closest(".filter-group");
if (!group) return;
button.addEventListener("click", () => {
group.classList.toggle("is-collapsed");
button.setAttribute("aria-expanded", group.classList.contains("is-collapsed") ? "false" : "true");
});
});
const mobileFilterButton = document.querySelector(".catalog-mobile-filter-btn");
const mobileFilterClose = document.querySelector(".catalog-mobile-filter-close");
const filterPanel = document.querySelector(".catalog-filter-panel");
const mobileFilterbar = document.querySelector(".catalog-mobile-filterbar");
const mobileSortButton = document.querySelector(".catalog-mobile-sort");
const mobileSortLabel = document.querySelector(".catalog-mobile-sort-label");
const mobileSortPanel = document.querySelector(".catalog-mobile-sort-panel");
const mobileFilterQuery = window.matchMedia("(max-width: 767px)");
const setMobileSortOpen = (open) => {
if (!mobileSortButton || !mobileSortPanel || !mobileFilterbar) return;
mobileFilterbar.classList.toggle("is-sort-open", open);
mobileSortPanel.classList.toggle("is-open", open);
mobileSortPanel.setAttribute("aria-hidden", open ? "false" : "true");
mobileSortPanel.toggleAttribute("inert", !open);
mobileSortButton.setAttribute("aria-expanded", open ? "true" : "false");
};
const setMobileFilterOpen = (open) => {
if (!filterPanel || !mobileFilterButton) return;
document.body.classList.toggle("catalog-mobile-filter-open", open);
mobileFilterButton.setAttribute("aria-expanded", open ? "true" : "false");
if (open) setMobileSortOpen(false);
};
mobileFilterButton?.addEventListener("click", (event) => {
event.stopPropagation();
setMobileFilterOpen(!document.body.classList.contains("catalog-mobile-filter-open"));
});
mobileFilterClose?.addEventListener("click", () => setMobileFilterOpen(false));
filterPanel?.addEventListener("click", (event) => {
if (mobileFilterQuery.matches) event.stopPropagation();
});
mobileSortButton?.addEventListener("click", (event) => {
event.stopPropagation();
if (!mobileFilterQuery.matches) return;
setMobileFilterOpen(false);
setMobileSortOpen(!mobileSortPanel?.classList.contains("is-open"));
});
mobileSortPanel?.addEventListener("click", (event) => {
event.stopPropagation();
});
mobileSortPanel?.querySelectorAll("[data-sort-label]").forEach((button) => {
button.addEventListener("click", () => {
mobileSortPanel.querySelectorAll("[data-sort-label]").forEach((item) => item.classList.toggle("is-selected", item === button));
if (mobileSortLabel) mobileSortLabel.textContent = button.dataset.sortLabel || button.textContent.trim();
setMobileSortOpen(false);
});
});
document.addEventListener("click", () => {
if (document.body.classList.contains("catalog-mobile-filter-open")) setMobileFilterOpen(false);
if (mobileSortPanel?.classList.contains("is-open")) setMobileSortOpen(false);
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
setMobileFilterOpen(false);
setMobileSortOpen(false);
}
});
filterPanel?.querySelectorAll(".filter-apply, .filter-reset").forEach((button) => {
button.addEventListener("click", () => {
if (mobileFilterQuery.matches) setMobileFilterOpen(false);
});
});
const syncFilterCheckState = () => {
document.querySelectorAll(".filter-check").forEach((label) => {
const input = label.querySelector('input[type="checkbox"]');
label.classList.toggle("is-checked", Boolean(input?.checked));
});
};
document.querySelectorAll('.filter-check input[type="checkbox"]').forEach((input) => {
input.addEventListener("change", syncFilterCheckState);
});
syncFilterCheckState();
mobileFilterQuery.addEventListener?.("change", (event) => {
if (!event.matches) {
setMobileFilterOpen(false);
setMobileSortOpen(false);
}
});
let catalogPriceTouched = false;
let getCatalogPriceFilter = () => ({ enabled: false, min: null, max: null });
let resetCatalogPriceFilter = () => {};
let updateCatalogFilterBadges = () => {};
const priceFilter = document.querySelector("[data-price-filter]");
if (priceFilter) {
const minInput = priceFilter.querySelector("[data-price-min-input]");
const maxInput = priceFilter.querySelector("[data-price-max-input]");
const range = priceFilter.querySelector(".filter-range");
const minLimit = Number(priceFilter.dataset.minPrice || 0);
const maxLimit = Number(priceFilter.dataset.maxPrice || 1000000);
const step = Number(priceFilter.dataset.priceStep || 1);
let minPrice = Number(minInput?.value.replace(/\D/g, "") || minLimit);
let maxPrice = Number(maxInput?.value.replace(/\D/g, "") || maxLimit);
let activeHandle = null;
const clampPrice = (value) => Math.min(maxLimit, Math.max(minLimit, Math.round(value / step) * step));
const priceToPercent = (value) => ((value - minLimit) / (maxLimit - minLimit)) * 100;
const clientXToPrice = (clientX) => {
if (!range) return minLimit;
const rect = range.getBoundingClientRect();
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
return clampPrice(minLimit + ratio * (maxLimit - minLimit));
};
const syncPriceFilter = (source, writeInputs = true) => {
minPrice = clampPrice(minPrice);
maxPrice = clampPrice(maxPrice);
if (minPrice > maxPrice) {
if (source === "min") minPrice = maxPrice;
else maxPrice = minPrice;
}
if (writeInputs && minInput) minInput.value = String(minPrice);
if (writeInputs && maxInput) maxInput.value = String(maxPrice);
if (range) {
range.style.setProperty("--price-start", `${priceToPercent(minPrice)}%`);
range.style.setProperty("--price-end", `${priceToPercent(maxPrice)}%`);
}
};
getCatalogPriceFilter = () => ({
enabled: catalogPriceTouched || minPrice !== minLimit || maxPrice !== maxLimit,
min: minPrice,
max: maxPrice,
});
resetCatalogPriceFilter = () => {
catalogPriceTouched = false;
minPrice = minLimit;
maxPrice = maxLimit;
syncPriceFilter();
};
const setPriceFromPointer = (event, handle) => {
catalogPriceTouched = true;
const nextPrice = clientXToPrice(event.clientX);
if (handle === "min") minPrice = Math.min(nextPrice, maxPrice);
else maxPrice = Math.max(nextPrice, minPrice);
syncPriceFilter(handle);
updateCatalogFilterBadges();
};
minInput?.addEventListener("input", () => {
catalogPriceTouched = true;
const value = minInput.value.replace(/\D/g, "");
minInput.value = value;
if (!value) return;
minPrice = Number(value);
syncPriceFilter("min", false);
updateCatalogFilterBadges();
});
maxInput?.addEventListener("input", () => {
catalogPriceTouched = true;
const value = maxInput.value.replace(/\D/g, "");
maxInput.value = value;
if (!value) return;
maxPrice = Number(value);
syncPriceFilter("max", false);
updateCatalogFilterBadges();
});
minInput?.addEventListener("blur", () => syncPriceFilter("min"));
maxInput?.addEventListener("blur", () => syncPriceFilter("max"));
range?.addEventListener("pointerdown", (event) => {
event.preventDefault();
const nextPrice = clientXToPrice(event.clientX);
activeHandle = Math.abs(nextPrice - minPrice) <= Math.abs(nextPrice - maxPrice) ? "min" : "max";
range.setPointerCapture?.(event.pointerId);
setPriceFromPointer(event, activeHandle);
});
range?.addEventListener("pointermove", (event) => {
if (!activeHandle) return;
setPriceFromPointer(event, activeHandle);
});
const releasePriceHandle = (event) => {
if (!activeHandle) return;
range?.releasePointerCapture?.(event.pointerId);
activeHandle = null;
};
range?.addEventListener("pointerup", releasePriceHandle);
range?.addEventListener("pointercancel", releasePriceHandle);
syncPriceFilter();
}
const services = [
["service-steam.png", "Steam"],
["service-telegram.png", "Telegram"],
["service-roblox.png", "Roblox"],
["service-brawl.png", "Brawl Stars"],
["service-pubg.png", "PUBG Mob..."],
["service-appstore.png", "App Store"],
["service-chatgpt.png", "ChatGPT"],
["service-playstation.png", "PlayStation"],
["service-tiktok.png", "TikTok"],
["service-mobile.png", "Mobile Leg..."],
];
const servicesRow = document.getElementById("servicesRow");
if (servicesRow) {
services.forEach(([icon, label, active]) => {
servicesRow.insertAdjacentHTML(
"beforeend",
`<button class="service-item ${active ? "is-active" : ""}"><img src="./assets/${icon}" alt=""><span>${label}</span></button>`
);
});
servicesRow.insertAdjacentHTML(
"beforeend",
`<button class="service-item more-service"><span class="more-icon"><img class="more-icon-img" src="./assets/more-service.svg" alt=""></span><span>еще 841</span></button>`
);
}
const discountPattern = ["red", "red", "red", "red", "red", "red", "red", "red"];
const escapeHtml = (value) =>
String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
const catalogProducts = Array.isArray(window.playpapaCatalog?.products) ? window.playpapaCatalog.products : [];
const catalogState = window.playpapaCatalogState || {};
const catalogApiUrl = "{{ path('catalog_items') }}";
const activeCatalogCategory = catalogState.category || "all";
const catalogSearchInput = document.querySelector("[data-catalog-search]");
const fallbackProducts = [
{
name: "DOOM 2016 STEAM KEY РФ+СНГ",
image_url: "./assets/product-pubg.png",
price_label: "49,99 ₽",
url: "/product",
catalog_kind: "keys",
catalog_kinds: ["keys"],
},
];
const sourceProducts = catalogProducts.length ? catalogProducts : fallbackProducts;
const officialProducts = Array.isArray(window.playpapaCatalog?.official_products) && window.playpapaCatalog.official_products.length
? window.playpapaCatalog.official_products
: sourceProducts.slice(0, 10);
let loadedCatalogProducts = sourceProducts.slice();
let catalogTotalCount = Number(window.playpapaCatalog?.total ?? loadedCatalogProducts.length);
let catalogNextOffset = Number(window.playpapaCatalog?.next_offset ?? loadedCatalogProducts.length);
let catalogHasMore = Boolean(window.playpapaCatalog?.has_more ?? catalogTotalCount > loadedCatalogProducts.length);
let catalogLoading = false;
let catalogRequestId = 0;
let catalogSearchQuery = (catalogSearchInput?.value || catalogState.query || "").trim().toLowerCase();
let catalogSortValue = catalogState.sort || "popular";
const catalogPageSize = 24;
const filterInputs = Array.from(document.querySelectorAll("[data-filter-type]"));
const filterApplyBadges = Array.from(document.querySelectorAll(".filter-apply span"));
const mobileFilterBadge = document.querySelector(".catalog-mobile-filter-btn b");
const mobileTags = document.querySelector(".catalog-mobile-tags");
const catalogSortButton = document.querySelector(".catalog-sort");
const sortOptions = [
["popular", "Популярные"],
["price_asc", "Сначала дешёвые"],
["price_desc", "Сначала дорогие"],
["new", "Сначала новинки"],
["rating", "По рейтингу"],
];
const sortLabels = Object.fromEntries(sortOptions);
const productWord = (count) => {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return "товар";
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return "товара";
return "товаров";
};
const productMarkup = (product, color, index) => `
<article class="product-card">
<div class="product-cover">
<img src="${escapeHtml(product.image_url || "./assets/product-pubg.png")}" alt="">
${product.discount_label ? `<span class="discount ${color}">${escapeHtml(product.discount_label)}</span>` : ""}
</div>
<div class="product-body">
<h3>${escapeHtml(product.name || "Товар PlayPapa")}</h3>
<div class="price-line"><strong>${escapeHtml(product.price_label || "Цена уточняется")}</strong></div>
<a href="${escapeHtml(product.url || "/product")}">Купить</a>
</div>
</article>
`;
const emptyCatalogMarkup = () => `
<div class="catalog-empty">
<strong>Ничего не найдено</strong>
<span>Попробуйте другую категорию или измените запрос.</span>
</div>
`;
const renderCatalogGrid = (grid, products, startIndex, emptyAllowed = false, countOverride = null) => {
const count = countOverride ?? Number(grid.dataset.count || 5);
const slice = products.slice(startIndex, startIndex + count);
if (slice.length === 0) {
grid.innerHTML = emptyAllowed ? emptyCatalogMarkup() : "";
return;
}
const items = slice.map((product, i) => productMarkup(product, discountPattern[(startIndex + i) % discountPattern.length], startIndex + i)).join("");
grid.innerHTML = items;
};
const catalogTotal = document.querySelector(".catalog-total");
const officialGrid = document.querySelector(".catalog-feature-grid");
const officialScrollButtons = document.querySelectorAll("[data-official-scroll]");
const catalogLoader = document.querySelector(".catalog-loader");
const catalogTopupRow = document.querySelector(".catalog-topup-row");
const renderCatalogProducts = () => {
const products = loadedCatalogProducts;
document.querySelectorAll("[data-products]").forEach((grid) => {
const slot = grid.dataset.products || "";
if (slot === "catalog-featured") {
renderCatalogGrid(grid, officialProducts, 0, false, 10);
return;
}
if (slot === "catalog-results-grid-primary") {
renderCatalogGrid(grid, products, 0, true, 12);
return;
}
if (slot === "catalog-results-grid-secondary") {
renderCatalogGrid(grid, products, 12, false, Math.max(0, products.length - 12));
return;
}
renderCatalogGrid(grid, products, 0);
});
if (catalogTotal) {
catalogTotal.textContent = `Всего ${catalogTotalCount} ${productWord(catalogTotalCount)}`;
}
if (catalogLoader) {
catalogLoader.hidden = !catalogLoading && !catalogHasMore;
}
if (catalogTopupRow) {
catalogTopupRow.hidden = activeCatalogCategory !== "all";
}
};
const selectedFilterValues = (type) =>
filterInputs
.filter((input) => input.dataset.filterType === type && input.checked)
.map((input) => input.value)
.filter(Boolean);
const buildCatalogParams = (offset = 0) => {
const params = new URLSearchParams();
params.set("offset", String(offset));
params.set("limit", String(catalogPageSize));
params.set("sort", catalogSortValue);
if (activeCatalogCategory !== "all") params.set("category", activeCatalogCategory);
if (catalogSearchQuery) params.set("q", catalogSearchQuery);
const types = selectedFilterValues("type");
const genres = selectedFilterValues("genre");
if (types.length) params.set("type", types.join(","));
if (genres.length) params.set("genre", genres.join(","));
const price = getCatalogPriceFilter();
if (price.enabled) {
params.set("min_price", String(price.min));
params.set("max_price", String(price.max));
}
return params;
};
const setCatalogLoading = (loading) => {
catalogLoading = loading;
renderCatalogProducts();
};
const updateCatalogSortUi = () => {
const label = sortLabels[catalogSortValue] || sortLabels.popular;
if (mobileSortLabel) mobileSortLabel.textContent = label;
if (catalogSortButton) catalogSortButton.textContent = label;
mobileSortPanel?.querySelectorAll("[data-sort-value]").forEach((button) => {
button.classList.toggle("is-selected", button.dataset.sortValue === catalogSortValue);
});
};
const fetchCatalogPage = async ({ offset = 0, append = false } = {}) => {
const requestId = ++catalogRequestId;
setCatalogLoading(true);
try {
const response = await fetch(`${catalogApiUrl}?${buildCatalogParams(offset).toString()}`, {
headers: { Accept: "application/json" },
});
if (!response.ok) throw new Error(`Catalog API returned ${response.status}`);
const payload = await response.json();
if (requestId !== catalogRequestId) return;
const nextProducts = Array.isArray(payload.products) ? payload.products : [];
loadedCatalogProducts = append ? loadedCatalogProducts.concat(nextProducts) : nextProducts;
catalogTotalCount = Number(payload.total ?? loadedCatalogProducts.length);
catalogNextOffset = Number(payload.next_offset ?? loadedCatalogProducts.length);
catalogHasMore = Boolean(payload.has_more);
} catch (error) {
console.warn(error);
if (!append) {
loadedCatalogProducts = [];
catalogTotalCount = 0;
catalogNextOffset = 0;
catalogHasMore = false;
}
} finally {
if (requestId === catalogRequestId) {
setCatalogLoading(false);
}
}
};
const applyCatalogFilters = () => {
catalogNextOffset = 0;
catalogHasMore = true;
fetchCatalogPage({ offset: 0, append: false });
};
const loadMoreCatalogProducts = () => {
if (catalogLoading || !catalogHasMore) return;
fetchCatalogPage({ offset: catalogNextOffset, append: true });
};
const selectedFilterLabels = () =>
filterInputs
.filter((input) => input.checked)
.map((input) => input.closest(".filter-check")?.querySelector("span")?.textContent?.trim())
.filter(Boolean);
const renderMobileTags = () => {
if (!mobileTags) return;
const tags = [];
if (activeCatalogCategory !== "all") {
const activeChip = Array.from(document.querySelectorAll("[data-catalog-category]")).find((chip) => chip.dataset.catalogCategory === activeCatalogCategory);
tags.push(activeChip?.textContent?.trim() || "Категория");
}
if (catalogSearchQuery) tags.push(`Поиск: ${catalogSearchQuery}`);
tags.push(...selectedFilterLabels());
const price = getCatalogPriceFilter();
if (price.enabled) tags.push(`${price.min}-${price.max} ₽`);
if (!tags.length) {
mobileTags.innerHTML = '<button type="button" disabled>Все товары</button>';
return;
}
mobileTags.innerHTML = `${tags.map((tag) => `<button type="button" disabled>${escapeHtml(tag)}</button>`).join("")}<button type="button" data-catalog-reset>Сбросить</button>`;
};
updateCatalogFilterBadges = () => {
const checkedCount = filterInputs.filter((input) => input.checked).length;
const price = getCatalogPriceFilter();
const count = checkedCount + (price.enabled ? 1 : 0) + (activeCatalogCategory !== "all" ? 1 : 0);
filterApplyBadges.forEach((badge) => {
badge.textContent = String(count);
badge.hidden = count === 0;
});
if (mobileFilterBadge) {
mobileFilterBadge.textContent = String(count);
mobileFilterBadge.hidden = count === 0;
}
renderMobileTags();
};
const resetCatalogFilters = () => {
if (activeCatalogCategory !== "all") {
window.location.href = "/catalog";
return;
}
filterInputs.forEach((input) => {
input.checked = false;
});
catalogSearchQuery = "";
if (catalogSearchInput) catalogSearchInput.value = "";
resetCatalogPriceFilter();
catalogSortValue = "popular";
syncFilterCheckState();
updateCatalogSortUi();
updateCatalogFilterBadges();
window.history.replaceState(null, "", "/catalog");
applyCatalogFilters();
};
const setCatalogSort = (value, reload = true) => {
catalogSortValue = sortLabels[value] ? value : "popular";
updateCatalogSortUi();
if (reload) applyCatalogFilters();
};
renderCatalogProducts();
updateCatalogSortUi();
updateCatalogFilterBadges();
const goToCatalogSearch = () => {
catalogSearchQuery = (catalogSearchInput?.value || "").trim().toLowerCase();
applyCatalogFilters();
};
let catalogSearchTimer = 0;
catalogSearchInput?.addEventListener("input", () => {
catalogSearchQuery = catalogSearchInput.value.trim().toLowerCase();
window.clearTimeout(catalogSearchTimer);
catalogSearchTimer = window.setTimeout(() => {
updateCatalogFilterBadges();
applyCatalogFilters();
}, 280);
});
catalogSearchInput?.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
window.clearTimeout(catalogSearchTimer);
goToCatalogSearch();
});
filterInputs.forEach((input) => {
input.addEventListener("change", updateCatalogFilterBadges);
});
document.querySelectorAll(".filter-apply").forEach((button) => {
button.addEventListener("click", () => {
updateCatalogFilterBadges();
applyCatalogFilters();
});
});
document.querySelectorAll(".filter-reset").forEach((button) => {
button.addEventListener("click", () => {
resetCatalogFilters();
});
});
mobileTags?.addEventListener("click", (event) => {
if (event.target.closest("[data-catalog-reset]")) resetCatalogFilters();
});
mobileSortPanel?.querySelectorAll("[data-sort-value]").forEach((button) => {
button.addEventListener("click", () => setCatalogSort(button.dataset.sortValue || "popular"));
});
catalogSortButton?.addEventListener("click", () => {
const currentIndex = Math.max(0, sortOptions.findIndex(([value]) => value === catalogSortValue));
const next = sortOptions[(currentIndex + 1) % sortOptions.length][0];
setCatalogSort(next);
});
document.querySelectorAll(".catalog-sort-icon").forEach((button, index) => {
button.addEventListener("click", () => {
if (index === 0) {
setCatalogSort(catalogSortValue === "price_asc" ? "price_desc" : "price_asc");
return;
}
setCatalogSort("popular");
});
});
if ("IntersectionObserver" in window && catalogLoader) {
const catalogObserver = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) loadMoreCatalogProducts();
}, { rootMargin: "420px 0px" });
catalogObserver.observe(catalogLoader);
} else {
window.addEventListener("scroll", () => {
if (window.innerHeight + window.scrollY < document.documentElement.scrollHeight - 520) return;
loadMoreCatalogProducts();
}, { passive: true });
}
const officialScrollStep = () => {
if (!officialGrid) return;
const firstCard = officialGrid.querySelector(".product-card");
const cardWidth = firstCard ? firstCard.getBoundingClientRect().width : 230;
const gap = Number.parseFloat(getComputedStyle(officialGrid).columnGap || "16");
return cardWidth + gap;
};
const scrollOfficialGrid = (direction) => {
if (!officialGrid) return;
const step = officialScrollStep() || 246;
const maxLeft = Math.max(0, officialGrid.scrollWidth - officialGrid.clientWidth);
const nextLeft = direction === "prev" ? officialGrid.scrollLeft - step : officialGrid.scrollLeft + step;
const target = direction === "prev"
? (nextLeft <= 2 ? maxLeft : nextLeft)
: (nextLeft >= maxLeft - 2 ? 0 : nextLeft);
officialGrid.scrollTo({ left: target, behavior: "smooth" });
};
officialScrollButtons.forEach((button) => {
button.addEventListener("click", () => scrollOfficialGrid(button.dataset.officialScroll === "prev" ? "prev" : "next"));
});
if (officialGrid) {
let officialDragPointer = null;
let officialDragStartX = 0;
let officialDragStartLeft = 0;
let officialDragMoved = false;
let officialSuppressClick = false;
const isOfficialInteractiveTarget = (target) => Boolean(target.closest("a, button, input, select, textarea, label"));
const finishOfficialDrag = (event) => {
if (officialDragPointer === null || event.pointerId !== officialDragPointer) return;
officialGrid.releasePointerCapture?.(event.pointerId);
officialGrid.classList.remove("is-dragging");
officialSuppressClick = officialDragMoved;
officialDragPointer = null;
window.setTimeout(() => {
officialDragMoved = false;
officialSuppressClick = false;
}, 0);
};
officialGrid.addEventListener("pointerdown", (event) => {
if (event.button !== undefined && event.button !== 0) return;
if (isOfficialInteractiveTarget(event.target)) return;
officialDragPointer = event.pointerId;
officialDragStartX = event.clientX;
officialDragStartLeft = officialGrid.scrollLeft;
officialDragMoved = false;
officialGrid.setPointerCapture?.(event.pointerId);
officialGrid.classList.add("is-dragging");
});
officialGrid.addEventListener("pointermove", (event) => {
if (officialDragPointer === null || event.pointerId !== officialDragPointer) return;
const delta = event.clientX - officialDragStartX;
if (Math.abs(delta) > 4) officialDragMoved = true;
officialGrid.scrollLeft = officialDragStartLeft - delta;
});
officialGrid.addEventListener("pointerup", finishOfficialDrag);
officialGrid.addEventListener("pointercancel", finishOfficialDrag);
officialGrid.addEventListener("click", (event) => {
if (!officialSuppressClick) return;
event.preventDefault();
event.stopPropagation();
}, true);
}
</script>
{% endblock %}