Тема магазина по макету Figma

Каталог, карточка товара, корзина, оформление заказа и блог на WooCommerce.

Стили разложены по слоям: токены из Figma, база, компоненты, страницы,
брейкпоинты. Цвета, типографика, радиусы и отступы взяты из Variables и
текстовых стилей макета, ниже по слоям хардкода нет.

Иконки — Phosphor, вставляются инлайном как SVG, символами ничего не рисуется.
Стили WooCommerce отключены, вёрстка своя, !important не используется.
This commit is contained in:
f1den
2026-07-30 22:38:49 +03:00
commit 29d10ce4cf
72 changed files with 7465 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
.DS_Store
Thumbs.db
.idea/
.vscode/
*.log
+16
View File
@@ -0,0 +1,16 @@
<?php
/**
* Not found.
*
* @package VyatkaShop
*/
get_header();
?>
<div class="container empty-state">
<h1 class="page-heading">404</h1>
<p><?php esc_html_e( 'Страница не найдена.', 'vyatka-shop' ); ?></p>
<a class="button" href="<?php echo esc_url( home_url( '/' ) ); ?>"><?php esc_html_e( 'На главную', 'vyatka-shop' ); ?></a>
</div>
<?php
get_footer();
+84
View File
@@ -0,0 +1,84 @@
# Vyatka Shop
Тема WordPress для интернет-магазина, сверстанная по макету Figma. Каталог,
карточка товара, корзина, оформление заказа и блог — на WooCommerce, весь
контент правится из админки.
Стек: WordPress, WooCommerce, PHP 8.2+, vanilla CSS и JS. Сборщика нет,
зависимостей нет — тема кладётся в `wp-content/themes/` и работает.
## Установка
1. Скопировать папку темы в `wp-content/themes/vyatka-shop`.
2. Установить и активировать WooCommerce.
3. Активировать тему в «Внешний вид → Темы».
Дальше WooCommerce сам предложит создать страницы магазина. Меню задаётся в
«Внешний вид → Меню», заголовок и подзаголовок первого экрана — в
«Внешний вид → Настроить → Главная страница».
## Доступ в админку
```
Адрес: /wp-admin/
Логин: demo
Пароль: VyatkaDemo2026
```
## Как устроено
Стили разбиты на слои и подключаются строго по порядку — каждый следующий
опирается на предыдущие и не лезет обратно:
```
tokens переменные из Figma: цвета, типографика, радиусы, отступы
base шрифт, ресет, типографическая шкала
components кнопки, поля, чекбоксы, радио, селекты, счётчик количества
layout шапка, общие блоки страницы, подвал
home главная: hero, карточки товаров, категорий, новостей
catalog категория: фильтры, тулбар, пагинация
product карточка товара: галерея, характеристики, табы
blog блог и статья
responsive брейкпоинты — идёт после всех слоёв, которые правит
misc разделители, состояния наведения, мобильный лист фильтров
```
`style.css` содержит только заголовок темы для WordPress, стилей в нём нет.
Значения не подбирались на глаз: цвета, размеры шрифтов, межстрочные,
трекинг, радиусы и отступы взяты из Variables и текстовых стилей Figma и
вынесены в `assets/css/tokens.css`. Ниже по слоям хардкода цветов и размеров
нет — всё через переменные.
PHP так же разложен по зонам ответственности, `functions.php` только
подключает модули:
```
inc/icons.php инлайн-SVG иконки
inc/setup.php theme supports, меню, кастомайзер
inc/urls.php ссылки под текущий хост
inc/template-tags.php общие хелперы вывода
inc/assets.php подключение стилей и скриптов
inc/woocommerce.php каталог, оформление заказа, AJAX-корзина
```
Иконки — набор Phosphor, лежат как обычные `.svg` в `assets/icons/` и
вставляются в разметку инлайном через `vyatka_shop_icon()`. Символами или
шрифтом иконок в теме не рисуется ничего, поэтому они наследуют `currentColor`
и масштабируются из CSS.
Стили WooCommerce отключены — вся вёрстка своя, чужие правила перебивать не
приходится, и `!important` в теме нет.
## Заметки
- Тема не привязана к домену. Если сайт открывается ещё под каким-то именем
помимо того, что записано в настройках WordPress (например, за прокси), это
имя объявляется в `wp-config.php`:
```php
define( 'VYATKA_SHOP_HOSTS', 'shop.example.com' );
```
- Реализована светлая тема — та, в которой нарисованы экраны. Тёмная палитра в
макете есть, но экранов под неё нет, поэтому она не версталась.
+186
View File
@@ -0,0 +1,186 @@
/*
* Base layer: font faces, reset, document defaults and the type scale.
* Only element selectors and utility classes live here.
*/
@font-face {
font-family: Inter;
src: url("../fonts/Inter.ttf") format("truetype");
font-style: normal;
font-weight: 100 900;
font-display: swap;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
[hidden] {
display: none;
}
html {
scroll-behavior: smooth;
scrollbar-width: none;
}
html::-webkit-scrollbar {
width: 0;
height: 0;
}
body {
display: flex;
min-height: 100vh;
min-height: 100dvh;
margin: 0;
flex-direction: column;
color: var(--text-main);
background: var(--bg-main);
font: var(--body-m);
-webkit-font-smoothing: antialiased;
}
body.menu-open {
overflow: hidden;
}
a {
color: inherit;
text-decoration: none;
}
img,
svg {
display: block;
max-width: 100%;
}
img {
height: auto;
}
button,
input,
select,
textarea {
color: inherit;
font: inherit;
}
button {
cursor: pointer;
}
/* ------------------------------------------------------------ type scale --- */
h1,
.h1 {
margin: 0;
font: var(--h1);
letter-spacing: var(--ls-tight);
}
h2,
.h2 {
margin: 0;
font: var(--h2);
letter-spacing: var(--ls-tight);
}
h3,
.h3 {
margin: 0;
font: var(--h3);
letter-spacing: var(--ls-tight);
}
h4,
.h4 {
margin: 0;
font: var(--h4);
letter-spacing: var(--ls-tight);
}
h5,
.h5 {
margin: 0;
font: var(--h5);
letter-spacing: var(--ls-tight-s);
}
.lead {
font: var(--lead);
}
.control-m {
font: var(--control-m);
}
.control-s {
font: var(--control-s);
}
.caption {
font: var(--caption);
}
.upper-l,
.upper-m,
.upper-s {
letter-spacing: var(--ls-upper);
text-transform: uppercase;
}
.upper-l {
font: var(--upper-l);
}
.upper-m {
font: var(--upper-m);
}
.upper-s {
font: var(--upper-s);
}
.accent-m {
font: var(--accent-m);
}
/* --------------------------------------------------------------- layout --- */
.container {
width: min(calc(100% - var(--container-gutter) * 2), var(--container));
margin-inline: auto;
}
.site-main {
display: block;
min-height: 0;
flex: 1 0 auto;
}
.screen-reader-text {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: 1ms !important;
animation-duration: 1ms !important;
scroll-behavior: auto !important;
}
}
+104
View File
@@ -0,0 +1,104 @@
/*
* Blog listing and article
*/
/* Blog and article */
.blog-page {
padding-bottom: 0;
}
.blog-page .page-heading {
margin-bottom: 24px;
}
.blog-page-spacer {
height: 64px;
}
.article-layout {
padding-bottom: 48px;
}
.article-layout .page-heading {
margin-bottom: 24px;
font: var(--h2);
letter-spacing: var(--ls-tight);
}
.article-prose {
width: 752px;
max-width: 100%;
}
.article-lead {
margin: 0 0 48px;
font: var(--lead);
}
.article-media,
.article-media-wide,
.prose .article-media {
display: block;
width: 1088px;
max-width: 100%;
height: 620px;
min-height: 0;
margin: 0;
border-radius: 0;
background: var(--border-secondary);
object-fit: cover;
}
.article-caption {
width: 1088px;
max-width: 100%;
margin: 8px 0 24px;
color: var(--text-caption);
font: var(--caption);
}
.article-layout .prose h2 {
margin: 32px 0 8px;
font: var(--h3);
letter-spacing: var(--ls-tight);
}
.article-layout .prose h3 {
margin: 24px 0 8px;
font: var(--h4);
letter-spacing: var(--ls-tight);
}
.article-layout .prose p {
margin: 0 0 16px;
}
.article-layout .prose ol,
.article-layout .prose ul {
margin: 0 0 24px;
padding-left: 36px;
}
.article-layout .prose li {
margin-bottom: 16px;
padding-left: 4px;
}
.article-layout .prose ul {
padding-left: 36px;
list-style: none;
}
.article-layout .prose ul li {
position: relative;
}
.article-layout .prose ul li::before {
position: absolute;
top: 0.7em;
left: -28px;
width: 16px;
height: 2px;
background: currentColor;
content: "";
}
+742
View File
@@ -0,0 +1,742 @@
/*
* Cart and checkout
*/
/* Cart / checkout */
.cart-page {
min-height: 460px;
padding-top: 16px;
padding-bottom: 48px;
}
.cart-page .page-heading {
margin-bottom: 28px;
}
.checkout-page {
/* The design height is a floor, not a cap: validation messages under the
fields make the page taller, and a fixed height would let the content run
under the footer instead. */
min-height: 1068px;
padding-top: 16px;
padding-bottom: 48px;
}
.checkout-page .page-heading {
margin-bottom: 44px;
}
.cart-layout,
.checkout-layout {
display: grid;
align-items: start;
}
.cart-layout {
grid-template-columns: minmax(0, 1fr) 432px;
gap: 40px;
}
.checkout-layout {
grid-template-columns: minmax(0, 1fr) 436px;
gap: 24px;
}
.cart-items {
display: grid;
gap: 16px;
}
.cart-item {
display: grid;
min-height: 152px;
padding: 16px;
grid-template-columns: 160px minmax(0, 1fr) 104px 144px 48px;
align-items: center;
gap: 16px;
border-radius: 16px;
box-shadow: inset 0 0 0 1px var(--border-main);
}
.cart-item-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.cart-item-title {
color: var(--text-main);
font: var(--control-m);
}
.cart-item-sku {
color: var(--text-caption);
font: var(--control-s);
}
.cart-item-price {
text-align: right;
font: var(--h5);
letter-spacing: var(--ls-tight-s);
}
.cart-item-price del {
display: block;
color: var(--text-caption);
font: var(--control-s);
}
.cart-item-price ins {
text-decoration: none;
}
.cart-item-quantity {
height: 48px;
}
/* Holds an inline SVG, so it has to centre its own child — the icon used to
be an absolutely positioned span. */
.cart-item-remove {
display: grid;
width: 48px;
height: 48px;
place-items: center;
border-radius: var(--br-btn-m);
color: var(--text-main);
background: var(--bg-secondary);
}
.cart-item-remove:hover,
.cart-item-remove:focus-visible {
background: var(--bg-button-light-hover);
}
.cart-item-remove .icon {
--icon-size: 20px;
}
.cart-table {
width: 100%;
border-collapse: collapse;
}
.cart-table th,
.cart-table td {
padding: 16px 12px;
border-bottom: 1px solid var(--border-main);
text-align: left;
}
.cart-product {
display: flex;
align-items: center;
gap: 14px;
}
.cart-thumb {
position: relative;
width: 160px;
height: 120px;
flex: 0 0 auto;
overflow: hidden;
border-radius: 8px;
background: var(--bg-select-unit-selected);
}
.cart-thumb .product-badge {
position: absolute;
z-index: 1;
top: 8px;
left: 8px;
}
.cart-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.order-card {
align-self: start;
padding: 24px;
border-radius: 16px;
background: var(--bg-secondary);
}
.cart-order-card {
display: flex;
width: 432px;
max-width: 100%;
min-height: 272px;
flex-direction: column;
background: var(--bg-main);
box-shadow: 0 16px 48px rgb(0 0 0 / 10%);
}
.cart-order-card .order-row {
display: grid;
grid-template-columns: auto minmax(16px, 1fr) auto;
align-items: center;
column-gap: 8px;
}
.cart-order-card .order-row::after {
grid-column: 2;
grid-row: 1;
border-top: 1px dashed var(--border-secondary);
content: "";
}
.cart-order-card .order-row > :first-child {
grid-column: 1;
}
.cart-order-card .order-row > :last-child {
grid-column: 3;
text-align: right;
}
.woocommerce .cart-order-card .button {
height: 56px;
margin-top: auto;
min-height: 56px;
}
.cart-page.is-updating {
opacity: 0.7;
transition: opacity 100ms ease-in-out;
}
.order-card h3 {
margin: 0 0 20px;
font: var(--h5);
letter-spacing: var(--ls-tight-s);
}
.order-row {
display: flex;
min-height: 32px;
padding: 4px 0;
justify-content: space-between;
gap: 20px;
font: var(--control-m);
}
/* In the design only the amount jumps to Header #4; the "Итого" label stays
on Control M like every other summary row. */
.order-row.total {
min-height: 40px;
margin-top: 4px;
padding: 8px 0 0;
align-items: baseline;
}
.order-row.total strong {
font: var(--h4);
letter-spacing: var(--ls-tight);
}
.order-card .button,
.order-card #place_order {
width: 100%;
margin-top: 16px;
}
.checkout-fields {
display: flex;
width: 432px;
max-width: 100%;
flex-direction: column;
}
.checkout-input {
display: block;
width: 432px;
max-width: 100%;
/* Height comes from the input so the field can grow by its error message
instead of clipping it. */
margin-bottom: 8px;
}
/*
* Checkout fields follow the Figma text-field: h-input-L tall, br-control-L
* radius, the border-input state ladder, and a label that stays with the field
* instead of vanishing as soon as you type. Empty, it sits centred at
* Control M; filled or focused, it shrinks to Caption and moves to the top
* with the value underneath.
*/
.checkout-input input {
width: 100%;
height: var(--h-input-l);
padding: 33px 20px 15px;
border: 1px solid var(--border-input);
border-radius: var(--br-control-l);
outline: 0;
color: var(--text-main);
background: var(--bg-main);
font: var(--control-m);
transition: border-color var(--motion-fast);
}
.checkout-input input::placeholder {
color: var(--text-caption);
}
.checkout-input input:hover {
border-color: var(--border-input-hover);
}
.checkout-input input:focus {
border-color: var(--border-input-focus);
}
.checkout-input input:disabled {
border-color: transparent;
color: var(--text-disable);
background: var(--bg-control-disable);
}
.checkout-input input[aria-invalid="true"],
.woocommerce-invalid .checkout-input input {
border-color: var(--border-input-error);
}
.checkout-delivery,
.checkout-payment-options {
min-width: 0;
margin: 20px 0 0;
padding: 0;
border: 0;
}
.checkout-delivery legend,
.checkout-payment-options legend {
margin-bottom: 16px;
padding: 0;
font: var(--h5);
letter-spacing: var(--ls-tight-s);
}
.delivery-switch {
position: relative;
display: flex;
width: 256px;
max-width: 100%;
height: 56px;
margin-bottom: 8px;
padding: 4px;
border-radius: 10px;
background: var(--bg-secondary);
}
.delivery-switch::before {
position: absolute;
top: 4px;
left: 4px;
width: calc((100% - 8px) / 2);
height: 48px;
border-radius: 8px;
background: var(--bg-main);
box-shadow: 0 2px 8px rgb(0 0 0 / 6%);
content: "";
transition: transform 180ms ease-in-out;
}
.delivery-switch.is-delivery::before {
transform: translateX(100%);
}
.delivery-switch label {
position: relative;
z-index: 1;
flex: 1 1 0;
cursor: pointer;
}
.delivery-switch input {
position: absolute;
opacity: 0;
}
.delivery-switch span {
display: grid;
height: 48px;
place-items: center;
border-radius: 8px;
transition: color 180ms ease-in-out;
font: var(--control-m);
}
.delivery-switch input:checked + span {
background: transparent;
box-shadow: none;
}
.checkout-radio {
display: flex;
min-height: 32px;
align-items: center;
gap: 8px;
font: var(--control-m);
}
.checkout-map {
display: block;
width: 432px;
max-width: 100%;
height: 300px;
margin-top: 16px;
border-radius: 8px;
object-fit: cover;
}
.delivery-address-fields {
margin-top: 16px;
}
.checkout-payment-options {
margin-top: 24px;
}
.checkout-order-products {
display: grid;
gap: 8px;
margin-bottom: 16px;
}
.checkout-order-product {
display: grid;
min-height: 72px;
grid-template-columns: 56px 1fr;
align-items: center;
gap: 16px;
border-bottom: 1px solid var(--border-secondary);
}
.checkout-order-thumb {
width: 56px;
height: 56px;
overflow: hidden;
border-radius: 6px;
background: var(--bg-select-unit-selected);
}
.checkout-order-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.checkout-order-product strong,
.checkout-order-product > div > span {
display: block;
font: var(--control-s);
}
.checkout-order-product > div > span {
color: var(--text-caption);
}
.checkout-order-totals .order-row {
border-bottom: 1px dashed var(--border-secondary);
}
.checkout-order-totals .order-row.total {
border-bottom: 0;
}
.checkout-consent {
display: flex;
margin-top: 16px;
align-items: center;
gap: 8px;
color: var(--text-caption);
font: var(--control-s);
}
.woocommerce form .form-row {
margin: 0 0 14px;
}
.woocommerce form .form-row label {
display: block;
margin-bottom: 6px;
font: var(--control-s);
}
.woocommerce-checkout-review-order-table {
width: 100%;
border-collapse: collapse;
}
.woocommerce-checkout-review-order-table th,
.woocommerce-checkout-review-order-table td {
padding: 9px 0;
border-bottom: 1px solid var(--border-secondary);
text-align: left;
}
.woocommerce-checkout-payment {
margin-top: 18px;
}
.woocommerce-checkout-payment ul {
margin: 0;
padding: 0;
list-style: none;
}
/*
* Notices. WooCommerce's own stylesheets are dequeued (see inc/assets.php),
* so these rules win on their own and no longer need `!important`. The status
* mark is the matching Phosphor vector carried as a mask — never a glyph.
*/
.woocommerce-message,
.woocommerce-info,
.woocommerce-error,
.woocommerce-warning {
--notice-accent: var(--notice-info);
--notice-mark: var(--icon-info-fill);
position: relative;
display: flex;
width: min(100%, 447px);
min-height: 64px;
margin: 16px 0;
padding: 16px 56px 16px 64px;
align-items: center;
border: 0;
border-radius: var(--br-modal);
color: var(--text-main-contrast);
background: var(--notice-accent);
font: var(--control-m);
list-style: none;
transition:
opacity 180ms ease-in-out,
transform 180ms ease-in-out;
}
.woocommerce-message {
--notice-accent: var(--notice-success);
--notice-mark: var(--icon-check-circle-fill);
}
.woocommerce-error {
--notice-accent: var(--notice-error);
--notice-mark: var(--icon-x-circle-fill);
}
.woocommerce-warning {
--notice-accent: var(--notice-warning);
--notice-mark: var(--icon-warning-fill);
}
.woocommerce-message::before,
.woocommerce-info::before,
.woocommerce-error::before,
.woocommerce-warning::before {
position: absolute;
top: 50%;
left: 16px;
width: 32px;
height: 32px;
background: currentColor;
content: "";
transform: translateY(-50%);
-webkit-mask: var(--notice-mark) center / contain no-repeat;
mask: var(--notice-mark) center / contain no-repeat;
}
.woocommerce-error li {
margin: 0;
}
.woocommerce-notice-dismiss {
position: absolute;
top: 50%;
right: 16px;
width: 24px;
height: 24px;
padding: 0;
border: 0;
color: var(--text-main-contrast);
background: transparent;
transform: translateY(-50%);
}
.woocommerce-notice-dismiss::before {
position: absolute;
inset: 0;
background: currentColor;
content: "";
-webkit-mask: var(--icon-x) center / contain no-repeat;
mask: var(--icon-x) center / contain no-repeat;
}
.woocommerce-notice-dismiss:focus-visible {
outline: 2px solid var(--text-main-contrast);
outline-offset: 2px;
}
.woocommerce-notice-closing {
opacity: 0;
transform: translateY(-8px);
}
.wc-block-components-notice-banner > svg {
display: none;
}
.shop-toast-stack {
position: fixed;
z-index: 1000;
top: 104px;
right: 24px;
display: grid;
width: min(calc(100vw - 48px), 447px);
gap: 12px;
pointer-events: none;
}
.home .shop-toast-stack {
top: 136px;
}
.shop-toast {
--toast-fade-duration: 300ms;
position: relative;
display: grid;
width: 100%;
min-height: 64px;
padding: 16px 56px 16px 64px;
grid-template-columns: 1fr;
align-items: center;
border-radius: 16px;
color: var(--text-main-contrast);
background: var(--notice-info);
box-shadow: 0 12px 32px rgb(0 0 0 / 14%);
opacity: 0;
pointer-events: auto;
transition: opacity var(--toast-fade-duration) ease-in-out;
font: var(--control-m);
}
.shop-toast-success {
background: var(--notice-success);
}
.shop-toast-error {
background: var(--notice-error);
}
.shop-toast.is-visible {
opacity: 1;
}
.shop-toast.is-leaving {
opacity: 0;
}
.shop-toast-icon {
--icon-size: 32px;
position: absolute;
top: 50%;
left: 16px;
display: grid;
width: 32px;
height: 32px;
place-items: center;
color: currentColor;
transform: translateY(-50%);
}
.shop-toast-dismiss {
position: absolute;
top: 50%;
right: 16px;
display: grid;
width: 24px;
height: 24px;
padding: 0;
place-items: center;
border: 0;
color: var(--text-main-contrast);
background: transparent;
transform: translateY(-50%);
}
.shop-toast-dismiss .icon {
--icon-size: 20px;
}
.shop-toast-dismiss:focus-visible {
outline: 2px solid var(--text-main-contrast);
outline-offset: 2px;
}
.empty-state {
padding: 60px 0;
text-align: center;
}
.cart-page-empty .cart-empty-message {
margin: 0;
font: var(--control-m);
}
.checkout-input .field-label {
position: absolute;
/* Half of the field, not of the wrapper — the wrapper grows when an error
message appears underneath and would drag the label down with it. */
top: calc(var(--h-input-l) / 2);
left: 21px;
/* The .field component sets width: 100%; here the label only needs to be as
wide as its own text, or it reaches past the field. */
width: auto;
max-width: calc(100% - 42px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-caption);
font-family: var(--font-sans);
font-size: 16px;
line-height: 24px;
pointer-events: none;
transform: translateY(-50%);
transition:
top var(--motion-fast),
font-size var(--motion-fast),
line-height var(--motion-fast),
transform var(--motion-fast);
}
.checkout-input input:focus + .field-label,
.checkout-input input:not(:placeholder-shown) + .field-label {
top: 16px;
font-size: 12px;
line-height: 16px;
transform: none;
}
/* The real placeholder is a single space so :placeholder-shown works; it must
never be visible. */
.checkout-input input::placeholder {
color: transparent;
}
.checkout-input.is-error .field-label {
color: var(--text-negative);
}
/*
* The delivery switch sizes to its labels rather than trusting a fixed width,
* so a longer word cannot crowd the segments.
*/
.delivery-switch span {
padding: 0 20px;
white-space: nowrap;
}
+576
View File
@@ -0,0 +1,576 @@
/*
* Catalog and category archive: filters, toolbar, pagination
*/
/* Catalog / archive */
.archive-shell {
display: grid;
padding: 0 0 72px;
grid-template-columns: 288px minmax(0, 1fr);
align-items: start;
gap: 48px;
}
.filters {
align-self: start;
}
.filter-group {
padding: 15px 0;
}
.filter-group:first-child {
padding-top: 0;
}
.filter-title {
display: inline-flex;
margin-bottom: 10px;
align-items: center;
gap: 8px;
font: var(--h5);
letter-spacing: var(--ls-tight-s);
}
button.filter-title {
width: auto;
padding: 0;
border: 0;
background: transparent;
text-align: left;
}
.filter-caret {
flex: 0 0 auto;
transition: transform 180ms ease-in-out;
}
.filter-collapsed .filter-title {
margin: 0;
}
.filter-collapsed .filter-caret {
transform: rotate(180deg);
}
.filter-content {
overflow: hidden;
opacity: 1;
transition:
height 180ms ease-in-out,
opacity 120ms ease-in-out;
}
.filter-content.is-animating {
will-change: height, opacity;
}
.price-fields {
display: grid;
grid-template-columns: 1fr 14px 1fr;
align-items: center;
gap: 8px;
}
.price-fields > span {
color: var(--text-caption);
text-align: center;
}
.filter-input,
.filter-select,
.field input,
.field textarea,
.field select,
.woocommerce form .form-row input.input-text,
.woocommerce form .form-row textarea,
.woocommerce form .form-row select {
width: 100%;
min-height: 48px;
padding: 10px 12px;
border: 1px solid var(--border-main);
border-radius: 7px;
outline: 0;
background: var(--bg-main);
}
.filter-input {
min-height: 56px;
padding: 14px;
font: var(--control-m);
appearance: textfield;
}
.price-range {
position: relative;
height: 20px;
margin-top: 8px;
--range-min: 0%;
--range-max: 66.3%;
}
.price-range::before {
position: absolute;
top: 50%;
right: 0;
left: 0;
height: 2px;
background: var(--border-secondary);
content: "";
transform: translateY(-50%);
}
.price-range::after {
position: absolute;
top: 50%;
right: calc(100% - var(--range-max));
left: var(--range-min);
height: 2px;
background: var(--text-main);
content: "";
transform: translateY(-50%);
}
.price-range input[type="range"] {
position: absolute;
z-index: 2;
inset: 0;
width: 100%;
height: 20px;
margin: 0;
padding: 0;
border: 0;
outline: 0;
background: transparent;
pointer-events: none;
appearance: none;
}
.price-range input[type="range"]::-webkit-slider-runnable-track {
height: 2px;
background: transparent;
}
.price-range input[type="range"]::-webkit-slider-thumb {
width: 20px;
height: 20px;
margin-top: -9px;
border: 2px solid var(--text-main);
border-radius: 50%;
background: var(--bg-main);
cursor: grab;
pointer-events: auto;
appearance: none;
}
.price-range input[type="range"]::-moz-range-track {
height: 2px;
background: transparent;
}
.price-range input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border: 2px solid var(--text-main);
border-radius: 50%;
background: var(--bg-main);
cursor: grab;
pointer-events: auto;
}
.price-range input[type="range"]:active::-webkit-slider-thumb {
cursor: grabbing;
}
.price-range input[type="range"]:focus-visible::-webkit-slider-thumb {
outline: 2px solid var(--text-link);
outline-offset: 2px;
}
.filter-option {
display: flex;
min-height: 32px;
align-items: center;
gap: 8px;
font: var(--control-m);
}
.filter-more {
display: inline-block;
margin-top: 5px;
padding: 0;
border: 0;
border-bottom: 1px dashed var(--border-link);
color: var(--text-link);
background: transparent;
transition:
color 100ms ease-in-out,
border-color 100ms ease-in-out;
font: var(--control-m);
}
.filter-more:hover,
.filter-more:focus-visible {
border-bottom-color: var(--text-link);
}
.filter-empty {
margin: 48px 0;
color: var(--text-caption);
text-align: center;
}
.archive-results {
min-width: 0;
}
.archive-toolbar {
display: flex;
min-height: 32px;
margin-bottom: 16px;
align-items: center;
justify-content: space-between;
gap: 20px;
color: var(--text-caption);
font: var(--control-m);
}
.sort-control,
.per-page-control {
display: flex;
align-items: center;
gap: 8px;
}
.archive-toolbar .woocommerce-ordering {
margin: 0;
}
.archive-toolbar select {
min-height: 32px;
padding: 4px 28px 4px 4px;
border: 0;
outline: 0;
color: var(--text-main);
background: transparent var(--icon-caret-down) right 4px center / 14px no-repeat;
font: var(--control-s);
appearance: none;
}
.custom-select {
position: relative;
display: inline-flex;
min-width: 0;
}
.custom-select-native {
position: absolute;
width: 1px;
height: 1px;
min-height: 0;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.custom-select-trigger {
display: inline-flex;
min-height: 24px;
padding: 0 32px 0 0;
align-items: center;
border: 0;
color: var(--text-main);
background: transparent;
text-align: left;
font: var(--control-m);
}
.custom-select-trigger::after {
position: absolute;
top: 50%;
right: 0;
width: 24px;
height: 24px;
background: currentColor;
content: "";
transform: translateY(-50%);
transition: transform 100ms ease-in-out;
-webkit-mask: var(--icon-caret-down) center / contain no-repeat;
mask: var(--icon-caret-down) center / contain no-repeat;
}
.custom-select.is-open .custom-select-trigger::after {
transform: translateY(-50%) rotate(180deg);
}
.custom-select-trigger:focus-visible {
outline: 2px solid var(--text-link);
outline-offset: 2px;
}
.custom-select-menu {
position: absolute;
z-index: 80;
top: calc(100% + 8px);
left: 0;
width: max-content;
min-width: 240px;
max-height: 336px;
overflow: auto;
border-radius: 8px;
background: var(--bg-main);
box-shadow: 0 10px 30px rgb(0 0 0 / 14%);
scrollbar-width: thin;
}
.per-page-control .custom-select-menu {
right: 0;
left: auto;
min-width: 160px;
}
.custom-select-option {
display: flex;
width: 100%;
min-height: 56px;
padding: 16px;
align-items: center;
border: 0;
border-bottom: 1px solid var(--border-main);
color: var(--text-main);
background: var(--bg-main);
text-align: left;
transition: background-color 100ms ease-in-out;
font: var(--control-m);
}
.custom-select-option:last-child {
border-bottom: 0;
}
.custom-select-option:hover,
.custom-select-option:focus-visible,
.custom-select-option.is-selected {
background: var(--bg-secondary);
}
.archive-results .product-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
row-gap: 24px;
}
.archive-results .product-card {
height: 405px;
}
.pagination,
.woocommerce nav.woocommerce-pagination ul {
display: flex;
margin: 24px 0 0;
padding: 0;
gap: 6px;
border: 0;
list-style: none;
}
.pagination .nav-links {
display: flex;
gap: 6px;
}
.pagination .page-numbers,
.woocommerce nav.woocommerce-pagination ul li a,
.woocommerce nav.woocommerce-pagination ul li span {
display: grid;
width: 56px;
height: 56px;
padding: 0;
place-items: center;
border: 1px solid var(--border-control);
border-radius: var(--br-btn-m);
background: var(--bg-main);
font: var(--control-m);
transition:
background-color 100ms ease-in-out,
border-color 100ms ease-in-out;
}
.woocommerce nav.woocommerce-pagination ul li {
border: 0;
}
.pagination .current,
.woocommerce nav.woocommerce-pagination ul li span.current {
color: var(--text-main);
background: var(--bg-secondary);
}
.pagination .icon-arrow-fill {
width: 24px;
height: 24px;
}
.pagination a.page-numbers:hover,
.pagination a.page-numbers:focus-visible,
.woocommerce nav.woocommerce-pagination ul li a:hover,
.woocommerce nav.woocommerce-pagination ul li a:focus-visible {
background: var(--bg-button-light-hover);
}
/* ------------------------------------------------- subcategory cards --- */
/*
* A scrollable row above the filters. Measured off `sub-categorys` in the
* Category block: 216x80 cards on bg-secondary, 16px gap, a 64px media square,
* and a circular next button overlapping the right edge.
*/
.subcategories {
position: relative;
margin-bottom: 24px;
}
.subcategory-list {
display: flex;
align-items: stretch;
margin: 0;
padding: 0;
gap: 16px;
overflow-x: auto;
scroll-behavior: smooth;
scrollbar-width: none;
list-style: none;
}
.subcategory-list::-webkit-scrollbar {
height: 0;
}
.subcategory-card {
display: flex;
width: 216px;
/* Fixed, not min: the design keeps the row even when a name wraps. */
height: 80px;
padding: 8px;
align-items: center;
gap: 12px;
border-radius: var(--br-section-m);
background: var(--bg-secondary);
transition: background-color var(--motion-fast);
}
.subcategory-card:hover {
background: var(--bg-button-light-hover);
}
.subcategory-card:active {
background: var(--bg-button-light-pressed);
}
.subcategory-card-media {
display: block;
width: 64px;
height: 64px;
flex: 0 0 64px;
overflow: hidden;
border-radius: var(--br-control-m);
background: var(--bg-select-unit-selected);
}
.subcategory-card-media img {
width: 100%;
height: 100%;
object-fit: cover;
}
.subcategory-card-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.subcategory-card-title {
display: -webkit-box;
overflow: hidden;
color: var(--text-main);
font: var(--control-m);
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.subcategory-card-count {
color: var(--text-caption);
font: var(--caption);
}
.subcategory-next {
position: absolute;
z-index: 2;
top: 50%;
right: 0;
display: grid;
width: 40px;
height: 40px;
padding: 0;
place-items: center;
border: 0;
border-radius: 50%;
color: var(--text-main);
background: var(--bg-main);
box-shadow: var(--shadow-m);
transform: translateY(-50%);
transition: background-color var(--motion-fast);
}
.subcategory-next:hover {
background: var(--bg-button-light-hover);
}
.subcategory-next[hidden] {
display: none;
}
/* --------------------------------------------------- quick filters --- */
/*
* The `quick-filters-tabs` row: pills that wrap, 36px tall, on the container
* edge rather than filled, and the active one inverts.
*/
.quick-filters {
display: flex;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 8px;
}
.quick-filter {
padding: 6px 16px;
border: 1px solid var(--border-control);
border-radius: 999px;
color: var(--text-main);
background: var(--bg-main);
font: var(--control-m);
transition:
color var(--motion-fast),
border-color var(--motion-fast),
background-color var(--motion-fast);
}
.quick-filter:hover {
border-color: var(--border-control-hover);
background: var(--bg-button-light-hover);
}
.quick-filter.is-active {
border-color: transparent;
color: var(--text-main-contrast);
background: var(--bg-button);
}
.quick-filter:focus-visible {
outline: 2px solid var(--border-input-focus);
outline-offset: 2px;
}
+576
View File
@@ -0,0 +1,576 @@
/*
* Components.
*
* Every control here is built against the `Компоненты` matrices in Figma
* (DESKTOP + MOBILE, light theme) and uses tokens only. States follow the
* design's own vocabulary: default / hover / pressed / focus / error /
* disabled.
*/
/* ---------------------------------------------------------------- icon --- */
.icon {
display: inline-block;
width: var(--icon-size, 24px);
height: var(--icon-size, 24px);
flex: 0 0 auto;
color: inherit;
fill: currentColor;
vertical-align: middle;
}
.icon-20 {
--icon-size: 20px;
}
.icon-16 {
--icon-size: 16px;
}
/* Icons that read as "back" are the forward vector mirrored, never a
second asset. */
.icon-flip {
transform: scaleX(-1);
}
/* -------------------------------------------------------------- button --- */
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: var(--pad-h-btn-m) var(--pad-w-text-m);
border: 1px solid transparent;
border-radius: var(--br-btn-m);
color: var(--text-main-contrast);
background: var(--bg-button);
font: var(--control-m);
text-align: center;
cursor: pointer;
transition:
color var(--motion-fast),
background-color var(--motion-fast),
border-color var(--motion-fast);
}
.button:hover {
background: var(--bg-button-hover);
}
.button:active {
background: var(--bg-button-pressed);
}
.button:disabled,
.button[aria-disabled="true"] {
color: var(--text-disable);
background: var(--bg-control-disable);
cursor: default;
}
/* Large button — the hero / checkout size. */
.button-l {
padding: var(--pad-h-btn-l) var(--pad-w-text-l);
border-radius: var(--br-btn-l);
}
/* An icon sitting next to the label tightens the padding on its side. */
.button:has(> .icon:first-child) {
padding-left: var(--pad-w-icon-m);
}
.button:has(> .icon:last-child) {
padding-right: var(--pad-w-icon-m);
}
.button-l:has(> .icon:first-child) {
padding-left: var(--pad-w-icon-l);
}
.button-l:has(> .icon:last-child) {
padding-right: var(--pad-w-icon-l);
}
/* Light (secondary) button. */
.button-light {
color: var(--text-main);
background: var(--bg-button-light);
}
.button-light:hover {
background: var(--bg-button-light-hover);
}
.button-light:active {
background: var(--bg-button-light-pressed);
}
/* Outline button — light fill with a visible edge. */
.button-outline {
color: var(--text-main);
border-color: var(--border-control);
background: var(--bg-main);
}
.button-outline:hover {
border-color: var(--border-control-hover);
background: var(--bg-button-light-hover);
}
.button-outline:active {
border-color: var(--border-control-pressed);
background: var(--bg-button-light-pressed);
}
/* Square icon-only control (cart, carousel arrows, favourite). */
.icon-button {
display: inline-grid;
width: 56px;
height: 56px;
padding: 0;
place-items: center;
border: 1px solid var(--border-control);
border-radius: var(--br-btn-m);
color: var(--text-main);
background: var(--bg-main);
cursor: pointer;
transition:
border-color var(--motion-fast),
background-color var(--motion-fast);
}
.icon-button:hover {
border-color: var(--border-control-hover);
background: var(--bg-button-light-hover);
}
.icon-button:active {
border-color: var(--border-control-pressed);
background: var(--bg-button-light-pressed);
}
.icon-button:disabled {
border-color: var(--border-control-disable);
color: var(--text-disable);
background: var(--bg-main);
cursor: default;
}
/* ---------------------------------------------------------- text link --- */
.text-link {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-link);
font: var(--control-m);
transition: color var(--motion-fast);
}
.text-link:hover {
color: var(--text-link-hover);
}
/* -------------------------------------------------- checkbox and radio --- */
/*
* Measured off the `radio-btn-icon` and `checkbox-icon` components: 20×20 box,
* 1px edge, 4px radius (`br-checkbox`), an 8px dot and a ~12px tick.
*
* The edge stays `border-checkbox` in every state, including checked — only
* the mark inside turns on. Turning the edge black as well is what made the
* radio read as a bullseye.
*
* Disabled fills with `bg-control-disable`, drops the edge, and dims the mark.
*/
.control-check {
display: inline-flex;
align-items: center;
gap: 12px;
font: var(--control-m);
cursor: pointer;
}
.control-check input[type="checkbox"],
.control-check input[type="radio"],
input[type="checkbox"],
input[type="radio"] {
position: relative;
width: 20px;
height: 20px;
margin: 0;
flex: 0 0 20px;
border: 1px solid var(--border-checkbox);
/* The mark is drawn in currentColor, so pin it here instead of inheriting
whatever colour the surrounding label happens to use. */
color: var(--text-main);
background: var(--bg-main);
cursor: pointer;
appearance: none;
transition:
border-color var(--motion-fast),
background-color var(--motion-fast);
}
input[type="checkbox"] {
border-radius: var(--br-checkbox);
}
input[type="radio"] {
border-radius: 50%;
}
input[type="checkbox"]:hover,
input[type="radio"]:hover {
border-color: var(--border-checkbox-hover);
}
/* The tick is the Phosphor `check-bold` vector, drawn as a mask so the mark
inherits `currentColor` and stays a vector at every zoom level. */
input[type="checkbox"]:checked::after {
position: absolute;
inset: 0;
background: currentColor;
content: "";
-webkit-mask: var(--icon-check) center / 12px 12px no-repeat;
mask: var(--icon-check) center / 12px 12px no-repeat;
}
input[type="radio"]:checked::after {
position: absolute;
top: 50%;
left: 50%;
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
content: "";
transform: translate(-50%, -50%);
}
input[type="checkbox"]:disabled,
input[type="radio"]:disabled {
border-color: transparent;
color: var(--text-disable);
background: var(--bg-control-disable);
cursor: default;
}
input[type="checkbox"]:focus-visible,
input[type="radio"]:focus-visible {
outline: 2px solid var(--border-input-focus);
outline-offset: 2px;
}
.control-check:has(input:disabled) {
color: var(--text-disable);
cursor: default;
}
/* ---------------------------------------------------------- text field --- */
/*
* Figma text field: 72px tall on desktop / 64px on mobile, 16px radius,
* the label sits inside the field above the value.
*/
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field-control {
position: relative;
display: flex;
min-height: var(--h-input-l);
padding: 12px 20px;
align-items: center;
flex-direction: column;
justify-content: center;
gap: 2px;
border: 1px solid var(--border-input);
border-radius: var(--br-control-l);
background: var(--bg-main);
transition: border-color var(--motion-fast);
}
.field-control:hover {
border-color: var(--border-input-hover);
}
.field-control:focus-within {
border-color: var(--border-input-focus);
box-shadow: inset 0 0 0 1px var(--border-input-focus);
}
.field-label {
width: 100%;
color: var(--text-caption);
font: var(--caption);
}
.field-control input,
.field-control textarea,
.field-input {
width: 100%;
padding: 0;
border: 0;
outline: 0;
color: var(--text-main);
background: transparent;
font: var(--control-m);
}
.field-control input::placeholder,
.field-control textarea::placeholder {
color: var(--text-caption);
}
.field-control textarea {
min-height: calc(var(--h-text-area-l) - 48px);
resize: vertical;
}
.field-error-text,
.field-error {
color: var(--text-negative);
font: var(--caption);
}
/* The slot only takes space once it carries a message. */
.field-error:empty {
display: none;
}
.field.is-error .field-control {
border-color: var(--border-input-error);
box-shadow: inset 0 0 0 1px var(--border-input-error);
}
.field.is-disabled .field-control,
.field-control:has(input:disabled),
.field-control:has(textarea:disabled) {
border-color: transparent;
background: var(--bg-control-disable);
}
.field.is-disabled .field-label,
.field-control:has(input:disabled) .field-label,
.field-control:has(input:disabled) input {
color: var(--text-disable);
}
/* Compact single-line input used inside filters and the cart. */
.input-compact {
min-height: 48px;
padding: 12px 16px;
border: 1px solid var(--border-input);
border-radius: var(--br-control-m);
outline: 0;
color: var(--text-main);
background: var(--bg-main);
font: var(--control-m);
transition: border-color var(--motion-fast);
}
.input-compact:hover {
border-color: var(--border-input-hover);
}
.input-compact:focus {
border-color: var(--border-input-focus);
}
/* --------------------------------------------------------------- select --- */
.custom-select {
position: relative;
}
.custom-select-trigger {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0;
border: 0;
color: var(--text-main);
background: transparent;
font: var(--control-m);
cursor: pointer;
transition: color var(--motion-fast);
}
.custom-select-trigger .icon {
--icon-size: 20px;
transition: transform var(--motion-fast);
}
.custom-select.is-open .custom-select-trigger .icon {
transform: rotate(180deg);
}
.custom-select-menu {
position: absolute;
z-index: 20;
top: calc(100% + 8px);
right: 0;
min-width: 100%;
padding: 8px 0;
border: 1px solid var(--border-main);
border-radius: var(--br-control-m);
background: var(--bg-select-unit);
box-shadow: var(--shadow-m);
}
.custom-select-option {
display: block;
width: 100%;
padding: 10px 16px;
border: 0;
color: var(--text-main);
background: transparent;
font: var(--control-m);
text-align: left;
white-space: nowrap;
cursor: pointer;
transition: background-color var(--motion-fast);
}
.custom-select-option:hover {
background: var(--bg-select-unit-hover);
}
.custom-select-option[aria-selected="true"] {
background: var(--bg-select-unit-selected);
}
/* ----------------------------------------------------- quantity control --- */
.quantity-control {
display: grid;
min-height: 52px;
grid-template-columns: 40px minmax(0, 1fr) 40px;
align-items: center;
border-radius: var(--br-control-m);
background: var(--bg-secondary);
}
.quantity-control button {
display: grid;
height: 100%;
padding: 0;
place-items: center;
border: 0;
border-radius: inherit;
color: var(--text-main);
background: transparent;
cursor: pointer;
transition: background-color var(--motion-fast);
}
.quantity-control button .icon {
--icon-size: 20px;
}
.quantity-control button:hover {
background: var(--bg-button-light-hover);
}
.quantity-control button:active {
background: var(--bg-button-light-pressed);
}
.quantity-control button:disabled {
color: var(--text-disable);
cursor: default;
}
.quantity-control button:disabled:hover,
.quantity-control button:disabled:active {
background: transparent;
}
.quantity-control button:focus-visible {
z-index: 1;
outline: 2px solid var(--border-input-focus);
outline-offset: -2px;
}
.quantity-control input {
width: 100%;
padding: 0;
border: 0;
outline: 0;
background: transparent;
font: var(--control-m);
text-align: center;
appearance: textfield;
}
.quantity-control input::-webkit-outer-spin-button,
.quantity-control input::-webkit-inner-spin-button {
margin: 0;
appearance: none;
}
/* ------------------------------------------------------------- pill/tag --- */
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: var(--br-checkbox);
color: var(--text-main-contrast);
background: var(--bg-main-contrast);
font: var(--upper-s);
letter-spacing: var(--ls-upper);
text-transform: uppercase;
}
/* ----------------------------------------------------------- focus ring --- */
.button:focus-visible,
.icon-button:focus-visible,
.text-link:focus-visible,
.custom-select-trigger:focus-visible,
.custom-select-option:focus-visible {
outline: 2px solid var(--border-input-focus);
outline-offset: 2px;
}
/* ------------------------------------------------------------ carousel --- */
/*
* Applied by script once a carousel is wired up. Without JS the container
* stays the grid it was authored as, showing every item.
*/
.is-carousel {
overflow: hidden;
}
.carousel-track {
--carousel-gap: 24px;
display: flex;
flex-wrap: nowrap;
gap: var(--carousel-gap);
will-change: transform;
transition: transform var(--motion-slow-duration) var(--motion-slow);
}
.carousel-track > * {
flex: 0 0
calc(
(100% - (var(--carousel-per-view) - 1) * var(--carousel-gap)) /
var(--carousel-per-view)
);
min-width: 0;
}
/* While the track is being re-ordered behind the scenes. */
.carousel-track.is-settling {
transition: none;
}
+372
View File
@@ -0,0 +1,372 @@
/*
* Home page: hero, product cards, category cards, news cards
*/
/* Home hero */
.home .site-header {
height: 120px;
}
.hero {
height: 654px;
min-height: 654px;
padding: 48px 0 0;
background: var(--bg-secondary);
}
.hero-copy {
display: flex;
width: 100%;
max-width: 1008px;
flex-direction: column;
gap: 32px;
}
.hero-main {
display: flex;
flex-direction: column;
gap: 24px;
}
.hero-title-group {
display: flex;
flex-direction: column;
gap: 16px;
}
.hero h1 {
max-width: 1008px;
margin: 0;
font: var(--h1);
letter-spacing: var(--ls-tight);
}
.hero-copy > p,
.hero-title-group > p {
max-width: 656px;
min-height: 84px;
margin: 0;
color: var(--text-main);
font: var(--lead);
}
.hero-features {
display: grid;
width: 100%;
max-width: 656px;
margin: 0;
grid-template-columns: repeat(3, 1fr);
gap: 32px;
}
.hero-feature {
display: flex;
min-width: 0;
flex-direction: column;
gap: 8px;
font: var(--control-m);
}
.feature-mark {
--icon-size: 24px;
display: block;
}
.hero-button {
width: 144px;
height: 72px;
min-height: 72px;
padding: 0 28px;
font: var(--control-m);
}
/* Product cards */
.products-section {
height: 555px;
padding-top: 16px;
overflow: hidden;
}
.products-section .section-head {
margin-bottom: 24px;
}
.product-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 24px;
}
.product-card {
container-type: inline-size;
min-width: 0;
padding: 16px;
border: 0;
border-radius: 16px;
background: var(--bg-main);
box-shadow: inset 0 0 0 1px var(--border-main);
transition: box-shadow 400ms var(--motion-slow);
}
.product-card:hover,
.product-card:focus-within {
box-shadow: inset 0 0 0 1px var(--border-secondary);
}
.product-card-media {
position: relative;
display: block;
height: 220px;
overflow: hidden;
border-radius: 8px;
background: var(--bg-select-unit-selected);
}
.product-card-media img {
width: 100%;
height: 100%;
object-fit: cover;
}
.product-badge {
display: inline-block;
padding: 3px 6px;
color: var(--text-main-contrast);
background: var(--text-main);
font: var(--upper-s);
letter-spacing: var(--ls-upper);
text-transform: uppercase;
}
.product-card-media .product-badge {
position: absolute;
z-index: 2;
top: 10px;
left: 10px;
}
.product-card-body {
padding: 12px 0 0;
}
.stock {
color: var(--text-positive);
font: var(--control-s);
}
.product-card-title {
margin: 5px 0 1px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--control-m);
}
.price {
display: flex;
min-height: 27px;
align-items: baseline;
gap: 10px;
font: var(--h5);
letter-spacing: var(--ls-tight-s);
}
.price ins {
order: -1;
text-decoration: none;
}
.price del {
color: var(--text-caption);
font: var(--control-s);
}
/*
* The stepper is 132px at the design width and gives that space back as the
* card narrows; min-content on the button track is its natural width, so the
* label is never squeezed. Below the point where both still fit they stack,
* ending up the same width as each other rather than one starving the other.
*/
.card-buy {
display: grid;
margin-top: 12px;
grid-template-columns: minmax(104px, 132px) minmax(min-content, 1fr);
gap: 8px;
}
@container (max-width: 251px) {
.card-buy {
grid-template-columns: minmax(0, 1fr);
}
}
.add-cart {
min-width: 0;
min-height: 52px;
padding: 10px 12px;
white-space: nowrap;
}
.add-cart .icon {
width: 20px;
height: 20px;
}
/* Category cards */
.categories-section {
padding-top: 60px;
}
.category-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
}
.category-card {
position: relative;
display: grid;
height: 260px;
grid-template-columns: 400px 260px;
overflow: hidden;
border-radius: var(--br-section-m);
background: var(--bg-secondary);
}
.category-card::after {
position: absolute;
z-index: 2;
inset: 0;
border: 1px solid var(--border-secondary);
border-radius: inherit;
content: "";
opacity: 0;
pointer-events: none;
transition: opacity 400ms var(--motion-slow);
}
.category-card-copy {
display: flex;
padding: 24px;
align-items: flex-start;
flex-direction: column;
transition: background-color 400ms var(--motion-slow);
}
.category-card:hover,
.category-card:focus-visible {
outline: 0;
}
.category-card:hover::after,
.category-card:focus-visible::after {
opacity: 1;
}
.category-card:hover .category-card-copy,
.category-card:focus-visible .category-card-copy {
background: var(--bg-main);
}
.category-card h3 {
margin: 0 0 3px;
font: var(--h4);
letter-spacing: var(--ls-tight);
}
.category-card p {
margin: 0;
color: var(--text-caption);
font: var(--control-m);
}
.category-card .text-link {
margin-top: auto;
}
.category-card-media {
background: var(--bg-select-unit-selected);
}
/* News cards */
.news-section {
padding-top: 24px;
padding-bottom: 64px;
}
.news-section .section-head {
margin-bottom: 24px;
}
.news-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 24px;
}
.news-card {
position: relative;
display: flex;
height: 408px;
padding: 24px;
justify-content: flex-end;
flex-direction: column;
overflow: hidden;
border-radius: 10px;
color: var(--text-main-contrast);
background: linear-gradient(to bottom, var(--bg-select-unit-selected) 0 43%, var(--bg-select-unit-selected) 48%, #000 100%);
}
.news-card > * {
position: relative;
z-index: 1;
transition: transform 400ms var(--motion-slow);
}
.news-card:hover > *,
.news-card:focus-visible > * {
transform: translateY(-8px);
}
.news-card h3 {
margin: 0 0 4px;
font: var(--h5);
letter-spacing: var(--ls-tight-s);
}
.news-card p {
max-width: 340px;
margin: 0;
color: var(--text-main-contrast);
font: var(--control-s);
}
.all-news-link {
display: flex;
height: 56px;
margin-top: 24px;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: var(--br-btn-m);
color: var(--text-main);
background: var(--bg-secondary);
font: var(--control-m);
transition: background-color 100ms ease-in-out;
}
.all-news-link:hover,
.all-news-link:focus-visible {
background: var(--bg-button-light-hover);
}
.all-news-link:active {
background: var(--bg-button-light-pressed);
}
/* The two home grids double as carousels; the track replaces the grid flow. */
.product-grid.is-carousel,
.news-grid.is-carousel {
display: block;
}
+240
View File
@@ -0,0 +1,240 @@
/*
* Header, shared page furniture and the footer
*/
/* Header */
.site-header {
position: relative;
z-index: 30;
background: var(--bg-main);
}
.header-inner {
display: flex;
height: 88px;
align-items: center;
gap: 37px;
border-bottom: 1px solid var(--border-main);
}
.brand {
display: grid;
width: 105px;
height: 42px;
flex: 0 0 105px;
align-items: center;
color: var(--text-main);
}
.brand-mark,
.brand img {
width: 105px;
height: auto;
}
.main-nav {
margin-right: auto;
}
.main-nav ul {
display: flex;
margin: 0;
padding: 0;
align-items: center;
gap: 24px;
list-style: none;
}
.main-nav a {
display: block;
padding: 8px 0;
font: var(--control-m);
}
/* Square icon controls all share the .icon-button component. */
.cart-link,
.slider-controls button,
.gallery-arrow,
.gallery-favorite {
display: inline-grid;
width: 56px;
height: 56px;
padding: 0;
place-items: center;
border: 1px solid var(--border-control);
border-radius: var(--br-btn-m);
color: var(--text-main);
background: var(--bg-main);
cursor: pointer;
transition:
border-color var(--motion-fast),
background-color var(--motion-fast),
color var(--motion-fast);
}
.cart-link:hover,
.slider-controls button:hover,
.gallery-arrow:hover,
.gallery-favorite:hover {
border-color: var(--border-control-hover);
background: var(--bg-button-light-hover);
}
.cart-link:active,
.slider-controls button:active,
.gallery-arrow:active,
.gallery-favorite:active {
border-color: var(--border-control-pressed);
background: var(--bg-button-light-pressed);
}
.cart-link:focus-visible,
.slider-controls button:focus-visible,
.gallery-arrow:focus-visible,
.gallery-favorite:focus-visible {
outline: 2px solid var(--border-input-focus);
outline-offset: 2px;
}
.menu-toggle {
display: none;
width: 48px;
height: 48px;
padding: 0;
place-items: center;
border: 1px solid var(--border-control);
border-radius: var(--br-btn-m);
color: var(--text-main);
background: var(--bg-main);
transition:
border-color var(--motion-fast),
background-color var(--motion-fast);
}
.menu-toggle:hover {
border-color: var(--border-control-hover);
background: var(--bg-button-light-hover);
}
/* Shared typography and controls */
.breadcrumbs {
display: flex;
margin: 32px 0 16px;
padding: 0;
flex-wrap: wrap;
gap: 8px;
color: var(--text-caption);
list-style: none;
font: var(--control-s);
}
.breadcrumbs li:not(:last-child)::after {
display: inline-block;
width: 6px;
height: 6px;
margin-left: 8px;
border-radius: 50%;
background: var(--text-disable);
content: "";
vertical-align: 2px;
}
.breadcrumbs li:last-child {
color: var(--text-main);
}
.page-heading,
.section-title {
margin: 0;
font: var(--h2);
letter-spacing: var(--ls-tight);
}
.page-heading {
margin-bottom: 24px;
}
.section {
padding: 64px 0;
}
.section-head {
display: flex;
margin-bottom: 28px;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.slider-controls {
display: flex;
gap: 8px;
}
.slider-controls .icon {
width: 24px;
height: 24px;
}
.slider-controls button:disabled {
cursor: default;
opacity: 0.35;
}
.gallery-arrow .icon {
width: 24px;
height: 24px;
}
/* Footer */
.site-footer {
height: 176px;
padding: 30px 0 22px;
flex: 0 0 auto;
background: var(--bg-secondary);
}
.page-template-page-catalog .site-main {
padding-bottom: 24px;
}
.catalog-grid-section {
padding-top: 16px;
}
.products-section {
padding-bottom: 48px;
}
.categories-section {
padding-top: 16px;
padding-bottom: 48px;
}
.footer-top {
height: 70px;
border-bottom: 1px dashed var(--border-secondary);
}
.footer-bottom {
display: grid;
padding-top: 20px;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 24px;
font: var(--control-s);
}
.footer-bottom > span:nth-child(2) {
display: flex;
gap: 28px;
}
.footer-bottom a,
.footer-bottom > span:last-child {
color: var(--text-caption);
text-decoration: underline;
text-decoration-color: var(--border-secondary);
text-decoration-style: dashed;
text-underline-offset: 4px;
}
+258
View File
@@ -0,0 +1,258 @@
/*
* Drawn separators, interaction states, the mobile filter sheet and checkout validation
*/
/* ------------------------------------------------- glyph-free separators --- */
/*
* These were literal "•", "" and "×" characters in the markup. They are
* separators, not content, so they are drawn instead of typed — which also
* keeps them out of the accessibility tree and out of copied text.
*/
.meta-separator {
display: inline-block;
width: 4px;
height: 4px;
margin: 0 10px;
border-radius: 50%;
background: currentColor;
opacity: 0.4;
vertical-align: middle;
}
.price-fields-separator {
width: 8px;
height: 1px;
flex: 0 0 8px;
background: var(--text-caption);
}
.multiply {
display: inline-block;
width: 8px;
height: 8px;
margin: 0 2px;
background: currentColor;
vertical-align: baseline;
-webkit-mask: var(--icon-x) center / contain no-repeat;
mask: var(--icon-x) center / contain no-repeat;
}
/* ------------------------------------------------------------ interaction --- */
/*
* Hover feedback the design specifies through its own state tokens: list rows
* pick up bg-select-unit-hover, muted tabs and links resolve toward
* text-main / text-link-hover.
*/
.filter-option {
padding: 0 8px;
margin-inline: -8px;
border-radius: var(--br-control-m);
transition: background-color var(--motion-fast);
cursor: pointer;
}
.filter-option:hover {
background: var(--bg-select-unit-hover);
}
.tab-list a {
transition: color var(--motion-fast);
}
.tab-list a:hover {
color: var(--text-main);
}
.main-nav a {
transition: color var(--motion-fast);
}
.main-nav a:hover {
color: var(--text-body);
}
.breadcrumbs a {
transition: color var(--motion-fast);
}
.breadcrumbs a:hover {
color: var(--text-main);
}
.product-card-title,
.cart-item-title a {
transition: color var(--motion-fast);
}
.product-card:hover .product-card-title {
color: var(--text-body);
}
.cart-item-remove {
transition:
background-color var(--motion-fast),
color var(--motion-fast);
}
.cart-item-remove:hover {
color: var(--notice-error);
}
/* ------------------------------------------------- mobile filter sheet --- */
/*
* The design keeps filters in a sidebar on desktop but moves them into a
* sheet on mobile, opened by a "Фильтр" button next to the sort control.
* The sheet reuses the same form, so filtering behaves identically.
*/
.filters-open,
.filters-sheet-head,
.filters-sheet-foot {
display: none;
}
@media (max-width: 767px) {
.filters-open {
display: inline-flex;
align-items: center;
gap: 8px;
padding: var(--pad-h-btn-m) var(--pad-w-text-m) var(--pad-h-btn-m) var(--pad-w-icon-m);
border: 0;
border-radius: var(--br-btn-l);
color: var(--text-main);
background: var(--bg-button-light);
font: var(--control-m);
transition: background-color var(--motion-fast);
}
.filters-open:hover {
background: var(--bg-button-light-hover);
}
.filters-open:active {
background: var(--bg-button-light-pressed);
}
.filters {
position: fixed;
z-index: 60;
inset: 0;
display: flex;
flex-direction: column;
/* The desktop rule sets align-self: start. On an absolutely positioned
box that means "size to content" instead of stretching between top and
bottom, which would make the sheet taller than the viewport. */
align-self: stretch;
overflow: hidden;
border-radius: var(--br-modal) var(--br-modal) 0 0;
background: var(--bg-main);
/* Kept in the DOM so the form state survives; moved out of reach until
the sheet is opened. */
visibility: hidden;
opacity: 0;
transform: translateY(2%);
transition:
opacity var(--motion-slow-duration) var(--motion-slow),
transform var(--motion-slow-duration) var(--motion-slow),
visibility 0s linear var(--motion-slow-duration);
}
.filters.is-open {
visibility: visible;
opacity: 1;
transform: none;
transition-delay: 0s;
}
.filters-sheet-head {
display: flex;
padding: 16px var(--container-gutter);
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--border-main);
}
.filters-sheet-title {
margin: 0;
font: var(--h4);
letter-spacing: var(--ls-tight);
}
.filters-close {
width: 44px;
height: 44px;
}
.filters form {
flex: 1 1 auto;
padding: 0 var(--container-gutter);
overflow-y: auto;
overscroll-behavior: contain;
}
.filters .filter-group {
padding: 16px 0;
border-bottom: 1px solid var(--border-main);
}
.filters-sheet-foot {
display: block;
padding: 16px var(--container-gutter) calc(16px + env(safe-area-inset-bottom));
border-top: 1px solid var(--border-main);
}
.filters-apply {
width: 100%;
}
body.filters-open-lock {
overflow: hidden;
}
}
.filter-input::-webkit-outer-spin-button,
.filter-input::-webkit-inner-spin-button {
margin: 0;
appearance: none;
}
/* --------------------------------------------------- checkout validation --- */
/*
* The design puts validation on the field itself: a red edge plus red caption
* text under it, per field. It does not use a banner listing every problem, so
* the WooCommerce notice group is suppressed once its errors have been mapped
* onto the fields.
*/
.checkout-input {
position: relative;
}
.checkout-input .field-error {
display: block;
margin-top: 4px;
color: var(--text-negative);
font: var(--caption);
}
.checkout-input .field-error:empty {
display: none;
}
.checkout-input.is-error input {
border-color: var(--border-input-error);
box-shadow: inset 0 0 0 1px var(--border-input-error);
}
.checkout-input input:focus {
box-shadow: inset 0 0 0 1px var(--border-input-focus);
}
.woocommerce-NoticeGroup-checkout.is-field-level,
.checkout .woocommerce-error.is-field-level {
display: none;
}
+300
View File
@@ -0,0 +1,300 @@
/*
* Product page: gallery, summary, tabs
*/
/* Product page */
.product-layout {
display: grid;
margin-bottom: 40px;
grid-template-columns: 776px 544px;
align-items: start;
gap: 24px;
}
.product-gallery {
position: relative;
height: 580px;
overflow: hidden;
border: 1px solid var(--border-main);
border-radius: 16px;
background: var(--bg-secondary);
}
.gallery-images {
position: absolute;
z-index: 0;
inset: 0;
}
.gallery-image {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.gallery-image-placeholder {
background: var(--bg-secondary);
}
.gallery-badges {
position: absolute;
z-index: 2;
top: 16px;
left: 16px;
display: flex;
gap: 4px;
}
.gallery-favorite {
position: absolute;
z-index: 2;
top: 10px;
right: 10px;
width: 48px;
height: 48px;
border-radius: 50%;
}
.gallery-favorite .icon {
--icon-size: 24px;
}
.gallery-favorite.is-active {
background: var(--bg-main);
}
.gallery-favorite.is-active {
color: var(--notice-error);
}
.gallery-arrow {
position: absolute;
z-index: 2;
top: 50%;
width: 48px;
height: 48px;
border-radius: 50%;
transform: translateY(-50%);
}
.gallery-arrow-prev {
left: 8px;
}
.gallery-arrow-next {
right: 8px;
}
.gallery-dots {
position: absolute;
bottom: 10px;
left: 50%;
display: flex;
gap: 4px;
transform: translateX(-50%);
}
.gallery-dots i {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-caption);
}
.gallery-dots i.active {
background: var(--text-main);
}
.product-summary {
min-height: 652px;
padding: 32px;
border-radius: 16px;
background: var(--bg-main);
box-shadow: 0 16px 38px rgb(0 0 0 / 8%);
}
.product-summary h1 {
margin: 6px 0 3px;
font: var(--h4);
letter-spacing: var(--ls-tight);
}
.product-meta-line {
color: var(--text-caption);
font: var(--control-s);
}
.product-summary .woocommerce-product-details__short-description {
margin-bottom: 14px;
color: var(--text-caption);
font: var(--control-m);
}
.product-summary .woocommerce-product-details__short-description p {
margin: 0;
}
.attribute-table {
width: 100%;
margin: 10px 0 18px;
border-collapse: collapse;
}
.attribute-table td {
padding: 8px 0;
border-top: 1px solid var(--border-main);
font: var(--control-m);
}
.attribute-table tr:last-child td {
border-bottom: 1px solid var(--border-main);
}
.attribute-table td:first-child {
width: 42%;
color: var(--text-caption);
}
.product-option {
position: relative;
display: block;
margin: 14px 0 20px;
}
.product-option > span {
position: absolute;
z-index: 1;
top: 10px;
left: 20px;
color: var(--text-caption);
font: var(--caption);
}
.product-option select {
width: 100%;
height: 60px;
padding: 23px 50px 7px 20px;
border: 1px solid var(--border-main);
border-radius: 10px;
outline: 0;
background: var(--bg-main) var(--icon-caret-down) right 20px center / 16px no-repeat;
appearance: none;
}
.product-option .custom-select {
display: flex;
width: 100%;
}
.product-option .custom-select-trigger {
width: 100%;
height: 60px;
padding: 23px 50px 7px 20px;
border: 1px solid var(--border-main);
border-radius: 10px;
background: var(--bg-main);
}
.product-option .custom-select-trigger::after {
right: 20px;
}
.product-option .custom-select-menu {
width: 100%;
}
.product-summary > .price {
margin: 0 0 12px;
font: var(--h3);
letter-spacing: var(--ls-tight);
}
.product-summary form.cart {
display: grid;
margin: 0;
grid-template-columns: 144px minmax(0, 1fr);
gap: 12px;
}
.product-summary form.cart .quantity-control,
.product-summary form.cart .single_add_to_cart_button {
min-height: 56px;
}
.product-summary .single_add_to_cart_button {
width: 100%;
}
.product-summary .single_add_to_cart_button .icon {
--icon-size: 20px;
}
.buy-one-click {
display: flex;
width: 100%;
min-height: 56px;
margin-top: 8px;
}
.product-tabs {
margin: 0 0 64px;
}
.single-product .product-tabs {
margin-bottom: 46px;
}
.single-product .section {
padding: 16px 0 90px;
}
.tab-list {
display: flex;
margin: 0 0 28px;
padding: 0;
gap: 30px;
border-bottom: 1px solid var(--border-main);
list-style: none;
}
.tab-list a {
display: block;
padding: 14px 0 12px;
color: var(--text-caption);
text-transform: uppercase;
font: var(--upper-m);
letter-spacing: var(--ls-upper);
text-transform: uppercase;
}
.tab-list .active a {
color: var(--text-main);
border-bottom: 1px solid var(--text-main);
}
.prose {
max-width: 800px;
font: var(--body-m);
}
.prose h2,
.prose h3 {
margin: 28px 0 8px;
}
.prose h2 {
font: var(--h4);
letter-spacing: var(--ls-tight);
}
.prose h3 {
font: var(--h5);
letter-spacing: var(--ls-tight-s);
}
.prose p {
margin: 0 0 14px;
}
+548
View File
@@ -0,0 +1,548 @@
/*
* Breakpoints. Must stay after every layer it overrides
*/
/* Responsive */
@media (max-width: 1439px) {
.container {
width: min(calc(100% - 48px), 1200px);
}
.product-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.category-card {
grid-template-columns: minmax(0, 1fr) 40%;
}
.archive-shell {
grid-template-columns: 250px minmax(0, 1fr);
gap: 32px;
}
.archive-results .product-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.product-layout {
grid-template-columns: minmax(0, 1.43fr) minmax(440px, 1fr);
}
}
@media (max-width: 1023px) {
.home .site-header {
height: 120px;
}
.hero-copy,
.hero h1 {
width: 100%;
max-width: 100%;
}
.hero h1 {
white-space: normal;
}
.header-inner {
gap: 16px;
}
.menu-toggle {
display: grid;
margin-left: auto;
}
.main-nav {
position: fixed;
z-index: 50;
inset: 88px 0 0;
display: none;
margin: 0;
padding: 30px 24px;
background: var(--bg-main);
}
.menu-open .main-nav {
display: block;
}
.main-nav ul {
align-items: stretch;
flex-direction: column;
gap: 0;
}
.main-nav a {
padding: 15px 0;
border-bottom: 1px solid var(--border-main);
font: var(--lead);
}
.product-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.category-grid,
.news-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.archive-shell,
.product-layout,
.cart-layout,
.checkout-layout {
grid-template-columns: 1fr;
}
.cart-order-card {
width: 100%;
}
.filters {
padding: 20px;
border-radius: 12px;
background: var(--bg-secondary);
}
.archive-results .product-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.product-gallery {
height: min(70vw, 580px);
}
.product-summary {
min-height: 0;
}
.footer-bottom {
grid-template-columns: 1fr auto;
}
.footer-bottom > span:nth-child(2) {
order: 3;
grid-column: 1 / -1;
}
}
@media (max-width: 639px) {
.home .site-header {
height: 72px;
}
.container {
width: calc(100% - 32px);
}
.header-inner {
height: 72px;
}
.main-nav {
inset: 72px 0 0;
}
.cart-link {
width: 48px;
height: 48px;
}
.shop-toast-stack,
.home .shop-toast-stack {
top: 88px;
right: 16px;
width: calc(100vw - 32px);
}
.section {
padding: 48px 0;
}
.hero {
height: auto;
min-height: 0;
padding: 44px 0 48px;
}
.hero-title-group > p,
.hero-features {
width: 100%;
max-width: 100%;
}
.hero-features {
grid-template-columns: 1fr;
gap: 14px;
}
.products-section {
height: auto;
overflow: visible;
}
.product-grid,
.category-grid,
.news-grid,
.archive-results .product-grid {
grid-template-columns: 1fr;
}
.category-card {
height: 230px;
grid-template-columns: minmax(0, 1fr) 38%;
}
.news-card {
height: 360px;
}
.pagination .nav-links,
.woocommerce nav.woocommerce-pagination ul {
flex-wrap: wrap;
}
.pagination .page-numbers,
.woocommerce nav.woocommerce-pagination ul li a,
.woocommerce nav.woocommerce-pagination ul li span {
width: 40px;
height: 40px;
}
.slider-controls {
display: none;
}
/* The design keeps the filter button and the sort control on one row; the
sort label stacks above its value so the pair fits at 360px. */
.archive-toolbar {
align-items: center;
flex-direction: row;
flex-wrap: nowrap;
gap: 12px;
}
.archive-toolbar .sort-control {
display: flex;
min-width: 0;
margin-left: auto;
align-items: flex-end;
flex-direction: column;
gap: 0;
text-align: right;
}
.archive-toolbar .sort-control > span {
font: var(--caption);
}
/* The mobile layout does not expose the per-page control. */
.archive-toolbar .per-page-control {
display: none;
}
.product-gallery {
height: 400px;
}
.product-summary {
padding: 22px;
}
.product-summary form.cart {
grid-template-columns: 120px minmax(0, 1fr);
}
.tab-list {
overflow-x: auto;
}
.tab-list a {
white-space: nowrap;
}
.article-media,
.article-media-wide {
height: 62vw;
}
.article-lead {
margin-bottom: 32px;
}
.cart-table thead {
display: none;
}
.cart-table tr,
.cart-table td {
display: block;
}
.cart-table tr {
padding: 16px 0;
border-bottom: 1px solid var(--border-main);
}
.cart-table td {
padding: 7px 0;
border: 0;
}
.cart-page {
width: 100%;
height: 622px;
padding: 8px 16px 32px;
}
.checkout-fields {
width: 100%;
}
.checkout-page {
width: 100%;
min-height: 1278px;
padding: 8px 16px 32px;
}
.checkout-page .page-heading {
margin-bottom: 14px;
}
.checkout-layout {
gap: 32px;
}
.checkout-input,
.checkout-map {
width: 100%;
}
.checkout-input,
.checkout-input input {
height: 64px;
}
.checkout-delivery,
.checkout-payment-options {
margin-top: 24px;
}
.checkout-delivery legend,
.checkout-payment-options legend {
margin-bottom: 8px;
}
.delivery-switch {
height: 48px;
margin-bottom: 16px;
}
.delivery-switch span {
height: 40px;
}
.checkout-radio {
min-height: 24px;
}
.checkout-map {
height: auto;
margin-top: 16px;
aspect-ratio: 1.44;
}
.checkout-payment-options {
margin-top: 24px;
}
.checkout-order-card {
width: 100%;
min-height: 408px;
height: 408px;
padding: 16px;
}
.site-footer {
height: auto;
min-height: 210px;
}
.footer-bottom {
grid-template-columns: 1fr;
}
.footer-bottom > span:nth-child(2) {
grid-column: auto;
flex-direction: column;
gap: 10px;
}
}
@media (max-width: 767px) {
/* The mobile frame shows the same row, edge to edge and without the
desktop next button — a touch drag does that job. */
.subcategories {
margin-inline: calc(var(--container-gutter) * -1);
padding-inline: var(--container-gutter);
}
.subcategory-next {
display: none;
}
.subcategory-card {
width: 200px;
}
.quick-filters {
flex-wrap: nowrap;
margin-inline: calc(var(--container-gutter) * -1);
padding-inline: var(--container-gutter);
overflow-x: auto;
scrollbar-width: none;
}
.quick-filters::-webkit-scrollbar {
height: 0;
}
.quick-filter {
flex: 0 0 auto;
}
}
/*
* The cart row needs about 550px before its columns start colliding, and the
* summary beside it is a fixed 432px. Below the width where both fit, they
* stack — otherwise the summary ends up sitting on top of the items.
*/
@media (max-width: 1199px) {
.cart-layout,
.checkout-layout {
grid-template-columns: minmax(0, 1fr);
}
.cart-order-card {
width: 100%;
}
/* Once the summary drops below the fields the checkout is one column, so
centre it rather than leaving both blocks pinned to the left edge. */
.checkout-layout .checkout-fields,
.checkout-layout .checkout-order-card {
width: 432px;
max-width: 100%;
margin-inline: auto;
}
}
/*
* The cart row needs about 550px before its columns collide. Below that it
* takes the stacked shape from the mobile frame — thumbnail top left, copy
* beside it, price and controls on the bottom row.
*/
@media (max-width: 767px) {
.cart-page .page-heading {
margin-bottom: 8px;
}
.cart-layout {
display: flex;
flex-direction: column;
gap: 16px;
}
.cart-items {
width: 100%;
gap: 8px;
}
.cart-item {
position: relative;
display: block;
width: 100%;
min-height: 150px;
height: 150px;
padding: 12px;
}
.cart-thumb {
position: absolute;
top: 12px;
left: 12px;
width: 88px;
height: 70px;
}
.cart-thumb .product-badge {
display: none;
}
.cart-item-copy {
position: absolute;
top: 16px;
right: 12px;
left: 112px;
}
.cart-item-price {
position: absolute;
bottom: 16px;
left: 12px;
text-align: left;
}
.cart-item-quantity {
position: absolute;
right: 64px;
bottom: 16px;
width: 104px;
height: 44px;
}
.cart-item-remove {
position: absolute;
right: 12px;
bottom: 16px;
width: 44px;
height: 44px;
}
.cart-order-card {
width: 100%;
min-height: 212px;
height: 212px;
padding: 16px;
}
.cart-order-card .order-row {
min-height: 28px;
padding: 2px 0;
}
.cart-order-card .order-row.total {
min-height: 36px;
margin: 0;
padding-top: 4px;
}
.cart-order-card .button {
height: 56px;
margin-top: 12px;
}
}
@media (max-width: 767px) {
.checkout-input input {
padding: 25px 20px 15px;
}
.checkout-input input:focus + .field-label,
.checkout-input input:not(:placeholder-shown) + .field-label {
top: 10px;
}
}
+188
View File
@@ -0,0 +1,188 @@
/*
* Design tokens.
*
* A 1:1 transcription of the Figma variable collections (`colors`, `borders`,
* `size`) and text styles. Nothing below this file may invent a colour, a
* radius or a type size — everything resolves to a token defined here.
*/
:root {
/* text */
--text-main: #000;
--text-body: rgb(0 0 0 / 80%);
--text-caption: rgb(0 0 0 / 40%);
--text-disable: rgb(0 0 0 / 24%);
--text-main-contrast: #fff;
--text-body-contrast: rgb(255 255 255 / 88%);
--text-caption-contrast: rgb(255 255 255 / 48%);
--text-disable-contrast: rgb(255 255 255 / 24%);
--text-link: #3b67db;
--text-link-hover: #294ca7;
--text-negative: #d7202b;
--text-positive: #82b443;
--text-white: #fff;
--text-black: #000;
/* bg */
--bg-main: #fff;
--bg-secondary: #f4f4f4;
--bg-main-contrast: #000;
--bg-secondary-contrast: #212121;
--bg-button: #010101;
--bg-button-hover: #373737;
--bg-button-pressed: #000;
--bg-button-light: #f3f3f3;
--bg-button-light-hover: #e2e2e2;
--bg-button-light-pressed: #f0f0f0;
--bg-control-disable: rgb(0 0 0 / 6%);
--bg-select-unit: #fff;
--bg-select-unit-hover: #f4f4f4;
--bg-select-unit-selected: #ededed;
--bg-modal-shadow: rgb(0 0 0 / 64%);
/* border */
--border-main: rgb(0 0 0 / 8%);
--border-secondary: rgb(0 0 0 / 12%);
--border-link: rgb(48 93 209 / 24%);
--border-link-hover: rgb(41 76 167 / 32%);
--border-link-main: rgb(0 0 0 / 16%);
--border-link-main-hover: rgb(0 0 0 / 24%);
--border-checkbox: rgb(0 0 0 / 24%);
--border-checkbox-hover: rgb(0 0 0 / 32%);
--border-input: rgb(0 0 0 / 12%);
--border-input-hover: rgb(0 0 0 / 16%);
--border-input-focus: #393939;
--border-input-error: #d7202b;
--border-control: rgb(0 0 0 / 12%);
--border-control-hover: rgb(0 0 0 / 20%);
--border-control-pressed: rgb(0 0 0 / 12%);
--border-control-disable: rgb(0 0 0 / 8%);
/* notice */
--notice-info: #4299ff;
--notice-success: #8cca3e;
--notice-warning: #f1ab43;
--notice-error: #f5473c;
/* radii — desktop values, overridden for mobile below */
--br-control-l: 16px;
--br-control-m: 8px;
--br-btn-l: 12px;
--br-btn-m: 8px;
--br-section-l: 24px;
--br-section-m: 16px;
--br-modal: 16px;
--br-checkbox: 4px;
/* sizes */
--margin-main-top: 16px;
--margin-main-bottom: 48px;
--margin-header-bottom: 32px;
--h-input-l: 72px;
--h-text-area-l: 136px;
--pad-h-btn-l: 24px;
--pad-h-btn-m: 16px;
--pad-w-text-l: 28px;
--pad-w-text-m: 20px;
--pad-w-icon-l: 24px;
--pad-w-icon-m: 16px;
/* type scale — desktop */
--font-sans: "Inter", system-ui, -apple-system, "Segoe UI", arial, sans-serif;
--h1: 700 68px/72px var(--font-sans);
--h2: 700 48px/56px var(--font-sans);
--h3: 700 38px/42px var(--font-sans);
--h4: 700 28px/32px var(--font-sans);
--h5: 700 20px/24px var(--font-sans);
--lead: 400 20px/28px var(--font-sans);
--body-m: 400 16px/24px var(--font-sans);
--control-m: 400 16px/24px var(--font-sans);
--control-s: 400 14px/20px var(--font-sans);
--caption: 400 12px/16px var(--font-sans);
--upper-l: 600 16px/24px var(--font-sans);
--upper-m: 600 14px/24px var(--font-sans);
--upper-s: 600 12px/16px var(--font-sans);
--accent-m: 700 16px/24px var(--font-sans);
--ls-tight: -0.03em;
--ls-tight-s: -0.02em;
--ls-upper: 0.02em;
/* layout */
--container: 1344px;
--container-gutter: 24px;
/* motion — Figma "Slow" spring: mass 1, stiffness 80, damping 20, 400ms */
--motion-fast: 100ms ease-in-out;
--motion-slow-duration: 400ms;
--motion-slow: linear(
0 0%,
0.0257 6.25%,
0.0879 12.5%,
0.1696 18.75%,
0.2601 25%,
0.3523 31.25%,
0.4419 37.5%,
0.5265 43.75%,
0.6047 50%,
0.6759 56.25%,
0.7401 62.5%,
0.7976 68.75%,
0.8488 75%,
0.894 81.25%,
0.934 87.5%,
0.9691 93.75%,
1 100%
);
/* effects */
--shadow-s: 0 2px 8px rgb(0 0 0 / 6%);
--shadow-m: 0 8px 32px rgb(0 0 0 / 10%);
/*
* The checkbox tick. A native `input` cannot hold a child element, so the
* Phosphor `check-bold` vector is carried as a mask — still the real SVG
* path, tinted with `currentColor`, and it covers checkboxes WooCommerce
* prints for itself.
*/
--icon-check: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path d="M232.49,80.49l-128,128a12,12,0,0,1-17,0l-56-56a12,12,0,1,1,17-17L96,183,215.51,63.51a12,12,0,0,1,17,17Z"/></svg>');
--icon-x: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/></svg>');
--icon-caret-down: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z"/></svg>');
--icon-caret-up: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path d="M213.66,165.66a8,8,0,0,1-11.32,0L128,91.31,53.66,165.66a8,8,0,0,1-11.32-11.32l80-80a8,8,0,0,1,11.32,0l80,80A8,8,0,0,1,213.66,165.66Z"/></svg>');
--icon-check-circle-fill: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm45.66,85.66-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32Z"/></svg>');
--icon-info-fill: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm-4,48a12,12,0,1,1-12,12A12,12,0,0,1,124,72Zm12,112a16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40a8,8,0,0,1,0,16Z"/></svg>');
--icon-warning-fill: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path d="M236.8,188.09,149.35,36.22h0a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM120,104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm8,88a12,12,0,1,1,12-12A12,12,0,0,1,128,192Z"/></svg>');
--icon-x-circle-fill: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm37.66,130.34a8,8,0,0,1-11.32,11.32L128,139.31l-26.34,26.35a8,8,0,0,1-11.32-11.32L116.69,128,90.34,101.66a8,8,0,0,1,11.32-11.32L128,116.69l26.34-26.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/></svg>');
}
@media (max-width: 767px) {
:root {
--br-control-l: 12px;
--br-btn-l: 8px;
--br-section-l: 16px;
--br-section-m: 12px;
--margin-main-top: 8px;
--margin-main-bottom: 32px;
--margin-header-bottom: 16px;
--h-input-l: 64px;
--h-text-area-l: 128px;
--pad-h-btn-l: 16px;
--pad-h-btn-m: 10px;
--pad-w-text-l: 24px;
--pad-w-text-m: 16px;
--pad-w-icon-l: 16px;
--pad-w-icon-m: 10px;
--h1: 700 32px/36px var(--font-sans);
--h2: 700 26px/30px var(--font-sans);
--h3: 700 22px/26px var(--font-sans);
--h4: 700 20px/22px var(--font-sans);
--h5: 700 16px/20px var(--font-sans);
--lead: 400 16px/24px var(--font-sans);
--upper-l: 600 14px/18px var(--font-sans);
--upper-m: 600 12px/16px var(--font-sans);
--upper-s: 600 10px/14px var(--font-sans);
--container-gutter: 16px;
}
}
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
# Local fonts
- `Inter.ttf` — Inter variable font by Rasmus Andersson, SIL Open Font License 1.1.
The files are distributed locally so the layout does not depend on a third-party font CDN.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128a8,8,0,0,1-8,8H120v64a8,8,0,0,1-13.66,5.66l-72-72a8,8,0,0,1,0-11.32l72-72A8,8,0,0,1,120,56v64h96A8,8,0,0,1,224,128Z"/></svg>

After

Width:  |  Height:  |  Size: 223 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z"/></svg>

After

Width:  |  Height:  |  Size: 248 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.7806 12.5307L14.0306 19.2807C13.9257 19.3857 13.792 19.4573 13.6465 19.4862C13.5009 19.5152 13.35 19.5004 13.2129 19.4436C13.0758 19.3868 12.9586 19.2905 12.8762 19.1671C12.7938 19.0436 12.7499 18.8985 12.75 18.7501V12.7501H3.75C3.55109 12.7501 3.36032 12.6711 3.21967 12.5304C3.07902 12.3898 3 12.199 3 12.0001C3 11.8012 3.07902 11.6104 3.21967 11.4698C3.36032 11.3291 3.55109 11.2501 3.75 11.2501H12.75V5.2501C12.7499 5.10168 12.7938 4.95656 12.8762 4.83312C12.9586 4.70967 13.0758 4.61345 13.2129 4.55664C13.35 4.49982 13.5009 4.48497 13.6465 4.51396C13.792 4.54294 13.9257 4.61447 14.0306 4.71948L20.7806 11.4695C20.8504 11.5391 20.9057 11.6218 20.9434 11.7129C20.9812 11.8039 21.0006 11.9015 21.0006 12.0001C21.0006 12.0987 20.9812 12.1963 20.9434 12.2873C20.9057 12.3784 20.8504 12.4611 20.7806 12.5307Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 942 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.7806 12.5306L14.0306 19.2806C13.8899 19.4213 13.699 19.5004 13.5 19.5004C13.301 19.5004 13.1101 19.4213 12.9694 19.2806C12.8286 19.1399 12.7496 18.949 12.7496 18.75C12.7496 18.551 12.8286 18.3601 12.9694 18.2194L18.4397 12.75H3.75C3.55109 12.75 3.36032 12.671 3.21967 12.5303C3.07902 12.3897 3 12.1989 3 12C3 11.8011 3.07902 11.6103 3.21967 11.4697C3.36032 11.329 3.55109 11.25 3.75 11.25H18.4397L12.9694 5.78061C12.8286 5.63988 12.7496 5.44901 12.7496 5.24999C12.7496 5.05097 12.8286 4.8601 12.9694 4.71936C13.1101 4.57863 13.301 4.49957 13.5 4.49957C13.699 4.49957 13.8899 4.57863 14.0306 4.71936L20.7806 11.4694C20.8504 11.539 20.9057 11.6217 20.9434 11.7128C20.9812 11.8038 21.0006 11.9014 21.0006 12C21.0006 12.0986 20.9812 12.1961 20.9434 12.2872C20.9057 12.3782 20.8504 12.461 20.7806 12.5306Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 933 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,48,88H208a8,8,0,0,1,5.66,13.66Z"/></svg>

After

Width:  |  Height:  |  Size: 188 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z"/></svg>

After

Width:  |  Height:  |  Size: 214 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M165.66,202.34a8,8,0,0,1-11.32,11.32l-80-80a8,8,0,0,1,0-11.32l80-80a8,8,0,0,1,11.32,11.32L91.31,128Z"/></svg>

After

Width:  |  Height:  |  Size: 200 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M181.66,133.66l-80,80a8,8,0,0,1-11.32-11.32L164.69,128,90.34,53.66a8,8,0,0,1,11.32-11.32l80,80A8,8,0,0,1,181.66,133.66Z"/></svg>

After

Width:  |  Height:  |  Size: 219 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M213.66,165.66a8,8,0,0,1-11.32,0L128,91.31,53.66,165.66a8,8,0,0,1-11.32-11.32l80-80a8,8,0,0,1,11.32,0l80,80A8,8,0,0,1,213.66,165.66Z"/></svg>

After

Width:  |  Height:  |  Size: 232 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.4728 6.95062L20.0691 15.6019C19.9369 16.0745 19.6542 16.4911 19.2639 16.7885C18.8736 17.0859 18.397 17.2479 17.9062 17.25H8.64C8.14784 17.2498 7.66926 17.0886 7.27725 16.791C6.88523 16.4935 6.6013 16.0758 6.46875 15.6019L3.18 3.75H1.5C1.30109 3.75 1.11032 3.67098 0.96967 3.53033C0.829018 3.38968 0.75 3.19891 0.75 3C0.75 2.80109 0.829018 2.61032 0.96967 2.46967C1.11032 2.32902 1.30109 2.25 1.5 2.25H3.75C3.91397 2.24997 4.07343 2.30367 4.20398 2.40289C4.33452 2.50211 4.42895 2.64138 4.47281 2.79938L5.36156 6H21.75C21.8656 5.99998 21.9797 6.02669 22.0833 6.07805C22.1869 6.1294 22.2772 6.20401 22.3472 6.29605C22.4171 6.38809 22.4649 6.49506 22.4867 6.60861C22.5085 6.72216 22.5037 6.83922 22.4728 6.95062ZM8.25 18.75C7.95333 18.75 7.66332 18.838 7.41665 19.0028C7.16997 19.1676 6.97771 19.4019 6.86418 19.676C6.75065 19.9501 6.72094 20.2517 6.77882 20.5426C6.8367 20.8336 6.97956 21.1009 7.18934 21.3107C7.39912 21.5204 7.66639 21.6633 7.95736 21.7212C8.24834 21.7791 8.54994 21.7494 8.82403 21.6358C9.09811 21.5223 9.33238 21.33 9.4972 21.0834C9.66203 20.8367 9.75 20.5467 9.75 20.25C9.75 19.8522 9.59196 19.4706 9.31066 19.1893C9.02936 18.908 8.64782 18.75 8.25 18.75ZM18 18.75C17.7033 18.75 17.4133 18.838 17.1666 19.0028C16.92 19.1676 16.7277 19.4019 16.6142 19.676C16.5006 19.9501 16.4709 20.2517 16.5288 20.5426C16.5867 20.8336 16.7296 21.1009 16.9393 21.3107C17.1491 21.5204 17.4164 21.6633 17.7074 21.7212C17.9983 21.7791 18.2999 21.7494 18.574 21.6358C18.8481 21.5223 19.0824 21.33 19.2472 21.0834C19.412 20.8367 19.5 20.5467 19.5 20.25C19.5 19.8522 19.342 19.4706 19.0607 19.1893C18.7794 18.908 18.3978 18.75 18 18.75Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M232.49,80.49l-128,128a12,12,0,0,1-17,0l-56-56a12,12,0,1,1,17-17L96,183,215.51,63.51a12,12,0,0,1,17,17Z"/></svg>

After

Width:  |  Height:  |  Size: 203 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm45.66,85.66-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32Z"/></svg>

After

Width:  |  Height:  |  Size: 268 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.0306 9.53068L12.5306 17.0307C12.461 17.1004 12.3782 17.1557 12.2872 17.1935C12.1961 17.2312 12.0986 17.2506 12 17.2506C11.9014 17.2506 11.8038 17.2312 11.7128 17.1935C11.6217 17.1557 11.539 17.1004 11.4694 17.0307L3.96936 9.53068C3.82863 9.38995 3.74957 9.19907 3.74957 9.00005C3.74957 8.80103 3.82863 8.61016 3.96936 8.46943C4.1101 8.32869 4.30097 8.24963 4.49999 8.24963C4.69901 8.24963 4.88988 8.32869 5.03061 8.46943L12 15.4397L18.9694 8.46943C19.039 8.39974 19.1218 8.34447 19.2128 8.30676C19.3039 8.26904 19.4014 8.24963 19.5 8.24963C19.5985 8.24963 19.6961 8.26904 19.7872 8.30676C19.8782 8.34447 19.9609 8.39974 20.0306 8.46943C20.1003 8.53911 20.1556 8.62183 20.1933 8.71288C20.231 8.80392 20.2504 8.9015 20.2504 9.00005C20.2504 9.0986 20.231 9.19618 20.1933 9.28722C20.1556 9.37827 20.1003 9.46099 20.0306 9.53068Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 957 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M96.26,37.05A8,8,0,0,1,102,27.29a104.11,104.11,0,0,1,52,0,8,8,0,0,1-2,15.75,8.15,8.15,0,0,1-2-.26,88.09,88.09,0,0,0-44,0A8,8,0,0,1,96.26,37.05ZM53.79,55.14a104.05,104.05,0,0,0-26,45,8,8,0,0,0,15.42,4.27,88,88,0,0,1,22-38.09A8,8,0,0,0,53.79,55.14ZM43.21,151.55a8,8,0,1,0-15.42,4.28,104.12,104.12,0,0,0,26,45,8,8,0,0,0,11.41-11.22A88.14,88.14,0,0,1,43.21,151.55ZM150,213.22a88,88,0,0,1-44,0,8,8,0,1,0-4,15.49,104.11,104.11,0,0,0,52,0,8,8,0,0,0-4-15.49ZM222.65,146a8,8,0,0,0-9.85,5.58,87.91,87.91,0,0,1-22,38.08,8,8,0,1,0,11.42,11.21,104,104,0,0,0,26-45A8,8,0,0,0,222.65,146Zm-9.86-41.54a8,8,0,0,0,15.42-4.28,104,104,0,0,0-26-45,8,8,0,1,0-11.41,11.22A88,88,0,0,1,212.79,104.45Z"/></svg>

After

Width:  |  Height:  |  Size: 774 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.1929 15.2869C20.1362 15.4239 20.0401 15.5411 19.9168 15.6235C19.7934 15.7059 19.6484 15.75 19.5001 15.75H4.5001C4.35168 15.7501 4.20656 15.7062 4.08312 15.6238C3.95967 15.5414 3.86345 15.4242 3.80664 15.2871C3.74982 15.15 3.73497 14.9991 3.76396 14.8535C3.79294 14.708 3.86447 14.5743 3.96948 14.4694L11.4695 6.96937C11.5391 6.89964 11.6218 6.84432 11.7129 6.80658C11.8039 6.76883 11.9015 6.7494 12.0001 6.7494C12.0987 6.7494 12.1963 6.76883 12.2873 6.80658C12.3784 6.84432 12.4611 6.89964 12.5307 6.96937L20.0307 14.4694C20.1356 14.5743 20.207 14.708 20.2358 14.8535C20.2647 14.999 20.2498 15.1498 20.1929 15.2869Z" fill="black" fill-opacity="0.24"/>
</svg>

After

Width:  |  Height:  |  Size: 768 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M230.6,49.53A15.81,15.81,0,0,0,216,40H40A16,16,0,0,0,28.19,66.76l.08.09L96,139.17V216a16,16,0,0,0,24.87,13.32l32-21.34A16,16,0,0,0,160,194.66V139.17l67.74-72.32.08-.09A15.8,15.8,0,0,0,230.6,49.53ZM40,56h0Zm106.18,74.58A8,8,0,0,0,144,136v58.66L112,216V136a8,8,0,0,0-2.16-5.47L40,56H216Z"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.5 9.5625C22.5 16.125 12.7697 21.4369 12.3553 21.6562C12.2461 21.715 12.124 21.7458 12 21.7458C11.876 21.7458 11.7539 21.715 11.6447 21.6562C11.2303 21.4369 1.5 16.125 1.5 9.5625C1.50174 8.02146 2.11468 6.54404 3.20436 5.45436C4.29404 4.36468 5.77146 3.75174 7.3125 3.75C9.24844 3.75 10.9434 4.5825 12 5.98969C13.0566 4.5825 14.7516 3.75 16.6875 3.75C18.2285 3.75174 19.706 4.36468 20.7956 5.45436C21.8853 6.54404 22.4983 8.02146 22.5 9.5625Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 574 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.6875 3.75C14.7516 3.75 13.0566 4.5825 12 5.98969C10.9434 4.5825 9.24844 3.75 7.3125 3.75C5.77146 3.75174 4.29404 4.36468 3.20436 5.45436C2.11468 6.54404 1.50174 8.02146 1.5 9.5625C1.5 16.125 11.2303 21.4369 11.6447 21.6562C11.7539 21.715 11.876 21.7458 12 21.7458C12.124 21.7458 12.2461 21.715 12.3553 21.6562C12.7697 21.4369 22.5 16.125 22.5 9.5625C22.4983 8.02146 21.8853 6.54404 20.7956 5.45436C19.706 4.36468 18.2285 3.75174 16.6875 3.75ZM12 20.1375C10.2881 19.14 3 14.5959 3 9.5625C3.00149 8.41921 3.45632 7.32317 4.26475 6.51475C5.07317 5.70632 6.16921 5.25149 7.3125 5.25C9.13594 5.25 10.6669 6.22125 11.3062 7.78125C11.3628 7.91881 11.4589 8.03646 11.5824 8.11926C11.7059 8.20207 11.8513 8.24627 12 8.24627C12.1487 8.24627 12.2941 8.20207 12.4176 8.11926C12.5411 8.03646 12.6372 7.91881 12.6937 7.78125C13.3331 6.21844 14.8641 5.25 16.6875 5.25C17.8308 5.25149 18.9268 5.70632 19.7353 6.51475C20.5437 7.32317 20.9985 8.41921 21 9.5625C21 14.5884 13.71 19.1391 12 20.1375Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm-4,48a12,12,0,1,1-12,12A12,12,0,0,1,124,72Zm12,112a16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40a8,8,0,0,1,0,16Z"/></svg>

After

Width:  |  Height:  |  Size: 282 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128ZM40,72H216a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16ZM216,184H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z"/></svg>

After

Width:  |  Height:  |  Size: 253 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/></svg>

After

Width:  |  Height:  |  Size: 243 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 12C21 12.1989 20.921 12.3897 20.7803 12.5303C20.6397 12.671 20.4489 12.75 20.25 12.75H3.75C3.55109 12.75 3.36032 12.671 3.21967 12.5303C3.07902 12.3897 3 12.1989 3 12C3 11.8011 3.07902 11.6103 3.21967 11.4697C3.36032 11.329 3.55109 11.25 3.75 11.25H20.25C20.4489 11.25 20.6397 11.329 20.7803 11.4697C20.921 11.6103 21 11.8011 21 12Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 465 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M209.66,122.34a8,8,0,0,1,0,11.32l-82.05,82a56,56,0,0,1-79.2-79.21L147.67,35.73a40,40,0,1,1,56.61,56.55L105,193A24,24,0,1,1,71,159L154.3,74.38A8,8,0,1,1,165.7,85.6L82.39,170.31a8,8,0,1,0,11.27,11.36L192.93,81A24,24,0,1,0,159,47L59.76,147.68a40,40,0,1,0,56.53,56.62l82.06-82A8,8,0,0,1,209.66,122.34Z"/></svg>

After

Width:  |  Height:  |  Size: 397 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 12C21 12.1989 20.921 12.3897 20.7803 12.5303C20.6397 12.671 20.4489 12.75 20.25 12.75H12.75V20.25C12.75 20.4489 12.671 20.6397 12.5303 20.7803C12.3897 20.921 12.1989 21 12 21C11.8011 21 11.6103 20.921 11.4697 20.7803C11.329 20.6397 11.25 20.4489 11.25 20.25V12.75H3.75C3.55109 12.75 3.36032 12.671 3.21967 12.5303C3.07902 12.3897 3 12.1989 3 12C3 11.8011 3.07902 11.6103 3.21967 11.4697C3.36032 11.329 3.55109 11.25 3.75 11.25H11.25V3.75C11.25 3.55109 11.329 3.36032 11.4697 3.21967C11.6103 3.07902 11.8011 3 12 3C12.1989 3 12.3897 3.07902 12.5303 3.21967C12.671 3.36032 12.75 3.55109 12.75 3.75V11.25H20.25C20.4489 11.25 20.6397 11.329 20.7803 11.4697C20.921 11.6103 21 11.8011 21 12Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 818 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.25 4.5H16.5V3.75C16.5 3.15326 16.2629 2.58097 15.841 2.15901C15.419 1.73705 14.8467 1.5 14.25 1.5H9.75C9.15326 1.5 8.58097 1.73705 8.15901 2.15901C7.73705 2.58097 7.5 3.15326 7.5 3.75V4.5H3.75C3.55109 4.5 3.36032 4.57902 3.21967 4.71967C3.07902 4.86032 3 5.05109 3 5.25C3 5.44891 3.07902 5.63968 3.21967 5.78033C3.36032 5.92098 3.55109 6 3.75 6H4.5V19.5C4.5 19.8978 4.65804 20.2794 4.93934 20.5607C5.22064 20.842 5.60218 21 6 21H18C18.3978 21 18.7794 20.842 19.0607 20.5607C19.342 20.2794 19.5 19.8978 19.5 19.5V6H20.25C20.4489 6 20.6397 5.92098 20.7803 5.78033C20.921 5.63968 21 5.44891 21 5.25C21 5.05109 20.921 4.86032 20.7803 4.71967C20.6397 4.57902 20.4489 4.5 20.25 4.5ZM9 3.75C9 3.55109 9.07902 3.36032 9.21967 3.21967C9.36032 3.07902 9.55109 3 9.75 3H14.25C14.4489 3 14.6397 3.07902 14.7803 3.21967C14.921 3.36032 15 3.55109 15 3.75V4.5H9V3.75ZM18 19.5H6V6H18V19.5ZM10.5 9.75V15.75C10.5 15.9489 10.421 16.1397 10.2803 16.2803C10.1397 16.421 9.94891 16.5 9.75 16.5C9.55109 16.5 9.36032 16.421 9.21967 16.2803C9.07902 16.1397 9 15.9489 9 15.75V9.75C9 9.55109 9.07902 9.36032 9.21967 9.21967C9.36032 9.07902 9.55109 9 9.75 9C9.94891 9 10.1397 9.07902 10.2803 9.21967C10.421 9.36032 10.5 9.55109 10.5 9.75ZM15 9.75V15.75C15 15.9489 14.921 16.1397 14.7803 16.2803C14.6397 16.421 14.4489 16.5 14.25 16.5C14.0511 16.5 13.8603 16.421 13.7197 16.2803C13.579 16.1397 13.5 15.9489 13.5 15.75V9.75C13.5 9.55109 13.579 9.36032 13.7197 9.21967C13.8603 9.07902 14.0511 9 14.25 9C14.4489 9 14.6397 9.07902 14.7803 9.21967C14.921 9.36032 15 9.55109 15 9.75Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm-8,56a8,8,0,0,1,16,0v56a8,8,0,0,1-16,0Zm8,104a12,12,0,1,1,12-12A12,12,0,0,1,128,184Z"/></svg>

After

Width:  |  Height:  |  Size: 242 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M236.8,188.09,149.35,36.22h0a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM120,104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm8,88a12,12,0,1,1,12-12A12,12,0,0,1,128,192Z"/></svg>

After

Width:  |  Height:  |  Size: 373 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm37.66,130.34a8,8,0,0,1-11.32,11.32L128,139.31l-26.34,26.35a8,8,0,0,1-11.32-11.32L116.69,128,90.34,101.66a8,8,0,0,1,11.32-11.32L128,116.69l26.34-26.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/></svg>

After

Width:  |  Height:  |  Size: 341 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/></svg>

After

Width:  |  Height:  |  Size: 284 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 107.07 37.49" fill="currentColor" role="img" aria-label="ЛОГОТИП"><g transform="scale(0.78 1)"><path d="M3.374 37.492Q1.710 37.492 0.722 36.348Q-0.266 35.204 0.098 32.708Q1.606 32.708 2.516 31.096Q3.426 29.484 3.946 25.896Q4.466 22.308 4.778 16.484Q5.090 10.660 5.454 2.236Q5.506 1.508 5.740 1.144Q5.974 0.780 7.014 0.650Q8.054 0.520 10.446 0.520L19.702 0.520L19.702 35.880Q19.702 36.400 19.468 36.608Q19.234 36.816 18.012 36.868Q16.790 36.920 13.878 36.920L13.878 5.980L10.914 5.980Q10.498 16.848 9.666 23.842Q8.834 30.836 7.326 34.164Q5.818 37.492 3.374 37.492M31.714 37.440Q29.166 37.440 27.372 36.634Q25.578 35.828 24.486 33.748Q23.394 31.668 22.874 27.976Q22.354 24.284 22.354 18.512Q22.354 11.440 23.446 7.410Q24.538 3.380 26.748 1.690Q28.958 0 32.286 0Q34.834 0 36.602 0.858Q38.370 1.716 39.462 3.770Q40.554 5.824 41.048 9.360Q41.542 12.896 41.542 18.304Q41.542 25.636 40.502 29.796Q39.462 33.956 37.304 35.698Q35.146 37.440 31.714 37.440M32.026 31.408Q33.326 31.408 33.976 30.108Q34.626 28.808 34.886 25.948Q35.146 23.088 35.146 18.512Q35.146 13.988 34.964 11.232Q34.782 8.476 34.158 7.254Q33.534 6.032 32.182 6.032Q30.934 6.032 30.258 7.358Q29.582 8.684 29.296 11.700Q29.010 14.716 29.010 19.812Q29.010 23.868 29.218 26.416Q29.426 28.964 30.076 30.186Q30.726 31.408 32.026 31.408M49.082 36.920L44.194 36.920L44.194 1.560Q44.194 1.560 44.350 1.040Q44.506 0.520 45.234 0.520L57.298 0.520Q57.298 2.860 57.246 3.900Q57.194 4.940 56.986 5.200Q56.778 5.460 56.258 5.460L50.018 5.460L50.018 35.880Q50.018 35.880 49.914 36.400Q49.810 36.920 49.082 36.920M68.218 37.440Q65.670 37.440 63.876 36.634Q62.082 35.828 60.990 33.748Q59.898 31.668 59.378 27.976Q58.858 24.284 58.858 18.512Q58.858 11.440 59.950 7.410Q61.042 3.380 63.252 1.690Q65.462 0 68.790 0Q71.338 0 73.106 0.858Q74.874 1.716 75.966 3.770Q77.058 5.824 77.552 9.360Q78.046 12.896 78.046 18.304Q78.046 25.636 77.006 29.796Q75.966 33.956 73.808 35.698Q71.650 37.440 68.218 37.440M68.530 31.408Q69.830 31.408 70.480 30.108Q71.130 28.808 71.390 25.948Q71.650 23.088 71.650 18.512Q71.650 13.988 71.468 11.232Q71.286 8.476 70.662 7.254Q70.038 6.032 68.686 6.032Q67.438 6.032 66.762 7.358Q66.086 8.684 65.800 11.700Q65.514 14.716 65.514 19.812Q65.514 23.868 65.722 26.416Q65.930 28.964 66.580 30.186Q67.230 31.408 68.530 31.408M85.950 36.920L85.950 1.040Q85.950 1.040 86.080 1.040Q86.210 1.040 86.990 1.040L90.734 1.040Q91.514 1.040 91.644 1.040Q91.774 1.040 91.774 1.040L91.774 35.880Q91.774 36.400 91.514 36.608Q91.254 36.816 90.032 36.868Q88.810 36.920 85.950 36.920M97.286 5.980L79.606 5.980L79.606 1.560Q79.606 0.520 80.646 0.520L98.326 0.520L98.326 4.940Q98.326 5.564 98.092 5.772Q97.858 5.980 97.286 5.980M100.458 36.920L100.458 1.508Q100.458 0.988 100.666 0.806Q100.874 0.624 101.992 0.572Q103.110 0.520 105.918 0.520L105.918 15.028Q105.918 16.640 105.866 18.174Q105.814 19.708 105.684 21.476Q105.554 23.244 105.294 25.688Q106.022 22.828 106.854 20.358Q107.686 17.888 108.518 15.340L112.730 1.872Q112.938 1.196 113.276 0.910Q113.614 0.624 114.628 0.572Q115.642 0.520 117.878 0.520L117.878 35.932Q117.878 36.400 117.566 36.608Q117.254 36.816 116.110 36.868Q114.966 36.920 112.418 36.920L112.418 24.024Q112.418 22.932 112.470 21.450Q112.522 19.968 112.626 18.356Q112.730 16.744 112.860 15.184Q112.990 13.624 113.146 12.376Q112.626 15.132 111.976 17.680Q111.326 20.228 110.234 23.348L105.918 35.568Q105.710 36.244 105.398 36.530Q105.086 36.816 104.020 36.868Q102.954 36.920 100.458 36.920M121.102 36.920L121.102 1.560Q121.102 1.040 121.362 0.832Q121.622 0.624 122.844 0.572Q124.066 0.520 126.926 0.520L137.274 0.520L137.274 35.880Q137.274 36.400 137.014 36.608Q136.754 36.816 135.558 36.868Q134.362 36.920 131.450 36.920L131.450 5.980L126.926 5.980L126.926 35.880Q126.926 36.400 126.666 36.608Q126.406 36.816 125.210 36.868Q124.014 36.920 121.102 36.920"/></g></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

+986
View File
@@ -0,0 +1,986 @@
(() => {
const body = document.body;
const menuButton = document.querySelector(".menu-toggle");
/*
* Icons rendered from script come from the same `assets/icons/*.svg` files
* the PHP component uses; the theme passes the markup through
* `window.vyatkaShopIcons`. Never draw an icon with a text glyph.
*/
const getIcon = (name) => {
const svg = (window.vyatkaShopIcons || {})[name];
return typeof svg === "string" ? svg : "";
};
menuButton?.addEventListener("click", () => {
const isOpen = body.classList.toggle("menu-open");
menuButton.setAttribute("aria-expanded", String(isOpen));
});
const syncQuantityControl = (input) => {
const wrapper = input?.closest(".quantity-control");
if (!wrapper) return;
const minimumValue = Number.parseInt(input.min || "1", 10);
const maximumValue = input.max ? Number.parseInt(input.max, 10) : Number.MAX_SAFE_INTEGER;
const currentValue = Number.parseInt(input.value || "1", 10);
const minimum = Number.isFinite(minimumValue) && minimumValue > 0 ? minimumValue : 1;
const maximum = Number.isFinite(maximumValue) && maximumValue > 0
? Math.max(maximumValue, minimum)
: Number.MAX_SAFE_INTEGER;
const current = Number.isFinite(currentValue) ? currentValue : minimum;
const decrease = wrapper.querySelector('[data-quantity-action="decrease"]');
const increase = wrapper.querySelector('[data-quantity-action="increase"]');
if (decrease) decrease.disabled = current <= minimum;
if (increase) increase.disabled = current >= maximum;
};
const clampQuantity = (input) => {
if (!input) return "1";
const minimumValue = Number.parseInt(input.min || "1", 10);
const maximumValue = input.max ? Number.parseInt(input.max, 10) : Number.MAX_SAFE_INTEGER;
const currentValue = Number.parseInt(input.value || "1", 10);
const minimum = Number.isFinite(minimumValue) && minimumValue > 0 ? minimumValue : 1;
const maximum = Number.isFinite(maximumValue) && maximumValue > 0
? Math.max(maximumValue, minimum)
: Number.MAX_SAFE_INTEGER;
const current = Number.isFinite(currentValue) ? currentValue : minimum;
input.value = String(Math.min(maximum, Math.max(minimum, current)));
syncQuantityControl(input);
return input.value;
};
document
.querySelectorAll('.quantity-control input[type="number"]')
.forEach((input) => syncQuantityControl(input));
document.addEventListener("input", (event) => {
const input = event.target.closest('.quantity-control input[type="number"]');
if (input) syncQuantityControl(input);
});
document.addEventListener("click", (event) => {
const button = event.target.closest("[data-quantity-action]");
if (!button) return;
const wrapper = button.closest(".quantity-control");
const input = wrapper?.querySelector('input[type="number"]');
if (!input) return;
const currentValue = Number.parseInt(input.value || "1", 10);
const minimumValue = Number.parseInt(input.min || "1", 10);
const maximumValue = input.max ? Number.parseInt(input.max, 10) : Number.MAX_SAFE_INTEGER;
const current = Number.isFinite(currentValue) ? currentValue : 1;
const minimum = Number.isFinite(minimumValue) && minimumValue > 0 ? minimumValue : 1;
const maximum = Number.isFinite(maximumValue) && maximumValue > 0
? Math.max(maximumValue, minimum)
: Number.MAX_SAFE_INTEGER;
const delta = button.dataset.quantityAction === "increase" ? 1 : -1;
input.value = String(Math.min(maximum, Math.max(minimum, current + delta)));
syncQuantityControl(input);
input.dispatchEvent(new Event("change", { bubbles: true }));
});
const getToastStack = () => {
let stack = document.querySelector(".shop-toast-stack");
if (stack) return stack;
stack = document.createElement("div");
stack.className = "shop-toast-stack";
stack.setAttribute("aria-live", "polite");
stack.setAttribute("aria-atomic", "false");
document.body.append(stack);
return stack;
};
const showToast = (message, type = "success") => {
const toast = document.createElement("div");
toast.className = `shop-toast shop-toast-${type}`;
toast.setAttribute("role", type === "error" ? "alert" : "status");
const icon = document.createElement("span");
icon.className = "shop-toast-icon";
icon.setAttribute("aria-hidden", "true");
icon.innerHTML = getIcon(type === "success" ? "check-circle-fill" : "x-circle-fill");
const copy = document.createElement("span");
copy.className = "shop-toast-copy";
copy.textContent = message;
const dismiss = document.createElement("button");
dismiss.className = "shop-toast-dismiss";
dismiss.type = "button";
dismiss.setAttribute("aria-label", "Закрыть уведомление");
dismiss.innerHTML = getIcon("x");
let fadeTimer;
let removeTimer;
const removeToast = (duration = 300) => {
window.clearTimeout(fadeTimer);
window.clearTimeout(removeTimer);
toast.style.setProperty("--toast-fade-duration", `${duration}ms`);
toast.classList.add("is-leaving");
window.setTimeout(() => toast.remove(), duration);
};
dismiss.addEventListener("click", () => removeToast());
toast.append(icon, copy, dismiss);
getToastStack().append(toast);
window.requestAnimationFrame(() => toast.classList.add("is-visible"));
fadeTimer = window.setTimeout(() => {
toast.style.setProperty("--toast-fade-duration", "1000ms");
toast.classList.add("is-leaving");
}, 6000);
removeTimer = window.setTimeout(() => toast.remove(), 7000);
};
const cartConfig = window.vyatkaShopCart || {
ajaxUrl: "/wp-admin/admin-ajax.php",
nonce: "",
};
let cartMutationQueue = Promise.resolve();
const sendCartMutation = async (payload) => {
const cartPage = document.querySelector(".cart-page");
cartPage?.classList.add("is-updating");
cartPage?.setAttribute("aria-busy", "true");
const requestBody = new URLSearchParams({
action: "vyatka_cart_mutation",
nonce: cartConfig.nonce,
...payload,
});
try {
const response = await fetch(cartConfig.ajaxUrl, {
method: "POST",
body: requestBody,
credentials: "same-origin",
headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
},
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result?.data?.message || "Не удалось обновить корзину");
}
const currentCartPage = document.querySelector(".cart-page");
if (currentCartPage && result.data?.html) {
currentCartPage.outerHTML = result.data.html;
}
return result.data;
} finally {
const currentCartPage = document.querySelector(".cart-page");
currentCartPage?.classList.remove("is-updating");
currentCartPage?.removeAttribute("aria-busy");
}
};
const queueCartMutation = (payload, successMessage = "") => {
cartMutationQueue = cartMutationQueue
.catch(() => {})
.then(() => sendCartMutation(payload))
.then((result) => {
if (successMessage) showToast(successMessage);
return result;
})
.catch((error) => {
showToast(error.message || "Нет соединения с сервером", "error");
return null;
});
return cartMutationQueue;
};
document.addEventListener("change", (event) => {
const input = event.target.closest('.quantity-control input[type="number"]');
if (!input) return;
const quantity = clampQuantity(input);
const cartItem = input.closest("[data-cart-item-key]");
if (!cartItem) return;
queueCartMutation({
operation: "update",
cart_item_key: cartItem.dataset.cartItemKey,
quantity,
});
});
document.addEventListener("focusout", (event) => {
const input = event.target.closest('.quantity-control input[type="number"]');
if (input) clampQuantity(input);
});
document.addEventListener("click", (event) => {
const remove = event.target.closest(".cart-item-remove");
if (!remove) return;
const cartItem = remove.closest("[data-cart-item-key]");
if (!cartItem) return;
event.preventDefault();
queueCartMutation({
operation: "remove",
cart_item_key: cartItem.dataset.cartItemKey,
});
});
document.addEventListener("submit", async (event) => {
const form = event.target.closest("form[data-ajax-add-to-cart]");
if (!form) return;
event.preventDefault();
if (form.dataset.addingToCart === "true") return;
const button = event.submitter || form.querySelector('[name="add-to-cart"]');
const productId = button?.value || form.querySelector('[name="add-to-cart"]')?.value;
if (!productId) {
showToast("Не удалось определить товар", "error");
return;
}
const quantity = clampQuantity(form.querySelector('input[name="quantity"]'));
form.dataset.addingToCart = "true";
if (button) {
button.disabled = true;
button.setAttribute("aria-busy", "true");
}
try {
await queueCartMutation(
{
operation: "add",
product_id: productId,
quantity,
},
"Товар успешно добавлен в корзину",
);
} finally {
form.dataset.addingToCart = "false";
if (button) {
button.disabled = false;
button.removeAttribute("aria-busy");
}
}
});
const productFilterForm = document.querySelector("[data-product-filters]");
const productCards = Array.from(document.querySelectorAll("[data-filter-results] [data-product-card]"));
const filterEmpty = document.querySelector("[data-filter-empty]");
const applyProductFilters = () => {
if (!productFilterForm || !productCards.length) return;
const selectedFeatures = Array.from(
productFilterForm.querySelectorAll('input[name="feature[]"]:checked')
)
.map((input) => input.value)
.filter((value) => value && value !== "all");
const selectedCollection = productFilterForm.querySelector('input[name="collection"]:checked')?.value || "";
const priceFilter = productFilterForm.querySelector("[data-price-filter]");
const priceActive = priceFilter?.dataset.priceActive === "true";
const minimumPrice = Number.parseFloat(priceFilter?.querySelector('[data-price-field="min"]')?.value || "0");
const maximumPrice = Number.parseFloat(priceFilter?.querySelector('[data-price-field="max"]')?.value || String(Number.MAX_SAFE_INTEGER));
let visibleProducts = 0;
productCards.forEach((card) => {
const features = (card.dataset.features || "").split(/\s+/).filter(Boolean);
const collectionMatches = !selectedCollection || card.dataset.collection === selectedCollection;
const featureMatches = !selectedFeatures.length || selectedFeatures.some((feature) => features.includes(feature));
const price = Number.parseFloat(card.dataset.price || "0");
const priceMatches = !priceActive || (price >= minimumPrice && price <= maximumPrice);
const visible = collectionMatches && featureMatches && priceMatches;
card.hidden = !visible;
if (visible) visibleProducts += 1;
});
if (filterEmpty) filterEmpty.hidden = visibleProducts > 0;
};
const updateProductFilterUrl = () => {
if (!productFilterForm) return;
const url = new URL(productFilterForm.action || window.location.href, window.location.href);
url.searchParams.delete("feature[]");
url.searchParams.delete("collection");
productFilterForm.querySelectorAll('input[name="feature[]"]:checked').forEach((input) => {
url.searchParams.append("feature[]", input.value);
});
const selectedCollection = productFilterForm.querySelector('input[name="collection"]:checked')?.value || "";
if (selectedCollection) url.searchParams.set("collection", selectedCollection);
const priceFilter = productFilterForm.querySelector("[data-price-filter]");
if (priceFilter?.dataset.priceActive === "true") {
url.searchParams.set("filter_min_price", priceFilter.querySelector('[data-price-field="min"]').value);
url.searchParams.set("filter_max_price", priceFilter.querySelector('[data-price-field="max"]').value);
} else {
url.searchParams.delete("filter_min_price");
url.searchParams.delete("filter_max_price");
}
window.history.replaceState({}, "", url);
};
document.querySelectorAll("[data-price-filter]").forEach((filter) => {
const minimumInput = filter.querySelector('[data-price-field="min"]');
const maximumInput = filter.querySelector('[data-price-field="max"]');
const minimumRange = filter.querySelector('[data-price-range="min"]');
const maximumRange = filter.querySelector('[data-price-range="max"]');
const range = filter.querySelector(".price-range");
if (!minimumInput || !maximumInput || !minimumRange || !maximumRange || !range) return;
const lowerBound = Number.parseInt(minimumRange.min, 10);
const upperBound = Number.parseInt(minimumRange.max, 10);
const gap = Number.parseInt(minimumRange.step || "1", 10);
const clamp = (value) => Math.min(upperBound, Math.max(lowerBound, value));
const syncRangeTrack = () => {
const min = Number.parseInt(minimumRange.value, 10);
const max = Number.parseInt(maximumRange.value, 10);
const size = upperBound - lowerBound;
range.style.setProperty("--range-min", `${((min - lowerBound) / size) * 100}%`);
range.style.setProperty("--range-max", `${((max - lowerBound) / size) * 100}%`);
minimumInput.value = String(min);
maximumInput.value = String(max);
};
minimumRange.addEventListener("input", () => {
filter.dataset.priceActive = "true";
minimumRange.value = String(Math.min(clamp(Number.parseInt(minimumRange.value, 10)), Number.parseInt(maximumRange.value, 10) - gap));
syncRangeTrack();
applyProductFilters();
});
maximumRange.addEventListener("input", () => {
filter.dataset.priceActive = "true";
maximumRange.value = String(Math.max(clamp(Number.parseInt(maximumRange.value, 10)), Number.parseInt(minimumRange.value, 10) + gap));
syncRangeTrack();
applyProductFilters();
});
const syncFields = () => {
let min = clamp(Number.parseInt(minimumInput.value || String(lowerBound), 10));
let max = clamp(Number.parseInt(maximumInput.value || String(upperBound), 10));
if (min > max - gap) min = max - gap;
if (max < min + gap) max = min + gap;
minimumRange.value = String(min);
maximumRange.value = String(max);
syncRangeTrack();
};
[minimumInput, maximumInput].forEach((input) => {
input.addEventListener("input", () => {
filter.dataset.priceActive = "true";
syncFields();
applyProductFilters();
});
input.addEventListener("change", () => {
updateProductFilterUrl();
productFilterForm?.requestSubmit();
});
});
minimumRange.addEventListener("change", () => {
updateProductFilterUrl();
productFilterForm?.requestSubmit();
});
maximumRange.addEventListener("change", () => {
updateProductFilterUrl();
productFilterForm?.requestSubmit();
});
syncFields();
});
/*
* Below the mobile breakpoint the filter sidebar becomes the sheet from the
* design. It stays the same <form>, so filtering behaves identically — only
* its presentation changes.
*/
const filterPanel = document.querySelector(".filters");
const filterOpener = document.querySelector("[data-filter-panel-open]");
const setFilterPanel = (open) => {
if (!filterPanel) return;
filterPanel.classList.toggle("is-open", open);
body.classList.toggle("filters-open-lock", open);
filterOpener?.setAttribute("aria-expanded", String(open));
if (open) {
filterPanel.querySelector("[data-filter-panel-close]")?.focus();
} else {
filterOpener?.focus();
}
};
filterOpener?.addEventListener("click", () => setFilterPanel(true));
document.querySelectorAll("[data-filter-panel-close]").forEach((button) => {
button.addEventListener("click", () => setFilterPanel(false));
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && filterPanel?.classList.contains("is-open")) {
setFilterPanel(false);
}
});
// Leaving the breakpoint must not strand the page in the locked state.
window.matchMedia("(min-width: 768px)").addEventListener("change", (event) => {
if (event.matches) setFilterPanel(false);
});
document.querySelectorAll("[data-filter-toggle]").forEach((button) => {
button.addEventListener("click", () => {
const group = button.closest(".filter-group");
const content = group?.querySelector(".filter-content");
if (!group || !content) return;
window.clearTimeout(content.filterAnimationTimer);
if (content.filterAnimation) {
content.filterAnimation.cancel();
}
const collapse = !group.classList.contains("filter-collapsed");
const expandedHeight = content.scrollHeight;
const startHeight = collapse ? expandedHeight : 0;
const endHeight = collapse ? 0 : expandedHeight;
content.classList.add("is-animating");
content.hidden = false;
content.style.height = `${startHeight}px`;
content.style.opacity = collapse ? "1" : "0";
if (collapse) {
group.classList.add("filter-collapsed");
} else {
group.classList.remove("filter-collapsed");
}
button.setAttribute("aria-expanded", String(!collapse));
const animation = content.animate(
[
{ height: `${startHeight}px`, opacity: collapse ? 1 : 0 },
{ height: `${endHeight}px`, opacity: collapse ? 0 : 1 },
],
{
duration: 180,
easing: "ease-in-out",
fill: "forwards",
},
);
content.filterAnimation = animation;
const finishAnimation = () => {
if (content.filterAnimation !== animation) return;
window.clearTimeout(content.filterAnimationTimer);
content.classList.remove("is-animating");
content.hidden = collapse;
content.style.height = "";
content.style.opacity = "";
content.filterAnimation = null;
};
animation.finished.then(finishAnimation).catch(() => {});
content.filterAnimationTimer = window.setTimeout(finishAnimation, 240);
});
});
document.querySelectorAll("[data-auto-submit]").forEach((control) => {
control.addEventListener("change", () => control.form?.requestSubmit());
});
document.querySelectorAll("[data-facet-filter]").forEach((control) => {
control.addEventListener("change", () => {
applyProductFilters();
updateProductFilterUrl();
productFilterForm?.requestSubmit();
});
});
document.querySelectorAll("[data-filter-more]").forEach((button) => {
button.addEventListener("click", () => {
const content = button.closest(".filter-content");
const extras = content?.querySelectorAll("[data-filter-extra]");
if (!extras?.length) return;
const expanded = button.getAttribute("aria-expanded") !== "true";
extras.forEach((option) => {
option.hidden = !expanded;
});
button.setAttribute("aria-expanded", String(expanded));
button.textContent = expanded ? "Скрыть" : "Показать все";
});
});
const customSelects = [];
document.querySelectorAll("[data-custom-select], .woocommerce-ordering select").forEach((select) => {
if (select.dataset.customSelectReady === "true") return;
const wrapper = document.createElement("div");
const trigger = document.createElement("button");
const value = document.createElement("span");
const menu = document.createElement("div");
wrapper.className = "custom-select";
trigger.className = "custom-select-trigger";
trigger.type = "button";
trigger.setAttribute("aria-haspopup", "listbox");
trigger.setAttribute("aria-expanded", "false");
value.className = "custom-select-value";
menu.className = "custom-select-menu";
menu.setAttribute("role", "listbox");
menu.hidden = true;
select.parentNode.insertBefore(wrapper, select);
wrapper.append(select, trigger, menu);
select.classList.add("custom-select-native");
select.dataset.customSelectReady = "true";
trigger.append(value);
const close = () => {
wrapper.classList.remove("is-open");
trigger.setAttribute("aria-expanded", "false");
menu.hidden = true;
};
const sync = () => {
const selectedOption = select.options[select.selectedIndex];
value.textContent = selectedOption?.textContent || "";
menu.querySelectorAll(".custom-select-option").forEach((option) => {
const selected = option.dataset.value === select.value;
option.classList.toggle("is-selected", selected);
option.setAttribute("aria-selected", String(selected));
});
};
Array.from(select.options).forEach((sourceOption) => {
const option = document.createElement("button");
option.className = "custom-select-option";
option.type = "button";
option.setAttribute("role", "option");
option.dataset.value = sourceOption.value;
option.textContent = sourceOption.textContent;
option.addEventListener("click", () => {
select.value = sourceOption.value;
sync();
close();
select.dispatchEvent(new Event("change", { bubbles: true }));
});
menu.append(option);
});
trigger.addEventListener("click", () => {
const open = !wrapper.classList.contains("is-open");
customSelects.forEach((item) => {
if (item.wrapper !== wrapper) item.close();
});
wrapper.classList.toggle("is-open", open);
trigger.setAttribute("aria-expanded", String(open));
menu.hidden = !open;
});
trigger.addEventListener("keydown", (event) => {
if (event.key === "Escape") close();
if (event.key === "ArrowDown" && menu.hidden) {
event.preventDefault();
trigger.click();
menu.querySelector(".is-selected, .custom-select-option")?.focus();
}
});
select.addEventListener("change", sync);
if (select.closest(".woocommerce-ordering")) {
select.addEventListener("change", () => select.form?.requestSubmit());
}
customSelects.push({ wrapper, close });
sync();
});
document.addEventListener("click", (event) => {
customSelects.forEach((item) => {
if (!item.wrapper.contains(event.target)) item.close();
});
});
document.querySelectorAll(".gallery-favorite").forEach((button) => {
button.addEventListener("click", () => {
const active = button.classList.toggle("is-active");
button.setAttribute("aria-pressed", String(active));
});
});
document.querySelectorAll(
".woocommerce-message, .woocommerce-info, .woocommerce-error, .woocommerce-warning"
).forEach((notice) => {
if (notice.querySelector(".woocommerce-notice-dismiss")) return;
const dismiss = document.createElement("button");
dismiss.className = "woocommerce-notice-dismiss";
dismiss.type = "button";
dismiss.setAttribute("aria-label", "Закрыть сообщение");
dismiss.addEventListener("click", () => {
notice.classList.add("woocommerce-notice-closing");
window.setTimeout(() => notice.remove(), 190);
});
notice.append(dismiss);
});
document.querySelectorAll(".tab-list a[data-tab]").forEach((link) => {
link.addEventListener("click", (event) => {
event.preventDefault();
const tabs = link.closest(".product-tabs");
tabs?.querySelectorAll(".tab-list li").forEach((item) => item.classList.remove("active"));
tabs?.querySelectorAll('.tab-list [role="tab"]').forEach((tab) => {
tab.setAttribute("aria-selected", String(tab === link));
});
link.parentElement?.classList.add("active");
tabs?.querySelectorAll("[data-tab-panel]").forEach((panel) => {
panel.hidden = panel.dataset.tabPanel !== link.dataset.tab;
});
});
});
document.querySelectorAll("[data-product-gallery]").forEach((galleryImages) => {
const gallery = galleryImages.closest(".product-gallery");
const slides = Array.from(galleryImages.querySelectorAll("[data-gallery-slide]"));
const dots = Array.from(gallery?.querySelectorAll("[data-gallery-dot]") || []);
const previous = gallery?.querySelector(".gallery-arrow-prev");
const next = gallery?.querySelector(".gallery-arrow-next");
let activeIndex = 0;
const render = () => {
slides.forEach((slide, index) => {
slide.hidden = index !== activeIndex;
});
dots.forEach((dot, index) => {
dot.classList.toggle("active", index === activeIndex);
});
};
previous?.addEventListener("click", () => {
activeIndex = (activeIndex - 1 + slides.length) % slides.length;
render();
});
next?.addEventListener("click", () => {
activeIndex = (activeIndex + 1) % slides.length;
render();
});
render();
});
document.querySelectorAll(".delivery-switch").forEach((deliverySwitch) => {
const form = deliverySwitch.closest("form");
const panels = Array.from(form?.querySelectorAll("[data-delivery-panel]") || []);
const radios = Array.from(deliverySwitch.querySelectorAll('input[name="delivery_type"]'));
const syncDeliveryPanels = () => {
const selected = radios.find((radio) => radio.checked)?.value || "pickup";
deliverySwitch.classList.toggle("is-delivery", selected === "delivery");
panels.forEach((panel) => {
const active = panel.dataset.deliveryPanel === selected;
panel.hidden = !active;
panel.querySelectorAll("input, select, textarea").forEach((control) => {
control.disabled = !active;
if (control.hasAttribute("data-delivery-required")) control.required = active;
});
});
};
radios.forEach((radio) => radio.addEventListener("change", syncDeliveryPanels));
syncDeliveryPanels();
});
/*
* Carousels.
*
* The track holds every item in one row and is translated by a page at a
* time. Instead of rewinding when it runs out, the items that just left the
* view are moved to the other end of the track while the transition is off,
* so the loop is endless and never snaps back to the start.
*
* Without JS the container stays the grid it was authored as and shows
* everything, so nothing depends on this running.
*/
document.querySelectorAll("[data-carousel]").forEach((carousel) => {
const controls = document.querySelector(`[data-carousel-controls="${carousel.id}"]`);
const previous = controls?.querySelector("[data-carousel-prev]");
const next = controls?.querySelector("[data-carousel-next]");
const cards = Array.from(carousel.children);
if (cards.length === 0) return;
const track = document.createElement("div");
track.className = "carousel-track";
cards.forEach((card) => track.append(card));
carousel.textContent = "";
carousel.append(track);
carousel.classList.add("is-carousel");
const perView = () => {
if (window.innerWidth <= 639) return 1;
if (window.innerWidth <= 1023) return 2;
if (carousel.classList.contains("news-grid")) return 3;
return window.innerWidth <= 1439 ? 3 : 4;
};
const items = () => Array.from(track.children);
const canScroll = () => items().length > perView();
const applyPerView = () => {
track.style.setProperty("--carousel-per-view", String(perView()));
const idle = !canScroll();
if (previous) previous.disabled = idle;
if (next) next.disabled = idle;
};
// Distance of one card plus the gap between cards.
const stride = () => {
const first = track.children[0];
if (!first) return 0;
const gap = Number.parseFloat(getComputedStyle(track).gap) || 0;
return first.getBoundingClientRect().width + gap;
};
// Re-order without animating, so the seam is never visible.
const settle = (mutate) => {
track.classList.add("is-settling");
mutate();
track.style.transform = "translateX(0px)";
void track.offsetWidth;
track.classList.remove("is-settling");
};
let busy = false;
const page = (direction) => {
if (busy || !canScroll()) return;
const count = Math.min(perView(), items().length - 1);
const distance = stride() * count;
if (distance <= 0) return;
busy = true;
const finish = () => {
track.removeEventListener("transitionend", finish);
window.clearTimeout(guard);
if (direction > 0) {
settle(() => {
for (let i = 0; i < count; i += 1) track.append(track.children[0]);
});
}
busy = false;
};
// A transition that never fires must not lock the control for good.
const guard = window.setTimeout(finish, 800);
track.addEventListener("transitionend", finish);
if (direction > 0) {
track.style.transform = `translateX(${-distance}px)`;
return;
}
// Going back: bring the tail to the front first, jump there without
// animating, then slide into place.
settle(() => {
for (let i = 0; i < count; i += 1) {
track.prepend(track.children[track.children.length - 1]);
}
track.style.transform = `translateX(${-distance}px)`;
});
void track.offsetWidth;
track.style.transform = "translateX(0px)";
};
previous?.addEventListener("click", () => page(-1));
next?.addEventListener("click", () => page(1));
window.addEventListener("resize", () => {
applyPerView();
settle(() => {});
});
applyPerView();
});
/*
* Checkout validation.
*
* The design shows problems on the field itself — a red edge and a red
* caption under it — not as one banner listing everything. So the fields are
* validated here first, and any error WooCommerce still returns is mapped
* back onto the field it belongs to.
*/
const checkoutForm = document.querySelector("form.checkout");
if (checkoutForm) {
const MESSAGES = {
valueMissing: "Заполните это поле",
typeMismatch: "Введены не верные данные",
patternMismatch: "Введены не верные данные",
tooShort: "Введены не верные данные",
};
const wrapperOf = (input) => input.closest(".checkout-input");
const setFieldError = (input, message) => {
const wrapper = wrapperOf(input);
if (!wrapper) return;
const slot = wrapper.querySelector("[data-field-error]");
wrapper.classList.toggle("is-error", Boolean(message));
input.setAttribute("aria-invalid", message ? "true" : "false");
if (slot) slot.textContent = message || "";
};
const messageFor = (input) => {
if (input.validity.valid) return "";
const key = Object.keys(MESSAGES).find((k) => input.validity[k]);
return MESSAGES[key] || "Введены не верные данные";
};
const validatableFields = () =>
Array.from(checkoutForm.querySelectorAll(".checkout-input input")).filter(
(input) => !input.disabled && input.willValidate
);
const validateField = (input) => {
const message = messageFor(input);
setFieldError(input, message);
return !message;
};
// Re-check as soon as the visitor leaves a field, and clear a shown error
// as soon as they start fixing it.
checkoutForm.addEventListener(
"blur",
(event) => {
const input = event.target.closest(".checkout-input input");
if (input) validateField(input);
},
true
);
checkoutForm.addEventListener("input", (event) => {
const input = event.target.closest(".checkout-input input");
if (input && wrapperOf(input)?.classList.contains("is-error")) validateField(input);
});
checkoutForm.addEventListener(
"submit",
(event) => {
const fields = validatableFields();
const invalid = fields.filter((input) => !validateField(input));
if (!invalid.length) return;
event.preventDefault();
event.stopImmediatePropagation();
invalid[0].focus();
invalid[0].scrollIntoView({ block: "center", behavior: "smooth" });
},
true
);
/*
* WooCommerce renders its own notice group when the server rejects the
* order. Each message starts with the field label, so it can be attached
* to that field; the banner is hidden only when every message found a home.
*/
const mapServerErrors = () => {
const group = document.querySelector(".woocommerce-NoticeGroup-checkout, .checkout .woocommerce-error");
if (!group) return;
const items = group.matches("ul") ? Array.from(group.querySelectorAll("li")) : Array.from(group.querySelectorAll("li, p"));
const texts = (items.length ? items : [group]).map((n) => n.textContent.trim()).filter(Boolean);
if (!texts.length) return;
let mappedAll = texts.length > 0;
texts.forEach((text) => {
const field = validatableFields().find((input) => {
const label = wrapperOf(input)?.querySelector(".field-label")?.textContent.trim();
return label && text.toLowerCase().startsWith(label.toLowerCase());
});
if (field) {
setFieldError(field, /обязательное/i.test(text) ? MESSAGES.valueMissing : MESSAGES.typeMismatch);
} else {
mappedAll = false;
}
});
if (mappedAll) {
document
.querySelectorAll(".woocommerce-NoticeGroup-checkout, .checkout .woocommerce-error")
.forEach((el) => el.classList.add("is-field-level"));
}
};
/*
* The consent box gates the order button: without it ticked there is no
* lawful basis to process the order, so the button is disabled rather
* than letting the submit fail somewhere later.
*/
const consent = checkoutForm.querySelector("[data-consent]");
const placeOrder = checkoutForm.querySelector("[data-consent-gated]");
if (consent && placeOrder) {
const syncConsent = () => {
placeOrder.disabled = !consent.checked;
placeOrder.setAttribute("aria-disabled", String(!consent.checked));
};
consent.addEventListener("change", syncConsent);
syncConsent();
}
mapServerErrors();
// WooCommerce replaces the notice group over AJAX rather than reloading.
document.body.addEventListener("checkout_error", () => window.setTimeout(mapServerErrors, 50));
new MutationObserver(() => mapServerErrors()).observe(checkoutForm, { childList: true, subtree: true });
}
/*
* Quick filters mirror the collection radio group in the sidebar. Clicking a
* chip selects the matching radio and submits the same form, so the two
* controls can never disagree.
*/
document.querySelectorAll("[data-quick-filter]").forEach((chip) => {
chip.addEventListener("click", () => {
const value = chip.dataset.quickFilter;
const radio = document.querySelector(`input[name="collection"][value="${CSS.escape(value)}"]`);
if (!radio) return;
radio.checked = true;
radio.dispatchEvent(new Event("change", { bubbles: true }));
});
});
/*
* Subcategory row. The next button pages the scroller by a whole card and
* hides itself once there is nothing further to reveal.
*/
document.querySelectorAll("[data-subcategories]").forEach((row) => {
const track = row.querySelector("[data-subcategory-track]");
const next = row.querySelector("[data-subcategory-next]");
if (!track || !next) return;
const step = () => {
const card = track.querySelector("li");
const gap = Number.parseFloat(getComputedStyle(track).gap) || 16;
return card ? card.getBoundingClientRect().width + gap : 232;
};
const sync = () => {
const atEnd = track.scrollLeft + track.clientWidth >= track.scrollWidth - 2;
const fits = track.scrollWidth <= track.clientWidth + 2;
next.hidden = fits;
next.querySelector(".icon")?.classList.toggle("icon-flip", atEnd && !fits);
};
next.addEventListener("click", () => {
const atEnd = track.scrollLeft + track.clientWidth >= track.scrollWidth - 2;
track.scrollBy({ left: atEnd ? -track.scrollWidth : step(), behavior: "smooth" });
});
track.addEventListener("scroll", sync, { passive: true });
window.addEventListener("resize", sync);
sync();
});
applyProductFilters();
})();
+26
View File
@@ -0,0 +1,26 @@
<?php
/**
* Site footer.
*
* @package VyatkaShop
*/
?>
</main>
<footer class="site-footer" id="contacts">
<div class="container">
<div class="footer-top">
<a class="brand" href="<?php echo esc_url( home_url( '/' ) ); ?>" aria-label="<?php esc_attr_e( 'На главную', 'vyatka-shop' ); ?>"><?php vyatka_shop_brand_mark(); ?></a>
</div>
<div class="footer-bottom">
<span>© 2025, «Название компании»</span>
<span>
<a href="<?php echo esc_url( get_privacy_policy_url() ?: '#' ); ?>"><?php esc_html_e( 'Политика конфиденциальности', 'vyatka-shop' ); ?></a>
<a href="<?php echo esc_url( home_url( '/requisites/' ) ); ?>"><?php esc_html_e( 'Реквизиты', 'vyatka-shop' ); ?></a>
</span>
<span><?php esc_html_e( 'Разработано в Вятка IT', 'vyatka-shop' ); ?></span>
</div>
</div>
</footer>
<?php wp_footer(); ?>
</body>
</html>
+128
View File
@@ -0,0 +1,128 @@
<?php
/**
* Home page.
*
* @package VyatkaShop
*/
get_header();
$products = function_exists( 'wc_get_products' )
? wc_get_products(
array(
'limit' => 8,
'status' => 'publish',
'orderby' => 'date',
'order' => 'DESC',
)
)
: array();
$categories = taxonomy_exists( 'product_cat' )
? get_terms(
array(
'taxonomy' => 'product_cat',
'hide_empty' => false,
'number' => 4,
'parent' => 0,
)
)
: array();
$news = get_posts(
array(
'numberposts' => 6,
'post_status' => 'publish',
)
);
?>
<section class="hero">
<div class="container">
<div class="hero-copy">
<div class="hero-main">
<div class="hero-title-group">
<h1><?php echo esc_html( get_theme_mod( 'vyatka_hero_title', 'Строим тёплые деревянные дома' ) ); ?></h1>
<p><?php echo esc_html( get_theme_mod( 'vyatka_hero_text', 'Учитывая ключевые сценарии поведения, существующая теория в значительной степени обусловливает важность дальнейших направлений развития.' ) ); ?></p>
</div>
<div class="hero-features">
<div class="hero-feature">
<?php vyatka_shop_icon( 'feature', array( 'class' => 'feature-mark' ) ); ?>
<span>Построим дом по<br>вашему дизайн-проекту</span>
</div>
<div class="hero-feature">
<?php vyatka_shop_icon( 'feature', array( 'class' => 'feature-mark' ) ); ?>
<span>Уникальный дизайн<br>с удобной планировкой</span>
</div>
<div class="hero-feature">
<?php vyatka_shop_icon( 'feature', array( 'class' => 'feature-mark' ) ); ?>
<span>Подберем мебель<br>и подключим технику</span>
</div>
</div>
</div>
<a class="button hero-button" href="<?php echo esc_url( home_url( '/catalog/' ) ); ?>"><?php esc_html_e( 'Подробнее', 'vyatka-shop' ); ?></a>
</div>
</div>
</section>
<section class="section products-section">
<div class="container">
<div class="section-head">
<h2 class="section-title"><?php esc_html_e( 'Товары', 'vyatka-shop' ); ?></h2>
<div class="slider-controls" data-carousel-controls="home-products">
<button type="button" data-carousel-prev aria-label="<?php esc_attr_e( 'Предыдущие товары', 'vyatka-shop' ); ?>"><?php vyatka_shop_icon( 'arrow-right', array( 'class' => 'icon-flip icon-20' ) ); ?></button>
<button type="button" data-carousel-next aria-label="<?php esc_attr_e( 'Следующие товары', 'vyatka-shop' ); ?>"><?php vyatka_shop_icon( 'arrow-right', array( 'class' => 'icon-20' ) ); ?></button>
</div>
</div>
<div class="product-grid" id="home-products" data-carousel>
<?php
foreach ( $products as $product_item ) {
$GLOBALS['product'] = $product_item;
get_template_part( 'template-parts/product-card' );
}
wp_reset_postdata();
?>
</div>
</div>
</section>
<section class="section categories-section">
<div class="container">
<div class="section-head">
<h2 class="section-title"><?php esc_html_e( 'Каталог', 'vyatka-shop' ); ?></h2>
</div>
<div class="category-grid">
<?php if ( ! is_wp_error( $categories ) ) : ?>
<?php foreach ( $categories as $category ) : ?>
<a class="category-card" href="<?php echo esc_url( get_term_link( $category ) ); ?>">
<span class="category-card-copy">
<h3><?php esc_html_e( 'Название', 'vyatka-shop' ); ?></h3>
<p><?php esc_html_e( 'Описание категории', 'vyatka-shop' ); ?></p>
<span class="text-link"><?php esc_html_e( 'Перейти', 'vyatka-shop' ); ?><?php vyatka_shop_icon( 'arrow-right-fill', array( 'class' => 'icon-20' ) ); ?></span>
</span>
<span class="category-card-media" aria-hidden="true"></span>
</a>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</section>
<section class="section news-section">
<div class="container">
<div class="section-head">
<h2 class="section-title"><?php esc_html_e( 'Новости', 'vyatka-shop' ); ?></h2>
<div class="slider-controls" data-carousel-controls="home-news">
<button type="button" data-carousel-prev aria-label="<?php esc_attr_e( 'Предыдущие новости', 'vyatka-shop' ); ?>"><?php vyatka_shop_icon( 'arrow-right', array( 'class' => 'icon-flip icon-20' ) ); ?></button>
<button type="button" data-carousel-next aria-label="<?php esc_attr_e( 'Следующие новости', 'vyatka-shop' ); ?>"><?php vyatka_shop_icon( 'arrow-right', array( 'class' => 'icon-20' ) ); ?></button>
</div>
</div>
<div class="news-grid" id="home-news" data-carousel>
<?php foreach ( $news as $post_item ) : ?>
<?php vyatka_shop_news_card( $post_item ); ?>
<?php endforeach; ?>
</div>
<a class="all-news-link" href="<?php echo esc_url( get_post_type_archive_link( 'post' ) ?: home_url( '/blog/' ) ); ?>"><?php esc_html_e( 'Все новости', 'vyatka-shop' ); ?></a>
</div>
</section>
<?php
get_footer();
+29
View File
@@ -0,0 +1,29 @@
<?php
/**
* Theme bootstrap.
*
* Behaviour lives in `inc/`, one module per concern. This file only defines
* the version constant and loads those modules in dependency order.
*
* @package VyatkaShop
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'VYATKA_SHOP_VERSION', (string) wp_get_theme()->get( 'Version' ) );
$vyatka_shop_modules = array(
'icons', // Inline SVG component; assets.php shares it with the script.
'setup', // Theme supports, menus, customizer.
'urls', // Host-agnostic URL handling for the showcase deployment.
'template-tags', // Shared render helpers.
'assets', // Stylesheet layers and scripts.
'woocommerce', // Catalog, checkout and the in-place cart endpoint.
);
foreach ( $vyatka_shop_modules as $vyatka_shop_module ) {
require_once get_template_directory() . '/inc/' . $vyatka_shop_module . '.php';
}
unset( $vyatka_shop_modules, $vyatka_shop_module );
+53
View File
@@ -0,0 +1,53 @@
<?php
/**
* Site header.
*
* @package VyatkaShop
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
?>
<!doctype html>
<html <?php language_attributes(); ?>>
<head>
<meta charset="<?php bloginfo( 'charset' ); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<?php wp_head(); ?>
</head>
<body <?php body_class(); ?>>
<?php wp_body_open(); ?>
<header class="site-header">
<div class="container header-inner">
<?php if ( has_custom_logo() ) : ?>
<div class="brand"><?php the_custom_logo(); ?></div>
<?php else : ?>
<a class="brand" href="<?php echo esc_url( home_url( '/' ) ); ?>" aria-label="<?php esc_attr_e( 'На главную', 'vyatka-shop' ); ?>">
<?php vyatka_shop_brand_mark(); ?>
</a>
<?php endif; ?>
<nav class="main-nav" id="site-navigation" aria-label="<?php esc_attr_e( 'Главная навигация', 'vyatka-shop' ); ?>">
<?php
wp_nav_menu(
array(
'theme_location' => 'primary',
'container' => false,
'fallback_cb' => 'vyatka_shop_fallback_menu',
)
);
?>
</nav>
<button class="menu-toggle" type="button" aria-expanded="false" aria-controls="site-navigation">
<?php vyatka_shop_icon( 'list' ); ?>
<span class="screen-reader-text"><?php esc_html_e( 'Открыть меню', 'vyatka-shop' ); ?></span>
</button>
<a class="cart-link" href="<?php echo esc_url( vyatka_shop_cart_url() ); ?>" aria-label="<?php esc_attr_e( 'Корзина', 'vyatka-shop' ); ?>">
<?php vyatka_shop_icon( 'cart' ); ?>
</a>
</div>
</header>
<main class="site-main">
+30
View File
@@ -0,0 +1,30 @@
<?php
/**
* Blog index.
*
* @package VyatkaShop
*/
get_header();
?>
<div class="container blog-page">
<?php vyatka_shop_breadcrumbs( __( 'Блог', 'vyatka-shop' ) ); ?>
<h1 class="page-heading"><?php esc_html_e( 'Блог', 'vyatka-shop' ); ?></h1>
<div class="news-grid">
<?php if ( have_posts() ) : ?>
<?php while ( have_posts() ) : ?>
<?php
the_post();
vyatka_shop_news_card( get_post() );
?>
<?php endwhile; ?>
<?php else : ?>
<p><?php esc_html_e( 'Публикаций пока нет.', 'vyatka-shop' ); ?></p>
<?php endif; ?>
</div>
<?php $total_pages = (int) $GLOBALS['wp_query']->max_num_pages; ?>
<?php vyatka_shop_pagination( $total_pages ); ?>
</div>
<div class="blog-page-spacer"></div>
<?php
get_footer();
+163
View File
@@ -0,0 +1,163 @@
<?php
/**
* Front-end assets.
*
* The stylesheet is layered: tokens -> base -> components -> style.css
* (layout and pages). Each layer may only use what the layers before it
* defined, which is what keeps the cascade free of override chains.
*
* @package VyatkaShop
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Version a theme asset with a short token derived from the file itself.
*
* Four characters is enough to tell one build of a file from the next, and it
* keeps the query string out of the way. Deriving it from the file rather than
* randomising it per request is what lets the browser actually cache: the
* token only moves when the file does.
*
* @param string $relative_path Path relative to the theme root.
* @return string
*/
function vyatka_shop_asset_version( string $relative_path ): string {
$file = get_template_directory() . '/' . ltrim( $relative_path, '/' );
if ( ! is_readable( $file ) ) {
return substr( hash( 'crc32b', $relative_path . VYATKA_SHOP_VERSION ), 0, 4 );
}
return substr( hash( 'crc32b', $relative_path . filemtime( $file ) . filesize( $file ) ), 0, 4 );
}
/**
* Icons the front-end script has to render on its own (toasts, dismiss
* buttons). Sharing them from PHP keeps `assets/icons/*.svg` the single
* source of truth.
*
* @return array<string,string>
*/
function vyatka_shop_script_icons(): array {
$icons = array();
foreach ( array( 'check-circle-fill', 'x-circle-fill', 'warning-fill', 'info-fill', 'x', 'caret-down' ) as $name ) {
$icons[ $name ] = vyatka_shop_get_icon( $name );
}
return $icons;
}
/**
* Load public assets.
*/
function vyatka_shop_assets(): void {
/*
* Order is the cascade. Each layer may only build on the ones before it:
* tokens define the vocabulary, base sets element defaults, components own
* the controls, the page layers place them, and responsive comes last
* because it overrides all of the above.
*/
$layers = array(
'vyatka-tokens' => 'assets/css/tokens.css',
'vyatka-base' => 'assets/css/base.css',
'vyatka-components' => 'assets/css/components.css',
'vyatka-layout' => 'assets/css/layout.css',
'vyatka-home' => 'assets/css/home.css',
'vyatka-catalog' => 'assets/css/catalog.css',
'vyatka-product' => 'assets/css/product.css',
'vyatka-blog' => 'assets/css/blog.css',
'vyatka-cart' => 'assets/css/cart.css',
'vyatka-responsive' => 'assets/css/responsive.css',
'vyatka-misc' => 'assets/css/misc.css',
);
$previous = array();
foreach ( $layers as $handle => $path ) {
wp_enqueue_style(
$handle,
get_template_directory_uri() . '/' . $path,
$previous,
vyatka_shop_asset_version( $path )
);
$previous = array( $handle );
}
// style.css only carries the theme header WordPress reads, but it still has
// to be enqueued so child themes can depend on it.
wp_enqueue_style(
'vyatka-shop',
get_stylesheet_uri(),
array( 'vyatka-misc' ),
vyatka_shop_asset_version( 'style.css' )
);
wp_enqueue_script(
'vyatka-shop',
get_template_directory_uri() . '/assets/js/theme.js',
array(),
vyatka_shop_asset_version( 'assets/js/theme.js' ),
true
);
wp_localize_script(
'vyatka-shop',
'vyatkaShopCart',
array(
'ajaxUrl' => '/wp-admin/admin-ajax.php',
'nonce' => wp_create_nonce( 'vyatka-cart' ),
)
);
wp_localize_script( 'vyatka-shop', 'vyatkaShopIcons', vyatka_shop_script_icons() );
}
add_action( 'wp_enqueue_scripts', 'vyatka_shop_assets' );
/**
* Drop WooCommerce's default presentation.
*
* The theme templates every WooCommerce surface it uses, so the plugin's
* stylesheets only add rules that have to be fought with `!important`.
* Removing them is what lets the component layer win on its own specificity.
*
* @param array<string,array<string,string>> $styles Registered WooCommerce styles.
* @return array<string,array<string,string>>
*/
function vyatka_shop_remove_woocommerce_styles( array $styles ): array {
unset(
$styles['woocommerce-general'],
$styles['woocommerce-layout'],
$styles['woocommerce-smallscreen']
);
return $styles;
}
add_filter( 'woocommerce_enqueue_styles', 'vyatka_shop_remove_woocommerce_styles' );
/**
* Remove the block-editor front-end styles WooCommerce and core add for
* surfaces this theme renders itself.
*
* Hooked on `wp_print_styles` rather than `wp_enqueue_scripts`: WooCommerce
* Blocks registers its stylesheet from `enqueue_block_assets`, which runs
* after the theme's own enqueue pass.
*/
function vyatka_shop_dequeue_block_styles(): void {
if ( is_admin() ) {
return;
}
$handles = array(
'wc-blocks-style',
'wc-blocks-style-all-products',
'wc-blocks-packages-style',
'wp-block-library-theme',
'classic-theme-styles',
'global-styles',
);
foreach ( $handles as $handle ) {
wp_dequeue_style( $handle );
wp_deregister_style( $handle );
}
}
add_action( 'wp_print_styles', 'vyatka_shop_dequeue_block_styles', 100 );
+134
View File
@@ -0,0 +1,134 @@
<?php
/**
* Inline SVG icon component.
*
* Every icon in the design is a Phosphor vector. Icons are stored as real
* `.svg` files under `assets/icons/` and inlined into the document so they
* inherit `currentColor`, can be sized from CSS and never cost an extra
* request. Nothing in the theme may draw an icon with a text glyph.
*
* @package VyatkaShop
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Read an icon file and normalise it for inline use.
*
* @param string $name Icon file name without extension.
* @return string Normalised `<svg>` markup, or an empty string when missing.
*/
function vyatka_shop_icon_source( string $name ): string {
static $cache = array();
if ( isset( $cache[ $name ] ) ) {
return $cache[ $name ];
}
$file = get_template_directory() . '/assets/icons/' . $name . '.svg';
if ( ! preg_match( '/^[a-z0-9-]+$/', $name ) || ! is_readable( $file ) ) {
$cache[ $name ] = '';
return '';
}
$svg = (string) file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
// Sizing belongs to CSS, colour to `currentColor`.
$svg = preg_replace( '/\s(?:width|height)="[^"]*"/', '', $svg );
$svg = preg_replace( '/\sfill="(?:black|#000(?:000)?)"/i', ' fill="currentColor"', $svg );
$svg = preg_replace( '/<svg\b(?![^>]*\bfill=)/', '<svg fill="currentColor"', $svg );
$cache[ $name ] = trim( (string) $svg );
return $cache[ $name ];
}
/**
* Build the markup for an inline icon.
*
* @param string $name Icon name, e.g. `cart`, `check-bold`.
* @param array<string,string> $args {
* Optional. Presentation options.
*
* @type string $class Extra class names appended to `icon icon-{name}`.
* @type string $label Accessible label. When omitted the icon is hidden
* from assistive technology.
* @type string $size Pixel size applied inline, for one-off sizes.
* }
* @return string
*/
function vyatka_shop_get_icon( string $name, array $args = array() ): string {
$svg = vyatka_shop_icon_source( $name );
if ( '' === $svg ) {
return '';
}
$classes = trim( 'icon icon-' . $name . ' ' . ( $args['class'] ?? '' ) );
$label = trim( (string) ( $args['label'] ?? '' ) );
$attributes = sprintf( ' class="%s"', esc_attr( $classes ) );
if ( '' !== $label ) {
$attributes .= sprintf( ' role="img" aria-label="%s"', esc_attr( $label ) );
} else {
$attributes .= ' aria-hidden="true" focusable="false"';
}
if ( ! empty( $args['size'] ) ) {
$attributes .= sprintf( ' style="--icon-size:%dpx"', absint( $args['size'] ) );
}
return (string) preg_replace( '/<svg\b/', '<svg' . $attributes, $svg, 1 );
}
/**
* Echo an inline icon.
*
* @param string $name Icon name.
* @param array<string,string> $args See vyatka_shop_get_icon().
*/
function vyatka_shop_icon( string $name, array $args = array() ): void {
echo vyatka_shop_get_icon( $name, $args ); // phpcs:ignore WordPress.Security.EscapingOutput.OutputNotEscaped
}
/**
* Allow inline SVG through `wp_kses_post()` so icons survive filtered output.
*
* @param array<string,array<string,bool>> $tags Allowed tags.
* @return array<string,array<string,bool>>
*/
function vyatka_shop_allow_inline_svg( array $tags ): array {
$shared = array(
'class' => true,
'style' => true,
'fill' => true,
'stroke' => true,
'aria-hidden' => true,
'aria-label' => true,
'focusable' => true,
'role' => true,
);
$tags['svg'] = $shared + array(
'xmlns' => true,
'viewbox' => true,
'width' => true,
'height' => true,
);
$tags['path'] = $shared + array(
'd' => true,
'fill-rule' => true,
'clip-rule' => true,
'stroke-width' => true,
'stroke-linecap' => true,
'stroke-linejoin' => true,
);
$tags['g'] = $shared;
$tags['circle'] = $shared + array( 'cx' => true, 'cy' => true, 'r' => true );
$tags['rect'] = $shared + array( 'x' => true, 'y' => true, 'width' => true, 'height' => true, 'rx' => true, 'ry' => true );
$tags['line'] = $shared + array( 'x1' => true, 'y1' => true, 'x2' => true, 'y2' => true );
$tags['polyline'] = $shared + array( 'points' => true );
$tags['polygon'] = $shared + array( 'points' => true );
return $tags;
}
add_filter( 'wp_kses_allowed_html', 'vyatka_shop_allow_inline_svg' );
+144
View File
@@ -0,0 +1,144 @@
<?php
/**
* Theme supports, menus and the customizer surface.
*
* @package VyatkaShop
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Configure theme features and navigation.
*/
function vyatka_shop_setup(): void {
load_theme_textdomain( 'vyatka-shop', get_template_directory() . '/languages' );
add_theme_support( 'title-tag' );
add_theme_support( 'post-thumbnails' );
add_theme_support( 'html5', array( 'search-form', 'gallery', 'caption', 'style', 'script' ) );
add_theme_support(
'custom-logo',
array(
'height' => 100,
'width' => 260,
'flex-height' => true,
'flex-width' => true,
)
);
add_theme_support( 'woocommerce' );
add_theme_support( 'wc-product-gallery-lightbox' );
add_theme_support( 'wc-product-gallery-slider' );
add_image_size( 'vyatka-product', 720, 560, true );
add_image_size( 'vyatka-news', 860, 620, true );
register_nav_menus(
array(
'primary' => __( 'Главное меню', 'vyatka-shop' ),
'footer' => __( 'Меню в подвале', 'vyatka-shop' ),
)
);
}
add_action( 'after_setup_theme', 'vyatka_shop_setup' );
/**
* Store options editable from Appearance > Customize.
*
* @param WP_Customize_Manager $customizer Customizer instance.
*/
function vyatka_shop_customize_register( WP_Customize_Manager $customizer ): void {
$customizer->add_section(
'vyatka_home',
array(
'title' => __( 'Главная страница', 'vyatka-shop' ),
'priority' => 30,
)
);
$fields = array(
'vyatka_hero_title' => array(
'label' => __( 'Заголовок', 'vyatka-shop' ),
'default' => __( 'Строим тёплые деревянные дома', 'vyatka-shop' ),
),
'vyatka_hero_text' => array(
'label' => __( 'Описание', 'vyatka-shop' ),
'default' => __( 'Учитывая ключевые сценарии поведения, существующая теория в значительной степени обусловливает важность дальнейших направлений развития.', 'vyatka-shop' ),
),
);
foreach ( $fields as $key => $field ) {
$customizer->add_setting(
$key,
array(
'default' => $field['default'],
'sanitize_callback' => 'sanitize_text_field',
)
);
$customizer->add_control(
$key,
array(
'label' => $field['label'],
'section' => 'vyatka_home',
'type' => 'text',
)
);
}
}
add_action( 'customize_register', 'vyatka_shop_customize_register' );
/**
* Match the deterministic desktop blog grid from the design.
*
* @param WP_Query $query Current query.
*/
function vyatka_shop_blog_query( WP_Query $query ): void {
if ( ! is_admin() && $query->is_main_query() && $query->is_home() ) {
$query->set( 'posts_per_page', 9 );
}
}
add_action( 'pre_get_posts', 'vyatka_shop_blog_query' );
/**
* Keep the primary navigation labels aligned with the design even when a
* previously saved WordPress menu contains an old typo.
*
* @param array<int,WP_Post> $items Menu items.
* @param stdClass $args Menu arguments.
* @return array<int,WP_Post>
*/
function vyatka_shop_primary_menu_labels( array $items, stdClass $args ): array {
if ( 'primary' !== ( $args->theme_location ?? '' ) ) {
return $items;
}
foreach ( $items as $item ) {
$path = (string) wp_parse_url( (string) $item->url, PHP_URL_PATH );
if ( '/blog/' === trailingslashit( $path ) ) {
$item->title = __( 'Блог', 'vyatka-shop' );
}
}
return $items;
}
add_filter( 'wp_nav_menu_objects', 'vyatka_shop_primary_menu_labels', 10, 2 );
/**
* Render a fallback primary menu before a menu is assigned.
*/
function vyatka_shop_fallback_menu(): void {
$links = array(
home_url( '/' ) => __( 'Главная', 'vyatka-shop' ),
home_url( '/catalog/' ) => __( 'Каталог', 'vyatka-shop' ),
get_post_type_archive_link( 'post' ) ?: home_url( '/blog/' ) => __( 'Блог', 'vyatka-shop' ),
home_url( '/#contacts' ) => __( 'Контакты', 'vyatka-shop' ),
);
echo '<ul>';
foreach ( $links as $url => $label ) {
printf(
'<li><a href="%1$s">%2$s</a></li>',
esc_url( $url ),
esc_html( $label )
);
}
echo '</ul>';
}
+123
View File
@@ -0,0 +1,123 @@
<?php
/**
* Reusable render helpers shared by the templates.
*
* @package VyatkaShop
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Render the same pagination component for posts and products.
*
* @param int $total_pages Number of available pages.
* @param int $current_page Current page number.
*/
function vyatka_shop_pagination( int $total_pages, int $current_page = 0 ): void {
if ( $total_pages < 2 ) {
return;
}
$current_page = $current_page ?: max( 1, get_query_var( 'paged' ) );
?>
<nav class="navigation pagination" aria-label="<?php esc_attr_e( 'Навигация по страницам', 'vyatka-shop' ); ?>">
<div class="nav-links">
<?php if ( $current_page > 1 ) : ?>
<a class="prev page-numbers" href="<?php echo esc_url( get_pagenum_link( $current_page - 1 ) ); ?>">
<?php vyatka_shop_icon( 'arrow-right-fill', array( 'class' => 'icon-flip icon-20' ) ); ?>
<span class="screen-reader-text"><?php esc_html_e( 'Предыдущая страница', 'vyatka-shop' ); ?></span>
</a>
<?php endif; ?>
<?php
echo wp_kses_post(
paginate_links(
array(
'current' => $current_page,
'total' => $total_pages,
'mid_size' => 4,
'end_size' => 1,
'prev_next' => false,
)
)
);
?>
<?php if ( $current_page < $total_pages ) : ?>
<a class="next page-numbers" href="<?php echo esc_url( get_pagenum_link( $current_page + 1 ) ); ?>">
<?php vyatka_shop_icon( 'arrow-right-fill', array( 'class' => 'icon-20' ) ); ?>
<span class="screen-reader-text"><?php esc_html_e( 'Следующая страница', 'vyatka-shop' ); ?></span>
</a>
<?php endif; ?>
</div>
</nav>
<?php
}
/**
* Render compact breadcrumbs matching the design.
*
* @param string $current Current page title.
*/
function vyatka_shop_breadcrumbs( string $current = '' ): void {
?>
<ol class="breadcrumbs" aria-label="<?php esc_attr_e( 'Хлебные крошки', 'vyatka-shop' ); ?>">
<li><a href="<?php echo esc_url( home_url( '/' ) ); ?>"><?php esc_html_e( 'Главная', 'vyatka-shop' ); ?></a></li>
<?php if ( is_singular( 'product' ) || is_product_category() ) : ?>
<li><a href="<?php echo esc_url( home_url( '/catalog/' ) ); ?>"><?php esc_html_e( 'Каталог', 'vyatka-shop' ); ?></a></li>
<?php elseif ( is_singular( 'post' ) ) : ?>
<li><a href="<?php echo esc_url( get_post_type_archive_link( 'post' ) ?: home_url( '/blog/' ) ); ?>"><?php esc_html_e( 'Блог', 'vyatka-shop' ); ?></a></li>
<?php endif; ?>
<?php if ( $current ) : ?>
<li aria-current="page"><?php echo esc_html( $current ); ?></li>
<?php endif; ?>
</ol>
<?php
}
/**
* Render a reusable post card.
*
* @param WP_Post $post Post object.
*/
function vyatka_shop_news_card( WP_Post $post ): void {
$excerpt = get_the_excerpt( $post );
?>
<a class="news-card" href="<?php echo esc_url( get_permalink( $post ) ); ?>">
<h3><?php echo esc_html( get_the_title( $post ) ); ?></h3>
<p><?php echo esc_html( wp_trim_words( $excerpt, 15 ) ); ?></p>
</a>
<?php
}
/**
* Render the placeholder wordmark used until a real logo is uploaded.
*
* The design's logo is set in a condensed display face; shipping that as an
* SVG outline keeps it sharp, drops a webfont the rest of the design system
* never uses, and matches the rule that every mark on the page is a vector.
*/
function vyatka_shop_brand_mark(): void {
$file = get_template_directory() . '/assets/images/logo.svg';
if ( ! is_readable( $file ) ) {
printf( '<span>%s</span>', esc_html__( 'Логотип', 'vyatka-shop' ) );
return;
}
$svg = (string) file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
echo (string) preg_replace( '/<svg\b/', '<svg class="brand-mark"', trim( $svg ), 1 ); // phpcs:ignore WordPress.Security.EscapingOutput.OutputNotEscaped
}
/**
* Get the cart URL without failing when WooCommerce is not active.
*/
function vyatka_shop_cart_url(): string {
return function_exists( 'wc_get_cart_url' ) ? wc_get_cart_url() : home_url( '/cart/' );
}
/**
* Return placeholder content for seeded products and posts.
*/
function vyatka_shop_placeholder_copy(): string {
return 'Мы создаём продуманные пространства для жизни: проектируем, подбираем материалы, производим комплектующие и контролируем качество на каждом этапе. Конструкция рассчитана на круглогодичное использование и адаптируется под особенности участка.';
}
+186
View File
@@ -0,0 +1,186 @@
<?php
/**
* URL handling.
*
* @package VyatkaShop
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Build links against the host the visitor actually asked for.
*
* WordPress stores one canonical home URL, so a site reachable under more than
* one name (a bare host and a proxied domain, say) would otherwise send people
* back to the stored one mid-session. Only the request host is trusted here,
* and only when it matches a host WordPress already knows about, so this cannot
* be driven by a forged Host header.
*
* @param string $url Complete URL generated by WordPress.
* @return string
*/
function vyatka_shop_request_host_url( string $url ): string {
$request_host = vyatka_shop_request_host();
if ( '' === $request_host || ! vyatka_shop_host_is_known( $request_host ) ) {
return $url;
}
$parts = wp_parse_url( $url );
if ( ! is_array( $parts ) ) {
return $url;
}
$forwarded_proto = isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] )
? strtolower( trim( explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) ) )[0] ) )
: '';
$scheme = 'https' === $forwarded_proto || is_ssl() ? 'https' : 'http';
$rewritten = $scheme . '://' . $request_host;
$rewritten .= $parts['path'] ?? '';
$rewritten .= isset( $parts['query'] ) ? '?' . $parts['query'] : '';
$rewritten .= isset( $parts['fragment'] ) ? '#' . $parts['fragment'] : '';
return $rewritten;
}
add_filter( 'home_url', 'vyatka_shop_request_host_url' );
add_filter( 'site_url', 'vyatka_shop_request_host_url' );
/**
* The host from the current request, without its port.
*/
function vyatka_shop_request_host(): string {
if ( ! isset( $_SERVER['HTTP_HOST'] ) ) {
return '';
}
return strtolower( preg_replace( '/:\d+$/', '', sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) ) );
}
/**
* Hosts this installation answers to: whatever is stored in the options, plus
* anything a site owner adds through the filter.
*
* @return array<int,string>
*/
function vyatka_shop_known_hosts(): array {
$hosts = array();
foreach ( array( 'home', 'siteurl' ) as $option_name ) {
$host = wp_parse_url( (string) get_option( $option_name ), PHP_URL_HOST );
if ( is_string( $host ) && '' !== $host ) {
$hosts[] = strtolower( $host );
}
}
// Sites behind a proxy answer to a name the stored options do not know.
// Declare those in wp-config.php:
// define( 'VYATKA_SHOP_HOSTS', 'shop.example.com, www.example.com' );
if ( defined( 'VYATKA_SHOP_HOSTS' ) ) {
foreach ( explode( ',', (string) VYATKA_SHOP_HOSTS ) as $host ) {
$host = strtolower( trim( $host ) );
if ( '' !== $host ) {
$hosts[] = $host;
}
}
}
/**
* Additional hosts the site is served under.
*
* @param array<int,string> $hosts Known hostnames.
*/
return array_values( array_unique( (array) apply_filters( 'vyatka_shop_known_hosts', $hosts ) ) );
}
/**
* @param string $host Hostname to test.
*/
function vyatka_shop_host_is_known( string $host ): bool {
return in_array( strtolower( $host ), vyatka_shop_known_hosts(), true );
}
/**
* Strip the origin from internal links in the finished HTML.
*
* Rewriting only the rendered document leaves redirects, REST responses and
* WP-CLI output fully qualified, while the markup a browser receives stays
* usable under whichever host served it.
*
* @param string $html Completed response body.
* @return string
*/
function vyatka_shop_relative_frontend_urls( string $html ): string {
foreach ( headers_list() as $header ) {
if ( 0 === stripos( $header, 'Content-Type:' ) && false === stripos( $header, 'text/html' ) ) {
return $html;
}
}
$hosts = vyatka_shop_known_hosts();
$request_host = vyatka_shop_request_host();
if ( '' !== $request_host ) {
$hosts[] = $request_host;
}
$absolute_bases = array();
foreach ( array_unique( $hosts ) as $host ) {
$absolute_bases[] = 'https://' . $host;
$absolute_bases[] = 'http://' . $host;
$absolute_bases[] = '//' . $host;
}
// Longest first, so "//host" never truncates "https://host".
usort( $absolute_bases, static fn( string $left, string $right ): int => strlen( $right ) <=> strlen( $left ) );
return str_replace( $absolute_bases, '', $html );
}
/**
* Buffer ordinary frontend documents so their internal links can be made
* relative without changing the site's canonical configuration.
*/
function vyatka_shop_start_relative_url_buffer(): void {
if ( is_admin() || wp_doing_ajax() || wp_is_json_request() || is_feed() ) {
return;
}
ob_start( 'vyatka_shop_relative_frontend_urls' );
}
add_action( 'template_redirect', 'vyatka_shop_start_relative_url_buffer', 0 );
/**
* Recover stale filtered category URLs when the selected page no longer exists.
*
* Filter forms always target page one, but bookmarked or cached /page/N/ URLs
* can still reach WordPress directly and become a 404 after the result set is
* narrowed. Preserve their query string and retry the category from page one.
*/
function vyatka_shop_redirect_stale_filtered_category_page(): void {
if ( ! is_404() || empty( $_GET ) ) {
return;
}
$filter_keys = array(
'filter_min_price',
'filter_max_price',
'feature',
'collection',
'per_page',
'orderby',
);
if ( ! array_intersect( $filter_keys, array_keys( $_GET ) ) ) {
return;
}
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
$request_path = wp_parse_url( $request_uri, PHP_URL_PATH );
if ( ! is_string( $request_path ) || ! preg_match( '#^(.*/product-category/[^/]+)/page/[1-9][0-9]*/?$#u', $request_path, $matches ) ) {
return;
}
$target = add_query_arg( wp_unslash( $_GET ), trailingslashit( $matches[1] ) );
wp_safe_redirect( $target, 302, 'Vyatka Shop' );
exit;
}
add_action( 'template_redirect', 'vyatka_shop_redirect_stale_filtered_category_page', -10 );
+373
View File
@@ -0,0 +1,373 @@
<?php
/**
* WooCommerce integration: catalog behaviour, checkout fields and the
* in-place cart endpoint.
*
* @package VyatkaShop
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Remove WooCommerce wrappers in favor of semantic theme templates.
*/
function vyatka_shop_woocommerce_hooks(): void {
remove_action( 'woocommerce_before_main_content', 'woocommerce_output_content_wrapper', 10 );
remove_action( 'woocommerce_after_main_content', 'woocommerce_output_content_wrapper_end', 10 );
remove_action( 'woocommerce_sidebar', 'woocommerce_get_sidebar', 10 );
}
add_action( 'wp', 'vyatka_shop_woocommerce_hooks' );
/**
* Respect the product-count control used by the archive toolbar.
*/
function vyatka_shop_products_per_page(): int {
$requested = isset( $_GET['per_page'] ) ? absint( wp_unslash( $_GET['per_page'] ) ) : 25;
return in_array( $requested, array( 12, 25, 50 ), true ) ? $requested : 25;
}
add_filter( 'loop_shop_per_page', 'vyatka_shop_products_per_page', 20 );
/**
* Return the deterministic demo facets rendered by product cards.
*
* @param int $product_id Product ID.
* @return array{feature:string,collection:string}
*/
function vyatka_shop_product_facets( int $product_id ): array {
$features = array( 'conifer', 'maple', 'birch', 'sea-buckthorn', 'rowan', 'blueberry', 'lingonberry' );
$collections = array( 'pine', 'linden', 'aspen', 'juniper', 'cranberry', 'blackberry', 'raspberry' );
return array(
'feature' => $features[ $product_id % count( $features ) ],
'collection' => $collections[ $product_id % count( $collections ) ],
);
}
/**
* Apply the visible demo facets to the main product query so filtering,
* sorting and pagination all operate on the same result set.
*
* @param WP_Query $query Main archive query.
*/
function vyatka_shop_filter_product_archive( WP_Query $query ): void {
static $collecting_products = false;
if (
$collecting_products ||
is_admin() ||
! $query->is_main_query() ||
( ! $query->is_post_type_archive( 'product' ) && ! $query->is_tax( 'product_cat' ) )
) {
return;
}
$selected_features = isset( $_GET['feature'] )
? array_values( array_diff( array_map( 'sanitize_key', (array) wp_unslash( $_GET['feature'] ) ), array( 'all', '' ) ) )
: array();
$selected_collection = isset( $_GET['collection'] ) ? sanitize_key( wp_unslash( $_GET['collection'] ) ) : '';
$price_active = isset( $_GET['filter_min_price'], $_GET['filter_max_price'] );
if ( ! $selected_features && ! $selected_collection && ! $price_active ) {
return;
}
$minimum_price = $price_active ? max( 0, absint( wp_unslash( $_GET['filter_min_price'] ) ) ) : 0;
$maximum_price = $price_active ? max( $minimum_price, absint( wp_unslash( $_GET['filter_max_price'] ) ) ) : PHP_INT_MAX;
$collecting_products = true;
$product_ids = get_posts(
array(
'post_type' => 'product',
'post_status' => 'publish',
'fields' => 'ids',
'posts_per_page' => -1,
'no_found_rows' => true,
'suppress_filters' => true,
)
);
$collecting_products = false;
if ( $query->is_tax( 'product_cat' ) ) {
$term = get_queried_object();
if ( $term instanceof WP_Term ) {
$product_ids = array_values(
array_filter(
$product_ids,
static fn( int $product_id ): bool => has_term( $term->term_id, 'product_cat', $product_id )
)
);
}
}
$matching_ids = array_values(
array_filter(
$product_ids,
static function ( int $product_id ) use ( $selected_features, $selected_collection, $price_active, $minimum_price, $maximum_price ): bool {
$facets = vyatka_shop_product_facets( $product_id );
$price = (float) get_post_meta( $product_id, '_price', true );
return ( ! $selected_features || in_array( $facets['feature'], $selected_features, true ) )
&& ( ! $selected_collection || $facets['collection'] === $selected_collection )
&& ( ! $price_active || ( $price >= $minimum_price && $price <= $maximum_price ) );
}
)
);
$query->set( 'post__in', $matching_ids ?: array( 0 ) );
$query->set( 'paged', max( 1, absint( $query->get( 'paged' ) ) ) );
}
add_action( 'pre_get_posts', 'vyatka_shop_filter_product_archive', 25 );
/**
* Use the wording shown in the design for the default product order.
*
* @param array<string,string> $options Ordering labels.
* @return array<string,string>
*/
function vyatka_shop_catalog_orderby( array $options ): array {
if ( isset( $options['menu_order'] ) ) {
$options['menu_order'] = __( 'По умолчанию', 'vyatka-shop' );
}
return $options;
}
add_filter( 'woocommerce_catalog_orderby', 'vyatka_shop_catalog_orderby' );
/**
* Adding a product is handled in place by the theme's AJAX control. Keeping
* WooCommerce's redirect option disabled also prevents it from queueing a
* second success message for the next page load.
*/
function vyatka_shop_disable_add_to_cart_redirect(): string {
return 'no';
}
add_filter( 'pre_option_woocommerce_cart_redirect_after_add', 'vyatka_shop_disable_add_to_cart_redirect' );
/**
* Success feedback is rendered by the client-side toast. Remove legacy
* success notices left in the WooCommerce session before any frontend
* template can print them into the document flow.
*/
function vyatka_shop_clear_legacy_success_notices(): void {
if ( is_admin() || wp_doing_ajax() || ! function_exists( 'wc_get_notices' ) || ! function_exists( 'wc_set_notices' ) ) {
return;
}
$notices = wc_get_notices();
if ( ! isset( $notices['success'] ) ) {
return;
}
unset( $notices['success'] );
wc_set_notices( $notices );
}
add_action( 'wp', 'vyatka_shop_clear_legacy_success_notices', 20 );
/**
* Calculate regular, current and sale-discount totals from one shared source.
*
* @param WC_Cart|null $cart Cart to summarize. Defaults to the active cart.
* @return array{regular_total: float, current_total: float, discount_total: float}
*/
function vyatka_shop_cart_price_summary( ?WC_Cart $cart = null ): array {
$cart = $cart ?? WC()->cart;
$regular_total = 0.0;
$current_total = 0.0;
if ( ! $cart ) {
return array(
'regular_total' => 0.0,
'current_total' => 0.0,
'discount_total' => 0.0,
);
}
foreach ( $cart->get_cart() as $cart_item ) {
$product = $cart_item['data'] ?? null;
$quantity = isset( $cart_item['quantity'] ) ? max( 0, (int) $cart_item['quantity'] ) : 0;
if ( ! $product instanceof WC_Product || ! $product->exists() || 0 === $quantity ) {
continue;
}
$current_price = max( 0.0, (float) $product->get_price() );
$regular_price = (float) $product->get_regular_price();
if ( $regular_price <= 0.0 ) {
$regular_price = $current_price;
}
$regular_total += $regular_price * $quantity;
$current_total += $current_price * $quantity;
}
return array(
'regular_total' => $regular_total,
'current_total' => $current_total,
'discount_total' => max( 0.0, $regular_total - $current_total ),
);
}
/**
* Render the current cart state for an AJAX response.
*/
function vyatka_shop_render_cart_fragment(): string {
ob_start();
if ( WC()->cart->is_empty() ) {
wc_get_template( 'cart/cart-empty.php' );
} else {
wc_get_template( 'cart/cart.php' );
}
$html = (string) ob_get_clean();
return vyatka_shop_relative_frontend_urls( $html );
}
/**
* Mutate the WooCommerce cart without navigating away from the current page.
*/
function vyatka_shop_ajax_cart_mutation(): void {
check_ajax_referer( 'vyatka-cart', 'nonce' );
if ( ! function_exists( 'WC' ) ) {
wp_send_json_error( array( 'message' => __( 'Корзина недоступна.', 'vyatka-shop' ) ), 503 );
}
if ( null === WC()->cart && function_exists( 'wc_load_cart' ) ) {
wc_load_cart();
}
if ( null === WC()->cart ) {
wp_send_json_error( array( 'message' => __( 'Корзина недоступна.', 'vyatka-shop' ) ), 503 );
}
$operation = isset( $_POST['operation'] ) ? sanitize_key( wp_unslash( $_POST['operation'] ) ) : '';
if ( 'add' === $operation ) {
$product_id = isset( $_POST['product_id'] ) ? absint( wp_unslash( $_POST['product_id'] ) ) : 0;
$quantity = isset( $_POST['quantity'] ) ? max( 1, absint( wp_unslash( $_POST['quantity'] ) ) ) : 1;
$product = wc_get_product( $product_id );
if ( ! $product || ! $product->is_purchasable() || ! $product->is_in_stock() ) {
wp_send_json_error( array( 'message' => __( 'Товар нельзя добавить в корзину.', 'vyatka-shop' ) ), 400 );
}
$maximum = $product->get_max_purchase_quantity();
if ( 0 < $maximum ) {
$quantity = min( $quantity, $maximum );
}
$existing_key = '';
foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) {
if ( $product_id === (int) $cart_item['product_id'] && empty( $cart_item['variation_id'] ) ) {
$existing_key = $cart_item_key;
break;
}
}
$changed = '' !== $existing_key
? WC()->cart->set_quantity( $existing_key, $quantity, false )
: WC()->cart->add_to_cart( $product_id, $quantity );
if ( false === $changed ) {
wp_send_json_error( array( 'message' => __( 'Не удалось добавить товар в корзину.', 'vyatka-shop' ) ), 400 );
}
} elseif ( 'update' === $operation ) {
$cart_item_key = isset( $_POST['cart_item_key'] )
? wc_clean( wp_unslash( $_POST['cart_item_key'] ) )
: '';
$quantity = isset( $_POST['quantity'] ) ? max( 1, absint( wp_unslash( $_POST['quantity'] ) ) ) : 1;
$cart_item = WC()->cart->get_cart_item( $cart_item_key );
if ( ! $cart_item ) {
wp_send_json_error( array( 'message' => __( 'Товар в корзине не найден.', 'vyatka-shop' ) ), 404 );
}
$product = $cart_item['data'];
$maximum = $product instanceof WC_Product ? $product->get_max_purchase_quantity() : -1;
if ( 0 < $maximum ) {
$quantity = min( $quantity, $maximum );
}
if ( false === WC()->cart->set_quantity( $cart_item_key, $quantity, false ) ) {
wp_send_json_error( array( 'message' => __( 'Не удалось изменить количество.', 'vyatka-shop' ) ), 400 );
}
} elseif ( 'remove' === $operation ) {
$cart_item_key = isset( $_POST['cart_item_key'] )
? wc_clean( wp_unslash( $_POST['cart_item_key'] ) )
: '';
if ( '' === $cart_item_key || false === WC()->cart->remove_cart_item( $cart_item_key ) ) {
wp_send_json_error( array( 'message' => __( 'Не удалось удалить товар.', 'vyatka-shop' ) ), 400 );
}
} else {
wp_send_json_error( array( 'message' => __( 'Неизвестная операция с корзиной.', 'vyatka-shop' ) ), 400 );
}
WC()->cart->calculate_totals();
WC()->cart->set_session();
wp_send_json_success(
array(
'cartCount' => WC()->cart->get_cart_contents_count(),
'html' => vyatka_shop_render_cart_fragment(),
)
);
}
add_action( 'wp_ajax_vyatka_cart_mutation', 'vyatka_shop_ajax_cart_mutation' );
add_action( 'wp_ajax_nopriv_vyatka_cart_mutation', 'vyatka_shop_ajax_cart_mutation' );
/**
* Keep only the three customer fields present in the checkout design.
*
* @param array<string,array<string,array<string,mixed>>> $fields Checkout fields.
* @return array<string,array<string,array<string,mixed>>>
*/
function vyatka_shop_checkout_fields( array $fields ): array {
// WooCommerce prefixes billing labels with "Выставление счёта", which turns
// a validation message into "Выставление счёта Имя — обязательное поле".
// The design labels these fields plainly, and the shorter label is also
// what the inline field errors match on.
$labels = array(
'billing_first_name' => __( 'Имя', 'vyatka-shop' ),
'billing_phone' => __( 'Телефон', 'vyatka-shop' ),
'billing_email' => __( 'Email', 'vyatka-shop' ),
);
$billing = array();
foreach ( $labels as $key => $label ) {
if ( isset( $fields['billing'][ $key ] ) ) {
$billing[ $key ] = $fields['billing'][ $key ];
$billing[ $key ]['required'] = true;
$billing[ $key ]['label'] = $label;
}
}
$fields['billing'] = $billing;
$fields['shipping'] = array();
$fields['order'] = array();
return $fields;
}
add_filter( 'woocommerce_checkout_fields', 'vyatka_shop_checkout_fields' );
/**
* Validate the delivery-only address field exposed by the checkout switch.
*/
function vyatka_shop_validate_delivery_fields(): void {
$delivery_type = isset( $_POST['delivery_type'] ) ? sanitize_key( wp_unslash( $_POST['delivery_type'] ) ) : 'pickup';
$address = isset( $_POST['delivery_address'] ) ? sanitize_text_field( wp_unslash( $_POST['delivery_address'] ) ) : '';
if ( 'delivery' === $delivery_type && '' === $address ) {
wc_add_notice( __( 'Укажите адрес доставки.', 'vyatka-shop' ), 'error' );
}
}
add_action( 'woocommerce_checkout_process', 'vyatka_shop_validate_delivery_fields' );
/**
* Store custom delivery controls with the order.
*
* @param WC_Order $order Order being created.
*/
function vyatka_shop_save_delivery_fields( WC_Order $order ): void {
$fields = array( 'delivery_type', 'pickup_address', 'delivery_address', 'delivery_unit' );
foreach ( $fields as $field ) {
if ( isset( $_POST[ $field ] ) ) {
$order->update_meta_data( '_' . $field, sanitize_text_field( wp_unslash( $_POST[ $field ] ) ) );
}
}
}
add_action( 'woocommerce_checkout_create_order', 'vyatka_shop_save_delivery_fields' );
+23
View File
@@ -0,0 +1,23 @@
<?php
/**
* Required fallback template.
*
* @package VyatkaShop
*/
get_header();
?>
<div class="container section">
<h1 class="page-heading"><?php bloginfo( 'name' ); ?></h1>
<?php if ( have_posts() ) : ?>
<?php while ( have_posts() ) : ?>
<?php the_post(); ?>
<article class="prose">
<h2><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h2>
<?php the_excerpt(); ?>
</article>
<?php endwhile; ?>
<?php endif; ?>
</div>
<?php
get_footer();
+42
View File
@@ -0,0 +1,42 @@
<?php
/**
* Template Name: Каталог категорий
*
* @package VyatkaShop
*/
get_header();
$categories = get_terms(
array(
'taxonomy' => 'product_cat',
'hide_empty' => false,
'parent' => 0,
'number' => 4,
)
);
?>
<div class="container">
<?php vyatka_shop_breadcrumbs( get_the_title() ); ?>
<h1 class="page-heading"><?php the_title(); ?></h1>
</div>
<section class="section catalog-grid-section">
<div class="container">
<div class="category-grid">
<?php if ( ! is_wp_error( $categories ) ) : ?>
<?php foreach ( $categories as $category ) : ?>
<a class="category-card" href="<?php echo esc_url( get_term_link( $category ) ); ?>">
<span class="category-card-copy">
<h3><?php esc_html_e( 'Название', 'vyatka-shop' ); ?></h3>
<p><?php esc_html_e( 'Описание категории', 'vyatka-shop' ); ?></p>
<span class="text-link"><?php esc_html_e( 'Перейти', 'vyatka-shop' ); ?><?php vyatka_shop_icon( 'arrow-right-fill', array( 'class' => 'icon-20' ) ); ?></span>
</span>
<span class="category-card-media" aria-hidden="true"></span>
</a>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</section>
<?php
get_footer();
+25
View File
@@ -0,0 +1,25 @@
<?php
/**
* Generic page.
*
* @package VyatkaShop
*/
get_header();
while ( have_posts() ) :
the_post();
if ( function_exists( 'is_cart' ) && ( is_cart() || is_checkout() ) ) {
the_content();
continue;
}
?>
<article class="container article-layout">
<?php vyatka_shop_breadcrumbs( get_the_title() ); ?>
<h1 class="page-heading"><?php the_title(); ?></h1>
<div class="prose"><?php the_content(); ?></div>
</article>
<?php
endwhile;
get_footer();
+28
View File
@@ -0,0 +1,28 @@
<?php
/**
* Single blog article.
*
* @package VyatkaShop
*/
get_header();
while ( have_posts() ) :
the_post();
?>
<article class="container article-layout">
<?php vyatka_shop_breadcrumbs( __( 'Заголовок страницы', 'vyatka-shop' ) ); ?>
<h1 class="page-heading"><?php esc_html_e( 'Заголовок страницы', 'vyatka-shop' ); ?></h1>
<p class="article-lead article-prose"><?php esc_html_e( 'В целом, конечно, понимание сути ресурсосберегающих технологий прекрасно подходит для реализации системы обучения кадров, соответствующей насущным потребностям. Внезапно, независимые государства формируют глобальную экономическую сеть и при этом — смешаны с не уникальными данными до степени совершенной неузнаваемости, из-за чего возрастает их статус бесполезности.', 'vyatka-shop' ); ?></p>
<?php if ( has_post_thumbnail() ) : ?>
<?php the_post_thumbnail( 'full', array( 'class' => 'article-media article-media-wide' ) ); ?>
<?php else : ?>
<div class="article-media article-media-wide" aria-hidden="true"></div>
<?php endif; ?>
<p class="article-caption"><?php esc_html_e( 'Подпись к фотографии', 'vyatka-shop' ); ?></p>
<div class="prose article-prose"><?php the_content(); ?></div>
</article>
<?php
endwhile;
get_footer();
+17
View File
@@ -0,0 +1,17 @@
/*
Theme Name: Vyatka Shop
Theme URI: https://git.mcbcorp.ru/Fiden/wp-vyatka-woocomm-test
Author: f1den
Description: WooCommerce theme built from the Vyatka IT Figma design system.
Version: 4.0.0
Requires at least: 6.6
Requires PHP: 8.2
Text Domain: vyatka-shop
*/
/*
* WordPress requires this file for the theme header above. The actual
* styles are layered and enqueued from inc/assets.php:
* tokens -> base -> components -> layout -> pages -> responsive -> misc
*/
+67
View File
@@ -0,0 +1,67 @@
<?php
/**
* Product card shared by the home and WooCommerce archives.
*
* @package VyatkaShop
*/
defined( 'ABSPATH' ) || exit;
global $product;
if ( ! $product instanceof WC_Product || ! $product->is_visible() ) {
return;
}
$product_id = $product->get_id();
$max_purchase_quantity = $product->get_max_purchase_quantity();
$facets = vyatka_shop_product_facets( $product_id );
$filter_price = $product->get_price();
?>
<article
<?php wc_product_class( 'product-card', $product ); ?>
data-product-card
data-features="<?php echo esc_attr( $facets['feature'] ); ?>"
data-collection="<?php echo esc_attr( $facets['collection'] ); ?>"
data-price="<?php echo esc_attr( (string) $filter_price ); ?>"
>
<a class="product-card-media" href="<?php echo esc_url( get_permalink( $product_id ) ); ?>">
<span class="product-badge"><?php esc_html_e( 'NEW', 'vyatka-shop' ); ?></span>
<?php
if ( has_post_thumbnail( $product_id ) ) {
echo wp_kses_post( $product->get_image( 'vyatka-product' ) );
}
?>
</a>
<div class="product-card-body">
<span class="stock"><?php echo esc_html( $product->is_in_stock() ? __( 'В наличии', 'vyatka-shop' ) : __( 'Нет в наличии', 'vyatka-shop' ) ); ?></span>
<h3 class="product-card-title">
<a href="<?php echo esc_url( get_permalink( $product_id ) ); ?>"><?php echo esc_html( $product->get_name() ); ?></a>
</h3>
<div class="price"><?php echo wp_kses_post( $product->get_price_html() ); ?></div>
<?php if ( $product->is_purchasable() && $product->is_in_stock() && $product->is_type( 'simple' ) ) : ?>
<form class="card-buy cart" action="<?php echo esc_url( wc_get_cart_url() ); ?>" method="post" data-ajax-add-to-cart>
<div class="quantity-control">
<button type="button" data-quantity-action="decrease" aria-label="<?php esc_attr_e( 'Уменьшить количество', 'vyatka-shop' ); ?>" disabled><?php vyatka_shop_icon( 'minus', array( 'class' => 'icon-20' ) ); ?></button>
<input
type="number"
name="quantity"
value="1"
min="1"
<?php if ( 0 < $max_purchase_quantity ) : ?>
max="<?php echo esc_attr( (string) $max_purchase_quantity ); ?>"
<?php endif; ?>
inputmode="numeric"
>
<button type="button" data-quantity-action="increase" aria-label="<?php esc_attr_e( 'Увеличить количество', 'vyatka-shop' ); ?>"<?php echo 1 === $max_purchase_quantity ? ' disabled' : ''; ?>><?php vyatka_shop_icon( 'plus', array( 'class' => 'icon-20' ) ); ?></button>
</div>
<button class="button add-cart" type="submit" name="add-to-cart" value="<?php echo esc_attr( (string) $product_id ); ?>">
<?php vyatka_shop_icon( 'cart', array( 'class' => 'icon-20' ) ); ?><?php esc_html_e( 'В корзину', 'vyatka-shop' ); ?>
</button>
</form>
<?php else : ?>
<a class="button add-cart" href="<?php echo esc_url( get_permalink( $product_id ) ); ?>"><?php esc_html_e( 'Подробнее', 'vyatka-shop' ); ?></a>
<?php endif; ?>
</div>
</article>
+77
View File
@@ -0,0 +1,77 @@
<?php
/**
* Subcategory cards shown above the catalog filters.
*
* Renders the children of the current category; on the catalog root, or when a
* category has no children, it renders that level's siblings instead, so the
* row always offers somewhere to go rather than collapsing to nothing.
*
* @package VyatkaShop
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$vyatka_current = is_product_category() ? get_queried_object() : null;
$vyatka_parent = $vyatka_current instanceof WP_Term ? $vyatka_current->term_id : 0;
$vyatka_terms = get_terms(
array(
'taxonomy' => 'product_cat',
'parent' => $vyatka_parent,
'hide_empty' => true,
)
);
// A leaf category shows its siblings.
if ( ( is_wp_error( $vyatka_terms ) || ! $vyatka_terms ) && $vyatka_current instanceof WP_Term ) {
$vyatka_terms = array_filter(
(array) get_terms(
array(
'taxonomy' => 'product_cat',
'parent' => $vyatka_current->parent,
'hide_empty' => true,
)
),
static fn( $term ): bool => $term instanceof WP_Term && $term->term_id !== $vyatka_current->term_id
);
}
if ( is_wp_error( $vyatka_terms ) || ! $vyatka_terms ) {
return;
}
?>
<div class="subcategories" data-subcategories>
<ul class="subcategory-list" data-subcategory-track>
<?php foreach ( $vyatka_terms as $vyatka_term ) : ?>
<li>
<a class="subcategory-card" href="<?php echo esc_url( get_term_link( $vyatka_term ) ); ?>">
<span class="subcategory-card-media">
<?php
$vyatka_thumb_id = (int) get_term_meta( $vyatka_term->term_id, 'thumbnail_id', true );
if ( $vyatka_thumb_id ) {
echo wp_kses_post( wp_get_attachment_image( $vyatka_thumb_id, 'thumbnail' ) );
}
?>
</span>
<span class="subcategory-card-copy">
<span class="subcategory-card-title"><?php echo esc_html( $vyatka_term->name ); ?></span>
<span class="subcategory-card-count">
<?php
printf(
/* translators: %s: number of products in the category. */
esc_html( _n( '%s товар', '%s товаров', (int) $vyatka_term->count, 'vyatka-shop' ) ),
esc_html( number_format_i18n( (int) $vyatka_term->count ) )
);
?>
</span>
</span>
</a>
</li>
<?php endforeach; ?>
</ul>
<button class="subcategory-next" type="button" data-subcategory-next aria-label="<?php esc_attr_e( 'Показать ещё подкатегории', 'vyatka-shop' ); ?>">
<?php vyatka_shop_icon( 'arrow-right-fill', array( 'class' => 'icon-20' ) ); ?>
</button>
</div>
+179
View File
@@ -0,0 +1,179 @@
<?php
/**
* Product archive and category page.
*
* @package VyatkaShop
*/
defined( 'ABSPATH' ) || exit;
get_header();
$title = woocommerce_page_title( false );
$categories = get_terms(
array(
'taxonomy' => 'product_cat',
'hide_empty' => true,
'parent' => 0,
)
);
$price_min = isset( $_GET['filter_min_price'] ) ? min( 37900, absint( wp_unslash( $_GET['filter_min_price'] ) ) ) : 0;
$price_max = isset( $_GET['filter_max_price'] ) ? max( $price_min + 100, min( 38000, absint( wp_unslash( $_GET['filter_max_price'] ) ) ) ) : 25600;
$feature_options = array(
'all' => 'Земляничный нектар',
'conifer' => 'Хвойный экстракт',
'maple' => 'Кленовый сироп',
'birch' => 'Берёзовый сок',
'sea-buckthorn' => 'Облепиховый морс',
'rowan' => 'Рябиновый настой',
'blueberry' => 'Черничный кисель',
'lingonberry' => 'Брусничный компот',
);
$collection_options = array(
'' => 'Вишнёвый нектар',
'pine' => 'Сосновый сироп',
'linden' => 'Липовый нектар',
'aspen' => 'Осиновый экстракт',
'juniper' => 'Можжевёловый морс',
'cranberry' => 'Клюквенный настой',
'blackberry' => 'Ежевичный кисель',
'raspberry' => 'Малиновый компот',
);
$selected_features = isset( $_GET['feature'] )
? array_map( 'sanitize_key', (array) wp_unslash( $_GET['feature'] ) )
: array( 'all' );
$selected_collection = isset( $_GET['collection'] ) ? sanitize_key( wp_unslash( $_GET['collection'] ) ) : '';
$selected_per_page = isset( $_GET['per_page'] ) ? absint( wp_unslash( $_GET['per_page'] ) ) : 25;
$filter_action = wc_get_page_permalink( 'shop' );
if ( is_product_category() ) {
$queried_category = get_queried_object();
if ( $queried_category instanceof WP_Term ) {
$category_link = get_term_link( $queried_category );
if ( ! is_wp_error( $category_link ) ) {
$filter_action = $category_link;
}
}
}
?>
<div class="container">
<?php vyatka_shop_breadcrumbs( $title ); ?>
<h1 class="page-heading"><?php echo esc_html( $title ); ?></h1>
<?php wc_print_notices(); ?>
<?php get_template_part( 'template-parts/subcategories' ); ?>
<div class="archive-shell">
<aside class="filters" id="product-filter-panel" aria-label="<?php esc_attr_e( 'Фильтры товаров', 'vyatka-shop' ); ?>">
<?php // Below the mobile breakpoint the panel becomes the sheet the design specifies. ?>
<div class="filters-sheet-head">
<h2 class="filters-sheet-title"><?php esc_html_e( 'Фильтры', 'vyatka-shop' ); ?></h2>
<button class="icon-button filters-close" type="button" data-filter-panel-close aria-label="<?php esc_attr_e( 'Закрыть фильтры', 'vyatka-shop' ); ?>">
<?php vyatka_shop_icon( 'x', array( 'class' => 'icon-20' ) ); ?>
</button>
</div>
<form id="product-filters" action="<?php echo esc_url( $filter_action ); ?>" method="get" data-product-filters>
<?php if ( isset( $_GET['orderby'] ) ) : ?>
<input type="hidden" name="orderby" value="<?php echo esc_attr( sanitize_key( wp_unslash( $_GET['orderby'] ) ) ); ?>">
<?php endif; ?>
<div class="filter-group" data-price-filter data-price-active="<?php echo isset( $_GET['filter_min_price'], $_GET['filter_max_price'] ) ? 'true' : 'false'; ?>">
<button class="filter-title" type="button" data-filter-toggle aria-expanded="true"><?php esc_html_e( 'Цена, ₽', 'vyatka-shop' ); ?><?php vyatka_shop_icon( 'caret-up', array( 'class' => 'filter-caret' ) ); ?></button>
<div class="filter-content">
<div class="price-fields">
<input class="filter-input" type="number" name="filter_min_price" min="0" max="38000" step="100" value="<?php echo esc_attr( (string) $price_min ); ?>" data-price-field="min">
<span class="price-fields-separator" aria-hidden="true"></span>
<input class="filter-input" type="number" name="filter_max_price" min="0" max="38000" step="100" value="<?php echo esc_attr( (string) $price_max ); ?>" data-price-field="max">
</div>
<div class="price-range">
<input type="range" min="0" max="38000" step="100" value="<?php echo esc_attr( (string) $price_min ); ?>" data-price-range="min" aria-label="<?php esc_attr_e( 'Минимальная цена', 'vyatka-shop' ); ?>">
<input type="range" min="0" max="38000" step="100" value="<?php echo esc_attr( (string) $price_max ); ?>" data-price-range="max" aria-label="<?php esc_attr_e( 'Максимальная цена', 'vyatka-shop' ); ?>">
</div>
</div>
</div>
<div class="filter-group">
<button class="filter-title filter-title-open" type="button" data-filter-toggle aria-expanded="true"><?php esc_html_e( 'Фильтр', 'vyatka-shop' ); ?><?php vyatka_shop_icon( 'caret-up', array( 'class' => 'filter-caret' ) ); ?></button>
<div class="filter-content">
<?php foreach ( $feature_options as $feature_value => $feature_label ) : ?>
<label class="filter-option"<?php echo array_search( $feature_value, array_keys( $feature_options ), true ) >= 5 ? ' data-filter-extra hidden' : ''; ?>>
<input type="checkbox" name="feature[]" value="<?php echo esc_attr( $feature_value ); ?>" data-facet-filter <?php checked( in_array( $feature_value, $selected_features, true ) ); ?>>
<?php echo esc_html( $feature_label ); ?>
</label>
<?php endforeach; ?>
<button class="filter-more" type="button" data-filter-more aria-expanded="false"><?php esc_html_e( 'Показать все', 'vyatka-shop' ); ?></button>
</div>
</div>
<div class="filter-group">
<button class="filter-title filter-title-open" type="button" data-filter-toggle aria-expanded="true"><?php esc_html_e( 'Фильтр', 'vyatka-shop' ); ?><?php vyatka_shop_icon( 'caret-up', array( 'class' => 'filter-caret' ) ); ?></button>
<div class="filter-content">
<?php foreach ( $collection_options as $collection_value => $collection_label ) : ?>
<label class="filter-option"<?php echo array_search( $collection_value, array_keys( $collection_options ), true ) >= 5 ? ' data-filter-extra hidden' : ''; ?>>
<input type="radio" name="collection" value="<?php echo esc_attr( $collection_value ); ?>" data-facet-filter <?php checked( $selected_collection, $collection_value ); ?>>
<?php echo esc_html( $collection_label ); ?>
</label>
<?php endforeach; ?>
<button class="filter-more" type="button" data-filter-more aria-expanded="false"><?php esc_html_e( 'Показать все', 'vyatka-shop' ); ?></button>
</div>
</div>
<?php for ( $filter_index = 0; $filter_index < 3; $filter_index++ ) : ?>
<div class="filter-group filter-collapsed">
<span class="filter-title"><?php esc_html_e( 'Фильтр', 'vyatka-shop' ); ?><?php vyatka_shop_icon( 'caret-up', array( 'class' => 'filter-caret' ) ); ?></span>
</div>
<?php endfor; ?>
</form>
<div class="filters-sheet-foot">
<button class="button button-l filters-apply" type="button" data-filter-panel-close><?php esc_html_e( 'Показать товары', 'vyatka-shop' ); ?></button>
</div>
</aside>
<section class="archive-results">
<?php
/*
* Quick filters. They mirror the collection facet from the sidebar —
* picking a chip selects the matching radio and submits the same
* form, so both controls always agree.
*/
?>
<div class="quick-filters" role="group" aria-label="<?php esc_attr_e( 'Быстрые фильтры', 'vyatka-shop' ); ?>">
<?php foreach ( $collection_options as $collection_value => $collection_label ) : ?>
<button
class="quick-filter<?php echo $selected_collection === $collection_value ? ' is-active' : ''; ?>"
type="button"
data-quick-filter="<?php echo esc_attr( $collection_value ); ?>"
aria-pressed="<?php echo $selected_collection === $collection_value ? 'true' : 'false'; ?>"
><?php echo esc_html( $collection_label ); ?></button>
<?php endforeach; ?>
</div>
<div class="archive-toolbar">
<button class="filters-open" type="button" data-filter-panel-open aria-expanded="false" aria-controls="product-filter-panel">
<?php vyatka_shop_icon( 'funnel', array( 'class' => 'icon-20' ) ); ?><?php esc_html_e( 'Фильтр', 'vyatka-shop' ); ?>
</button>
<div class="sort-control"><span><?php esc_html_e( 'Сортировка', 'vyatka-shop' ); ?></span><?php woocommerce_catalog_ordering(); ?></div>
<label class="per-page-control"><span><?php esc_html_e( 'Выводить товаров', 'vyatka-shop' ); ?></span>
<select class="archive-select" name="per_page" form="product-filters" data-auto-submit data-custom-select aria-label="<?php esc_attr_e( 'Количество товаров', 'vyatka-shop' ); ?>">
<?php foreach ( array( 12, 25, 50 ) as $per_page_option ) : ?>
<option value="<?php echo esc_attr( (string) $per_page_option ); ?>" <?php selected( $selected_per_page, $per_page_option ); ?>><?php echo esc_html( (string) $per_page_option ); ?></option>
<?php endforeach; ?>
</select>
</label>
</div>
<?php if ( woocommerce_product_loop() ) : ?>
<div class="product-grid" data-filter-results>
<?php while ( have_posts() ) : ?>
<?php
the_post();
$GLOBALS['product'] = wc_get_product( get_the_ID() );
get_template_part( 'template-parts/product-card' );
?>
<?php endwhile; ?>
</div>
<p class="filter-empty" data-filter-empty hidden><?php esc_html_e( 'По выбранным параметрам товаров нет.', 'vyatka-shop' ); ?></p>
<?php vyatka_shop_pagination( (int) $GLOBALS['wp_query']->max_num_pages ); ?>
<?php else : ?>
<?php do_action( 'woocommerce_no_products_found' ); ?>
<?php endif; ?>
</section>
</div>
</div>
<?php
get_footer();
+13
View File
@@ -0,0 +1,13 @@
<?php
/**
* Empty cart state.
*
* @package VyatkaShop
*/
defined( 'ABSPATH' ) || exit;
?>
<div class="container cart-page cart-page-empty">
<h1 class="page-heading"><?php esc_html_e( 'Корзина', 'vyatka-shop' ); ?></h1>
<p class="cart-empty-message"><?php esc_html_e( 'Ваша корзина пока пуста.', 'vyatka-shop' ); ?></p>
</div>
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* Cart page.
*
* @package VyatkaShop
*/
defined( 'ABSPATH' ) || exit;
do_action( 'woocommerce_before_cart' );
?>
<div class="container cart-page">
<h1 class="page-heading"><?php esc_html_e( 'Корзина', 'vyatka-shop' ); ?></h1>
<form class="woocommerce-cart-form" action="<?php echo esc_url( wc_get_cart_url() ); ?>" method="post">
<div class="cart-layout">
<div class="cart-items">
<?php foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) : ?>
<?php
$_product = apply_filters( 'woocommerce_cart_item_product', $cart_item['data'], $cart_item, $cart_item_key );
$product_id = apply_filters( 'woocommerce_cart_item_product_id', $cart_item['product_id'], $cart_item, $cart_item_key );
if ( ! $_product || ! $_product->exists() || $cart_item['quantity'] <= 0 ) {
continue;
}
$max_purchase_quantity = $_product->get_max_purchase_quantity();
$regular_total = (float) $_product->get_regular_price() * (int) $cart_item['quantity'];
?>
<article class="cart-item woocommerce-cart-form__cart-item" data-cart-item-key="<?php echo esc_attr( $cart_item_key ); ?>">
<a class="cart-thumb" href="<?php echo esc_url( get_permalink( $product_id ) ); ?>">
<span class="product-badge"><?php esc_html_e( 'NEW', 'vyatka-shop' ); ?></span>
<?php echo wp_kses_post( $_product->get_image( 'thumbnail' ) ); ?>
</a>
<div class="cart-item-copy">
<a class="cart-item-title" href="<?php echo esc_url( get_permalink( $product_id ) ); ?>"><?php echo esc_html( $_product->get_name() ); ?></a>
<span class="cart-item-sku"><?php esc_html_e( 'Арт: 3948GP', 'vyatka-shop' ); ?></span>
</div>
<div class="cart-item-price">
<del><?php echo wp_kses_post( wc_price( $regular_total ) ); ?></del>
<ins><?php echo wp_kses_post( WC()->cart->get_product_subtotal( $_product, $cart_item['quantity'] ) ); ?></ins>
</div>
<div class="quantity-control cart-item-quantity">
<button type="button" data-quantity-action="decrease" aria-label="<?php esc_attr_e( 'Уменьшить количество', 'vyatka-shop' ); ?>"<?php echo (int) $cart_item['quantity'] <= 1 ? ' disabled' : ''; ?>><?php vyatka_shop_icon( 'minus', array( 'class' => 'icon-20' ) ); ?></button>
<input
type="number"
name="cart[<?php echo esc_attr( $cart_item_key ); ?>][qty]"
value="<?php echo esc_attr( (string) $cart_item['quantity'] ); ?>"
min="1"
<?php if ( 0 < $max_purchase_quantity ) : ?>
max="<?php echo esc_attr( (string) $max_purchase_quantity ); ?>"
<?php endif; ?>
inputmode="numeric"
>
<button type="button" data-quantity-action="increase" aria-label="<?php esc_attr_e( 'Увеличить количество', 'vyatka-shop' ); ?>"<?php echo 0 < $max_purchase_quantity && (int) $cart_item['quantity'] >= $max_purchase_quantity ? ' disabled' : ''; ?>><?php vyatka_shop_icon( 'plus', array( 'class' => 'icon-20' ) ); ?></button>
</div>
<a class="cart-item-remove" href="<?php echo esc_url( wc_get_cart_remove_url( $cart_item_key ) ); ?>" aria-label="<?php esc_attr_e( 'Удалить товар', 'vyatka-shop' ); ?>"><?php vyatka_shop_icon( 'trash', array( 'class' => 'icon-20' ) ); ?></a>
</article>
<?php endforeach; ?>
<button class="screen-reader-text" type="submit" name="update_cart" value="<?php esc_attr_e( 'Обновить корзину', 'vyatka-shop' ); ?>"><?php esc_html_e( 'Обновить корзину', 'vyatka-shop' ); ?></button>
<?php wp_nonce_field( 'woocommerce-cart', 'woocommerce-cart-nonce' ); ?>
</div>
<?php $price_summary = vyatka_shop_cart_price_summary(); ?>
<aside class="order-card cart-order-card">
<div class="order-row"><span><?php esc_html_e( 'Товаров', 'vyatka-shop' ); ?></span><span><?php echo esc_html( (string) count( WC()->cart->get_cart() ) ); ?></span></div>
<div class="order-row"><span><?php esc_html_e( 'Сумма', 'vyatka-shop' ); ?></span><strong><?php echo wp_kses_post( wc_price( $price_summary['regular_total'] ) ); ?></strong></div>
<div class="order-row"><span><?php esc_html_e( 'Скидка', 'vyatka-shop' ); ?></span><span> <?php echo wp_kses_post( wc_price( $price_summary['discount_total'] ) ); ?></span></div>
<div class="order-row total"><span><?php esc_html_e( 'Итого', 'vyatka-shop' ); ?></span><strong><?php wc_cart_totals_order_total_html(); ?></strong></div>
<a class="button" href="<?php echo esc_url( wc_get_checkout_url() ); ?>"><?php esc_html_e( 'Перейти к оформлению', 'vyatka-shop' ); ?></a>
</aside>
</div>
</form>
</div>
<?php
do_action( 'woocommerce_after_cart' );
+108
View File
@@ -0,0 +1,108 @@
<?php
/**
* Checkout form.
*
* @package VyatkaShop
*
* @var WC_Checkout $checkout Checkout object.
*/
defined( 'ABSPATH' ) || exit;
if ( ! $checkout->is_registration_enabled() && $checkout->is_registration_required() && ! is_user_logged_in() ) {
echo esc_html( apply_filters( 'woocommerce_checkout_must_be_logged_in_message', __( 'Для оформления заказа необходимо войти.', 'vyatka-shop' ) ) );
return;
}
$price_summary = vyatka_shop_cart_price_summary();
?>
<div class="container checkout-page">
<h1 class="page-heading"><?php esc_html_e( 'Оформление заказа', 'vyatka-shop' ); ?></h1>
<form name="checkout" method="post" class="checkout woocommerce-checkout" action="<?php echo esc_url( wc_get_checkout_url() ); ?>" enctype="multipart/form-data">
<div class="checkout-layout">
<div class="checkout-fields">
<label class="checkout-input">
<input type="text" name="billing_first_name" autocomplete="given-name" placeholder=" " required>
<span class="field-label"><?php esc_html_e( 'Имя', 'vyatka-shop' ); ?></span>
<span class="field-error" data-field-error aria-live="polite"></span>
</label>
<label class="checkout-input">
<input type="tel" name="billing_phone" autocomplete="tel" placeholder=" " required>
<span class="field-label"><?php esc_html_e( 'Телефон', 'vyatka-shop' ); ?></span>
<span class="field-error" data-field-error aria-live="polite"></span>
</label>
<label class="checkout-input">
<input type="email" name="billing_email" autocomplete="email" placeholder=" " required>
<span class="field-label"><?php esc_html_e( 'Email', 'vyatka-shop' ); ?></span>
<span class="field-error" data-field-error aria-live="polite"></span>
</label>
<input type="hidden" name="billing_country" value="RU">
<fieldset class="checkout-delivery">
<legend><?php esc_html_e( 'Получение заказа', 'vyatka-shop' ); ?></legend>
<div class="delivery-switch" role="group" aria-label="<?php esc_attr_e( 'Способ получения', 'vyatka-shop' ); ?>">
<label><input type="radio" name="delivery_type" value="pickup" checked><span><?php esc_html_e( 'Самовывоз', 'vyatka-shop' ); ?></span></label>
<label><input type="radio" name="delivery_type" value="delivery"><span><?php esc_html_e( 'Доставка', 'vyatka-shop' ); ?></span></label>
</div>
<div data-delivery-panel="pickup">
<label class="checkout-radio"><input type="radio" name="pickup_address" value="1" checked> <span><?php esc_html_e( 'Москва, ул.Тверская, 168', 'vyatka-shop' ); ?></span></label>
<label class="checkout-radio"><input type="radio" name="pickup_address" value="2"> <span><?php esc_html_e( 'Москва, Садовое кольцо, 152к4', 'vyatka-shop' ); ?></span></label>
<label class="checkout-radio"><input type="radio" name="pickup_address" value="3"> <span><?php esc_html_e( 'Киров, Октябрьский проспект, 290к3', 'vyatka-shop' ); ?></span></label>
<img class="checkout-map" src="<?php echo esc_url( get_template_directory_uri() . '/assets/images/checkout-map.jpg' ); ?>" alt="<?php esc_attr_e( 'Пункты получения на карте', 'vyatka-shop' ); ?>">
</div>
<div class="delivery-address-fields" data-delivery-panel="delivery" hidden>
<label class="checkout-input">
<input type="text" name="delivery_address" placeholder=" " autocomplete="street-address" data-delivery-required disabled>
<span class="field-label"><?php esc_html_e( 'Адрес доставки', 'vyatka-shop' ); ?></span>
<span class="field-error" data-field-error aria-live="polite"></span>
</label>
<label class="checkout-input">
<input type="text" name="delivery_unit" placeholder=" " disabled>
<span class="field-label"><?php esc_html_e( 'Квартира или офис', 'vyatka-shop' ); ?></span>
<span class="field-error" data-field-error aria-live="polite"></span>
</label>
</div>
</fieldset>
<fieldset class="checkout-payment-options">
<legend><?php esc_html_e( 'Способ оплаты', 'vyatka-shop' ); ?></legend>
<label class="checkout-radio"><input type="radio" name="payment_method" value="cod" checked> <span><?php esc_html_e( 'Онлайн оплата', 'vyatka-shop' ); ?></span></label>
<label class="checkout-radio"><input type="radio" name="payment_method" value="bacs"> <span><?php esc_html_e( 'Оплата при получении', 'vyatka-shop' ); ?></span></label>
</fieldset>
</div>
<aside class="order-card checkout-order-card" id="order_review">
<div class="checkout-order-products">
<?php foreach ( WC()->cart->get_cart() as $cart_item ) : ?>
<?php
$_product = $cart_item['data'];
if ( ! $_product || ! $_product->exists() ) {
continue;
}
?>
<div class="checkout-order-product">
<div class="checkout-order-thumb"><?php echo wp_kses_post( $_product->get_image( 'thumbnail' ) ); ?></div>
<div>
<strong><?php echo esc_html( $_product->get_name() ); ?></strong>
<span><?php echo wp_kses_post( WC()->cart->get_product_subtotal( $_product, $cart_item['quantity'] ) ); ?> <span class="multiply" aria-hidden="true"></span><?php echo esc_html( (string) $cart_item['quantity'] ); ?></span>
</div>
</div>
<?php endforeach; ?>
</div>
<div class="checkout-order-totals">
<div class="order-row"><span><?php esc_html_e( 'Товаров в корзине', 'vyatka-shop' ); ?></span><span><?php echo esc_html( (string) count( WC()->cart->get_cart() ) ); ?></span></div>
<div class="order-row"><span><?php esc_html_e( 'Сумма заказа', 'vyatka-shop' ); ?></span><strong><?php echo wp_kses_post( wc_price( $price_summary['regular_total'] ) ); ?></strong></div>
<div class="order-row"><span><?php esc_html_e( 'Скидка', 'vyatka-shop' ); ?></span><span> <?php echo wp_kses_post( wc_price( $price_summary['discount_total'] ) ); ?></span></div>
<div class="order-row total"><span><?php esc_html_e( 'Итого', 'vyatka-shop' ); ?></span><strong><?php wc_cart_totals_order_total_html(); ?></strong></div>
</div>
<label class="checkout-consent">
<input type="checkbox" name="terms" value="1" required checked data-consent>
<span><?php esc_html_e( 'Даю согласие на обработку персональных данных', 'vyatka-shop' ); ?></span>
</label>
<?php wp_nonce_field( 'woocommerce-process_checkout', 'woocommerce-process-checkout-nonce' ); ?>
<button type="submit" class="button" name="woocommerce_checkout_place_order" id="place_order" data-consent-gated value="<?php esc_attr_e( 'Оформить заказ', 'vyatka-shop' ); ?>"><?php esc_html_e( 'Оформить заказ', 'vyatka-shop' ); ?></button>
</aside>
</div>
</form>
</div>
+9
View File
@@ -0,0 +1,9 @@
<?php
/**
* WooCommerce product card adapter.
*
* @package VyatkaShop
*/
defined( 'ABSPATH' ) || exit;
get_template_part( 'template-parts/product-card' );
+177
View File
@@ -0,0 +1,177 @@
<?php
/**
* Single product page.
*
* @package VyatkaShop
*/
defined( 'ABSPATH' ) || exit;
get_header();
while ( have_posts() ) :
the_post();
global $product;
if ( post_password_required() ) {
echo get_the_password_form(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
continue;
}
$attributes = $product->get_attributes();
$max_purchase_quantity = $product->get_max_purchase_quantity();
$related = wc_get_products(
array(
'include' => wc_get_related_products( $product->get_id(), 8 ),
'limit' => 8,
)
);
$gallery_image_ids = array_values(
array_unique(
array_filter(
array_merge(
array( $product->get_image_id() ),
$product->get_gallery_image_ids()
)
)
)
);
?>
<div class="container">
<?php vyatka_shop_breadcrumbs( $product->get_name() ); ?>
<?php wc_print_notices(); ?>
<article id="product-<?php the_ID(); ?>" <?php wc_product_class( '', $product ); ?>>
<div class="product-layout">
<div class="product-gallery">
<div class="gallery-badges">
<span class="product-badge"><?php esc_html_e( 'NEW', 'vyatka-shop' ); ?></span>
<span class="product-badge"><?php esc_html_e( 'NEW', 'vyatka-shop' ); ?></span>
<span class="product-badge"><?php esc_html_e( 'NEW', 'vyatka-shop' ); ?></span>
</div>
<button class="gallery-favorite" type="button" aria-label="<?php esc_attr_e( 'Добавить в избранное', 'vyatka-shop' ); ?>" aria-pressed="false"><?php vyatka_shop_icon( 'heart' ); ?></button>
<button class="gallery-arrow gallery-arrow-prev" type="button" aria-label="<?php esc_attr_e( 'Предыдущее изображение', 'vyatka-shop' ); ?>"><?php vyatka_shop_icon( 'arrow-right-fill', array( 'class' => 'icon-flip' ) ); ?></button>
<button class="gallery-arrow gallery-arrow-next" type="button" aria-label="<?php esc_attr_e( 'Следующее изображение', 'vyatka-shop' ); ?>"><?php vyatka_shop_icon( 'arrow-right-fill' ); ?></button>
<div class="gallery-images" data-product-gallery>
<?php for ( $gallery_index = 0; $gallery_index < max( 5, count( $gallery_image_ids ) ); $gallery_index++ ) : ?>
<?php $gallery_image_id = $gallery_image_ids[ $gallery_index % max( 1, count( $gallery_image_ids ) ) ] ?? 0; ?>
<?php if ( $gallery_image_id ) : ?>
<?php
echo wp_kses_post(
wp_get_attachment_image(
$gallery_image_id,
'full',
false,
array(
'class' => 'gallery-image',
'data-gallery-slide' => '',
'hidden' => 0 === $gallery_index ? false : true,
)
)
);
?>
<?php else : ?>
<span class="gallery-image gallery-image-placeholder" data-gallery-slide <?php echo 0 === $gallery_index ? '' : 'hidden'; ?>></span>
<?php endif; ?>
<?php endfor; ?>
</div>
<div class="gallery-dots" aria-label="<?php esc_attr_e( 'Изображения товара', 'vyatka-shop' ); ?>">
<?php for ( $gallery_index = 0; $gallery_index < max( 5, count( $gallery_image_ids ) ); $gallery_index++ ) : ?>
<i data-gallery-dot<?php echo 0 === $gallery_index ? ' class="active"' : ''; ?>></i>
<?php endfor; ?>
</div>
</div>
<div class="product-summary">
<div class="product-meta-line">
<?php esc_html_e( 'Арт: 4654GR', 'vyatka-shop' ); ?>
<span class="meta-separator" aria-hidden="true"></span><span class="stock"><?php echo esc_html( $product->is_in_stock() ? __( 'В наличии', 'vyatka-shop' ) : __( 'Нет в наличии', 'vyatka-shop' ) ); ?></span>
</div>
<h1><?php the_title(); ?></h1>
<div class="woocommerce-product-details__short-description">
<?php echo wp_kses_post( $product->get_short_description() ?: vyatka_shop_placeholder_copy() ); ?>
</div>
<table class="attribute-table">
<?php for ( $row_index = 0; $row_index < 4; $row_index++ ) : ?>
<tr><td><?php esc_html_e( 'Характеристика', 'vyatka-shop' ); ?></td><td><?php esc_html_e( 'Значение', 'vyatka-shop' ); ?></td></tr>
<?php endfor; ?>
</table>
<label class="product-option">
<span><?php esc_html_e( 'Свойство', 'vyatka-shop' ); ?></span>
<select name="product_option" form="product-cart-form" data-custom-select>
<option value="dark-gray"><?php esc_html_e( 'Темно-серый', 'vyatka-shop' ); ?></option>
</select>
</label>
<div class="price"><?php echo wp_kses_post( $product->get_price_html() ); ?></div>
<?php if ( $product->is_purchasable() && $product->is_in_stock() ) : ?>
<form class="cart" id="product-cart-form" action="<?php echo esc_url( $product->get_permalink() ); ?>" method="post" enctype="multipart/form-data" data-ajax-add-to-cart>
<div class="quantity-control">
<button type="button" data-quantity-action="decrease" aria-label="<?php esc_attr_e( 'Уменьшить количество', 'vyatka-shop' ); ?>" disabled><?php vyatka_shop_icon( 'minus', array( 'class' => 'icon-20' ) ); ?></button>
<input
type="number"
name="quantity"
value="1"
min="1"
<?php if ( 0 < $max_purchase_quantity ) : ?>
max="<?php echo esc_attr( (string) $max_purchase_quantity ); ?>"
<?php endif; ?>
inputmode="numeric"
>
<button type="button" data-quantity-action="increase" aria-label="<?php esc_attr_e( 'Увеличить количество', 'vyatka-shop' ); ?>"<?php echo 1 === $max_purchase_quantity ? ' disabled' : ''; ?>><?php vyatka_shop_icon( 'plus', array( 'class' => 'icon-20' ) ); ?></button>
</div>
<button class="button single_add_to_cart_button" type="submit" name="add-to-cart" value="<?php echo esc_attr( (string) $product->get_id() ); ?>"><?php vyatka_shop_icon( 'cart', array( 'class' => 'icon-20' ) ); ?><?php esc_html_e( 'В корзину', 'vyatka-shop' ); ?></button>
</form>
<?php endif; ?>
<a class="button button-outline buy-one-click" href="<?php echo esc_url( add_query_arg( array( 'add-to-cart' => $product->get_id(), 'quantity' => 1 ), wc_get_checkout_url() ) ); ?>"><?php esc_html_e( 'Купить в 1 клик', 'vyatka-shop' ); ?></a>
</div>
</div>
<div class="product-tabs">
<ul class="tab-list" role="tablist">
<li class="active"><a href="#product-tab-description" role="tab" aria-selected="true" aria-controls="product-tab-description" data-tab="description"><?php esc_html_e( 'Описание', 'vyatka-shop' ); ?></a></li>
<li><a href="#product-tab-attributes" role="tab" aria-selected="false" aria-controls="product-tab-attributes" data-tab="attributes"><?php esc_html_e( 'Характеристики', 'vyatka-shop' ); ?></a></li>
<li><a href="#product-tab-documents" role="tab" aria-selected="false" aria-controls="product-tab-documents" data-tab="documents"><?php esc_html_e( 'Документы', 'vyatka-shop' ); ?></a></li>
<li><a href="#product-tab-shipping" role="tab" aria-selected="false" aria-controls="product-tab-shipping" data-tab="shipping"><?php esc_html_e( 'Оплата и доставка', 'vyatka-shop' ); ?></a></li>
</ul>
<div class="prose" id="product-tab-description" role="tabpanel" data-tab-panel="description">
<?php echo wp_kses_post( $product->get_description() ?: wpautop( vyatka_shop_placeholder_copy() . ' ' . vyatka_shop_placeholder_copy() ) ); ?>
</div>
<div class="prose" id="product-tab-attributes" role="tabpanel" data-tab-panel="attributes" hidden>
<?php wc_display_product_attributes( $product ); ?>
</div>
<div class="prose" id="product-tab-documents" role="tabpanel" data-tab-panel="documents" hidden>
<p><?php esc_html_e( 'Проектная документация и спецификация предоставляются после оформления заказа.', 'vyatka-shop' ); ?></p>
</div>
<div class="prose" id="product-tab-shipping" role="tabpanel" data-tab-panel="shipping" hidden>
<p><?php esc_html_e( 'Стоимость и сроки доставки рассчитываются менеджером после уточнения адреса и комплектации.', 'vyatka-shop' ); ?></p>
</div>
</div>
<?php if ( $related ) : ?>
<section class="section">
<div class="section-head">
<h2 class="section-title"><?php esc_html_e( 'Похожие товары', 'vyatka-shop' ); ?></h2>
<div class="slider-controls" data-carousel-controls="related-products">
<button type="button" data-carousel-prev aria-label="<?php esc_attr_e( 'Предыдущие похожие товары', 'vyatka-shop' ); ?>"><?php vyatka_shop_icon( 'arrow-right', array( 'class' => 'icon-flip icon-20' ) ); ?></button>
<button type="button" data-carousel-next aria-label="<?php esc_attr_e( 'Следующие похожие товары', 'vyatka-shop' ); ?>"><?php vyatka_shop_icon( 'arrow-right', array( 'class' => 'icon-20' ) ); ?></button>
</div>
</div>
<div class="product-grid" id="related-products" data-carousel>
<?php foreach ( $related as $related_product ) : ?>
<?php
$GLOBALS['product'] = $related_product;
get_template_part( 'template-parts/product-card' );
?>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
</article>
</div>
<?php
endwhile;
get_footer();