Files
f1den 29d10ce4cf Тема магазина по макету Figma
Каталог, карточка товара, корзина, оформление заказа и блог на WooCommerce.

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

Иконки — Phosphor, вставляются инлайном как SVG, символами ничего не рисуется.
Стили WooCommerce отключены, вёрстка своя, !important не используется.
2026-07-30 22:38:49 +03:00

374 lines
13 KiB
PHP

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