feat(shop): category filter bar + sort/load-more 1:1 UX

Port zibll hierarchical cat tabs, multi-metric sort with price toggle,
skeleton/empty states, and REST archive payload (header/html/paginate)
while reusing product-card template for AJAX HTML.
This commit is contained in:
2026-07-18 16:42:52 +08:00
parent e2587ca3a0
commit 433b7cb3a6
9 changed files with 828 additions and 273 deletions
@@ -363,75 +363,92 @@ trait ShopControllerFrontendFlowOperations {
$term_id = (int) ($request->get_param('term_id') ?? 0);
$page = max(1, (int) ($request->get_param('page') ?? 1));
$orderby = sanitize_key((string) ($request->get_param('orderby') ?? ''));
$order = sanitize_key((string) ($request->get_param('order') ?? ''));
$view = \AKSRT\Shop\Shop::get_instance()->get_product_archive_view([
'taxonomy' => $taxonomy,
'term_id' => $term_id,
'paged' => $page,
'orderby' => $orderby,
'order' => $order,
]);
$sales_cfg = is_array($view['sales'] ?? null) ? $view['sales'] : [];
$html = '';
if (!empty($view['products']) && is_array($view['products'])) {
ob_start();
foreach ($view['products'] as $product) {
$this->render_product_card_html($product, $view);
if (function_exists('get_template_part')) {
get_template_part('templates/parts/shop/product', 'card', [
'product' => $product,
'sales_cfg' => $sales_cfg,
]);
}
}
$html = (string) ob_get_clean();
}
$empty_html = '';
if ($html === '' && function_exists('aksrt_get_ajax_null')) {
$empty_html = aksrt_get_ajax_null(__('内容空空如也', 'aksrt'));
} elseif ($html === '') {
$empty_html = '<div class="aksrt-product-archive__empty ajax-item"><p>' . esc_html__('内容空空如也', 'aksrt') . '</p></div>';
}
// Header HTML for cat filter replace (when filter present).
$header_html = '';
if (!empty($view['filter']) && is_array($view['filter'])) {
ob_start();
$this->render_product_cat_filter_html($view);
$header_html = (string) ob_get_clean();
}
$paginate_html = '';
$page_n = (int) ($view['page'] ?? 1);
$max_pages = (int) ($view['max_pages'] ?? 1);
$has_more = $page_n < $max_pages;
if ($has_more && (string) ($view['paging'] ?? '') === 'ajax') {
$next_url = add_query_arg([
'taxonomy' => $taxonomy,
'term_id' => $term_id,
'page' => $page_n + 1,
'orderby' => (string) ($view['orderby'] ?? 'date'),
'order' => (string) ($view['order'] ?? ''),
], rest_url('aksrt/v1/shop/frontend/products/archive'));
if (function_exists('aksrt_get_ajax_next_paginate')) {
$paginate_html = aksrt_get_ajax_next_paginate(
(int) ($view['total'] ?? 0),
$page_n,
max(1, (int) ($view['per_page'] ?? 12)),
$next_url
);
}
}
return $this->success([
'data' => array_merge($view, [
'html' => $html,
'has_more' => (int) ($view['page'] ?? 1) < (int) ($view['max_pages'] ?? 1),
'html' => $html !== '' ? $html : $empty_html,
'empty_html' => $empty_html,
'header_html' => $header_html,
'paginate_html' => $paginate_html,
'has_more' => $has_more,
'title' => !empty($view['term']['name'])
? (string) $view['term']['name'] . ' - ' . wp_strip_all_tags(get_bloginfo('name'))
: '',
]),
]);
}
private function render_product_card_html(array $product, array $view = []): void {
$sales_cfg = is_array($view['sales'] ?? null) ? $view['sales'] : [];
$mode = (string) ($sales_cfg['show'] ?? 'show');
$min_sales = max(0, (int) ($sales_cfg['show_min'] ?? 0));
$sales = (int) ($product['sales'] ?? 0);
$can_show_sales = false;
if ($mode === 'show') {
$can_show_sales = $sales > 0;
} elseif ($mode === 'threshold') {
$can_show_sales = $sales >= $min_sales;
}
$permalink = (string) ($product['permalink'] ?? '#');
$title = (string) ($product['title'] ?? '');
$image = (string) ($product['image'] ?? '');
$price = (float) ($product['price'] ?? 0);
$excerpt = (string) ($product['excerpt'] ?? '');
?>
<article class="aksrt-product-card">
<a href="<?php echo esc_url($permalink); ?>" class="aksrt-product-card__link">
<?php if ($image !== '') : ?>
<div class="aksrt-product-card__image">
<img src="<?php echo esc_url($image); ?>" alt="<?php echo esc_attr($title); ?>" loading="lazy">
</div>
<?php else : ?>
<div class="aksrt-product-card__image aksrt-product-card__image--placeholder">
<i class="fas fa-image"></i>
</div>
<?php endif; ?>
<div class="aksrt-product-card__body">
<h3 class="aksrt-product-card__title"><?php echo esc_html($title); ?></h3>
<?php if ($excerpt !== '') : ?>
<p class="aksrt-product-card__excerpt"><?php echo esc_html(wp_trim_words($excerpt, 15)); ?></p>
<?php endif; ?>
<div class="aksrt-product-card__footer">
<span class="aksrt-product-card__price">¥<?php echo esc_html(number_format($price, 2)); ?></span>
<?php if ($can_show_sales) : ?>
<span class="aksrt-product-card__sales"><?php printf(esc_html__('已售 %d', 'aksrt'), $sales); ?></span>
<?php endif; ?>
</div>
</div>
</a>
</article>
<?php
/**
* Render cat filter bar HTML for AJAX header replace.
*/
private function render_product_cat_filter_html(array $view): void {
$filter = is_array($view['filter'] ?? null) ? $view['filter'] : [];
$orderby = (string) ($view['orderby'] ?? ($filter['orderby'] ?? 'date'));
$order = strtoupper((string) ($view['order'] ?? ($filter['order'] ?? 'DESC')));
$term = is_array($view['term'] ?? null) ? $view['term'] : [];
$current_id = (int) ($filter['current_id'] ?? ($term['id'] ?? 0));
include get_template_directory() . '/templates/parts/shop/product-cat-filter.php';
}
public function get_frontend_revenue(\WP_REST_Request $request): \WP_REST_Response {
+163 -10
View File
@@ -57,10 +57,39 @@ trait Part1 {
$shop_settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_shop_settings() : [];
$list = is_array($shop_settings['list'] ?? null) ? $shop_settings['list'] : [];
$per_page = max(4, min(100, (int) ($args['per_page'] ?? ($list['count'] ?? 12))));
$orderby = sanitize_key((string) ($args['orderby'] ?? ($list['orderby'] ?? 'date')));
if (!in_array($orderby, ['date', 'sales', 'price_asc', 'price_desc', 'comments', 'modified'], true)) {
$orderby = 'date';
// Normalize orderby aliases (zibll + legacy AKSRT keys).
$orderby_raw = sanitize_key((string) ($args['orderby'] ?? ($list['orderby'] ?? 'date')));
$order = strtoupper(sanitize_key((string) ($args['order'] ?? '')));
if (!in_array($order, ['ASC', 'DESC'], true)) {
$order = '';
}
// Legacy split price keys → price + order.
if ($orderby_raw === 'price_asc') {
$orderby_raw = 'price';
$order = 'ASC';
} elseif ($orderby_raw === 'price_desc') {
$orderby_raw = 'price';
$order = 'DESC';
} elseif ($orderby_raw === 'sales_volume') {
$orderby_raw = 'sales';
} elseif ($orderby_raw === 'zibpay_price') {
$orderby_raw = 'price';
} elseif ($orderby_raw === 'comments') {
$orderby_raw = 'score';
}
$allowed_orderby = ['date', 'views', 'favorite_count', 'score', 'sales', 'price', 'modified'];
if (!in_array($orderby_raw, $allowed_orderby, true)) {
$orderby_raw = 'date';
}
if ($orderby_raw === 'price' && $order === '') {
$order = 'ASC';
}
if ($order === '' && $orderby_raw !== 'price') {
$order = 'DESC';
}
$orderby = $orderby_raw;
$paging = sanitize_key((string) ($args['paging'] ?? ($list['paging'] ?? 'number')));
if (!in_array($paging, ['number', 'ajax'], true)) {
$paging = 'number';
@@ -76,12 +105,14 @@ trait Part1 {
'max_pages' => 0,
'page' => $paged,
'orderby' => $orderby,
'order' => $order,
'paging' => $paging,
'ias_s' => $ias_s,
'ias_max' => $ias_max,
'per_page' => $per_page,
'style' => $style,
'sales' => is_array($shop_settings['sales'] ?? null) ? $shop_settings['sales'] : [],
'filter' => null,
];
if (!in_array($taxonomy, ['aksrt_product_cat', 'aksrt_product_tag'], true) && empty($args['allow_all'])) {
@@ -93,7 +124,7 @@ trait Part1 {
'posts_per_page' => $per_page,
'paged' => $paged,
'post_status' => 'publish',
'order' => 'DESC',
'order' => $order ?: 'DESC',
'orderby' => 'date',
];
@@ -114,16 +145,21 @@ trait Part1 {
$query_args['meta_key'] = '_aksrt_sales';
$query_args['orderby'] = 'meta_value_num';
$query_args['order'] = 'DESC';
} elseif ($orderby === 'price_asc') {
} elseif ($orderby === 'price') {
$query_args['meta_key'] = '_aksrt_price';
$query_args['orderby'] = 'meta_value_num';
$query_args['order'] = 'ASC';
} elseif ($orderby === 'price_desc') {
$query_args['meta_key'] = '_aksrt_price';
$query_args['order'] = $order === 'ASC' ? 'ASC' : 'DESC';
} elseif ($orderby === 'favorite_count') {
$query_args['meta_key'] = '_aksrt_favorite_count';
$query_args['orderby'] = 'meta_value_num';
$query_args['order'] = 'DESC';
} elseif ($orderby === 'comments') {
$query_args['orderby'] = 'comment_count';
} elseif ($orderby === 'views') {
$query_args['meta_key'] = '_aksrt_views';
$query_args['orderby'] = 'meta_value_num';
$query_args['order'] = 'DESC';
} elseif ($orderby === 'score') {
$query_args['meta_key'] = '_aksrt_score_average';
$query_args['orderby'] = 'meta_value_num';
$query_args['order'] = 'DESC';
} elseif ($orderby === 'modified') {
$query_args['orderby'] = 'modified';
@@ -138,6 +174,11 @@ trait Part1 {
}
wp_reset_postdata();
$filter = null;
if ($taxonomy === 'aksrt_product_cat' && $term) {
$filter = $this->build_product_cat_filter((int) $term->term_id, $orderby, $order);
}
return [
'products' => $products,
'term' => $term ? [
@@ -146,17 +187,129 @@ trait Part1 {
'description' => (string) $term->description,
'count' => (int) $term->count,
'taxonomy' => $taxonomy,
'parent' => (int) $term->parent,
'url' => get_term_link($term),
] : null,
'total' => (int) $query->found_posts,
'max_pages' => (int) $query->max_num_pages,
'page' => $paged,
'orderby' => $orderby,
'order' => $order,
'paging' => $paging,
'ias_s' => $ias_s,
'ias_max' => $ias_max,
'per_page' => $per_page,
'style' => $style,
'sales' => is_array($shop_settings['sales'] ?? null) ? $shop_settings['sales'] : [],
'filter' => $filter,
];
}
/**
* Hierarchical cat filter data (zibll zib_shop_get_cat_filter).
*
* @return array{siblings:array<int,array>,children:array<int,array>,parent_id:int,mode:string}
*/
public function build_product_cat_filter(int $cat_id, string $orderby = 'date', string $order = 'DESC'): array {
$taxonomy = 'aksrt_product_cat';
$cat = get_term($cat_id, $taxonomy);
if (!$cat || is_wp_error($cat)) {
return ['siblings' => [], 'children' => [], 'parent_id' => 0, 'mode' => 'flat', 'orderby' => $orderby, 'order' => $order];
}
$map_term = static function ($t) use ($orderby, $order): array {
$url = get_term_link($t);
if (!is_wp_error($url) && $orderby) {
$url = add_query_arg(array_filter([
'orderby' => $orderby,
'order' => $orderby === 'price' ? $order : null,
]), $url);
}
return [
'id' => (int) $t->term_id,
'name' => (string) $t->name,
'url' => is_wp_error($url) ? '' : (string) $url,
'count' => (int) $t->count,
];
};
$parent_id = (int) $cat->parent;
$children = get_terms([
'taxonomy' => $taxonomy,
'parent' => $cat_id,
'hide_empty' => false,
]);
if (is_wp_error($children)) {
$children = [];
}
if ($parent_id > 0) {
$siblings = get_terms([
'taxonomy' => $taxonomy,
'parent' => $parent_id,
'hide_empty' => false,
]);
} else {
$siblings = get_terms([
'taxonomy' => $taxonomy,
'parent' => 0,
'hide_empty' => false,
]);
}
if (is_wp_error($siblings)) {
$siblings = [];
}
$mode = 'siblings';
$parent_terms = [];
if (empty($children) && $parent_id > 0) {
$parent_term = get_term($parent_id, $taxonomy);
if ($parent_term && !is_wp_error($parent_term) && (int) $parent_term->parent > 0) {
$parent_terms = get_terms([
'taxonomy' => $taxonomy,
'parent' => (int) $parent_term->parent,
'hide_empty' => false,
]);
if (is_wp_error($parent_terms)) {
$parent_terms = [];
}
$mode = 'parent_siblings';
} elseif ($parent_id > 0) {
$parent_terms = get_terms([
'taxonomy' => $taxonomy,
'parent' => 0,
'hide_empty' => false,
]);
if (is_wp_error($parent_terms)) {
$parent_terms = [];
}
$mode = 'parent_siblings';
}
} elseif (!empty($children)) {
$mode = 'with_children';
}
$parent_url = '';
if ($parent_id > 0) {
$plink = get_term_link($parent_id, $taxonomy);
if (!is_wp_error($plink)) {
$parent_url = add_query_arg(array_filter([
'orderby' => $orderby,
'order' => $orderby === 'price' ? $order : null,
]), $plink);
}
}
return [
'mode' => $mode,
'current_id' => $cat_id,
'parent_id' => $parent_id,
'parent_url' => $parent_url,
'siblings' => array_map($map_term, empty($parent_terms) ? $siblings : $parent_terms),
'children' => array_map($map_term, $mode === 'with_children' ? $children : ($mode === 'parent_siblings' ? $siblings : [])),
'child_tabs' => array_map($map_term, $children),
'orderby' => $orderby,
'order' => $order,
];
}
+2 -1
View File
@@ -85,7 +85,8 @@ class ProductReview {
update_comment_meta($comment_id, '_aksrt_review_images', $images);
}
$this->refresh_product_scores($product_id);
$scores = $this->refresh_product_scores($product_id);
update_post_meta($product_id, '_aksrt_score_average', (float) ($scores['average'] ?? $average));
if (class_exists('\AKSRT\Payment\OrderRepository')) {
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);