Каталог, карточка товара, корзина, оформление заказа и блог на WooCommerce. Стили разложены по слоям: токены из Figma, база, компоненты, страницы, брейкпоинты. Цвета, типографика, радиусы и отступы взяты из Variables и текстовых стилей макета, ниже по слоям хардкода нет. Иконки — Phosphor, вставляются инлайном как SVG, символами ничего не рисуется. Стили WooCommerce отключены, вёрстка своя, !important не используется.
374 lines
13 KiB
PHP
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' );
|