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

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

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

Иконки — Phosphor, вставляются инлайном как SVG, символами ничего не рисуется.
Стили WooCommerce отключены, вёрстка своя, !important не используется.
This commit is contained in:
f1den committed 2026-07-30 22:38:49 +03:00
commit 29d10ce4cf
72 files changed
+7465

No files matched your search

+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' );