diff --git a/assets/css/frontend/forum/board.css b/assets/css/frontend/forum/board.css index 039319e3..df23dd1c 100644 --- a/assets/css/frontend/forum/board.css +++ b/assets/css/frontend/forum/board.css @@ -586,6 +586,104 @@ text-shadow: 0 1px 2px rgba(0,0,0,.35); } +/* detail list card (zibll forum-posts detail) */ +.aksrt-forum-topic-row.detail, +.aksrt-forum-posts.detail { + display: block; + padding: 16px; + margin-bottom: 12px; + border-radius: 14px; + background: var(--surface-1, #fff); + box-shadow: var(--shadow-sm, 0 1px 4px rgba(0,0,0,.06)); +} +.aksrt-forum-topic-row__author-card { gap: 10px; } +.aksrt-forum-topic-row__author-name { font-weight: 700; font-size: 14px; } +.aksrt-forum-topic-row.detail .aksrt-forum-topic-row__cover, +.aksrt-forum-posts.detail .forum-lists-cover { + display: block; + width: 100%; + height: auto; + max-height: 280px; + margin: 0 0 12px; + border-radius: 12px; + overflow: hidden; +} +.aksrt-forum-topic-row.detail .aksrt-forum-topic-row__cover img { + width: 100%; + height: auto; + max-height: 280px; + object-fit: cover; + display: block; +} +.aksrt-forum-topic-row .excerpt { + margin-top: 6px; + font-size: 14px; + line-height: 1.65; + color: var(--color-ink-2, #4b5563); +} +.aksrt-forum-topic-row .lists-imgs { + display: grid; + gap: 6px; + margin-top: 8px; +} +.aksrt-forum-topic-row .lists-imgs.count-1 { grid-template-columns: 1fr; max-width: 280px; } +.aksrt-forum-topic-row .lists-imgs.count-2 { grid-template-columns: 1fr 1fr; } +.aksrt-forum-topic-row .lists-imgs.count-3, +.aksrt-forum-topic-row .lists-imgs.count-4 { grid-template-columns: 1fr 1fr; } +.aksrt-forum-topic-row .lists-imgs__item { + display: block; + border-radius: 10px; + overflow: hidden; + aspect-ratio: 1; + background: var(--surface-2, #f3f4f6); +} +.aksrt-forum-topic-row .lists-imgs__item img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.aksrt-forum-topic-row__vote { display: grid; gap: 6px; } +.aksrt-forum-topic-row__vote-opt { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(80px, 2fr) 42px; + gap: 8px; + align-items: center; + font-size: 13px; +} +.aksrt-forum-topic-row__vote-bar { + height: 8px; + border-radius: 999px; + background: var(--surface-4, #eef0f3); + overflow: hidden; +} +.aksrt-forum-topic-row__vote-bar i { + display: block; + height: 100%; + background: var(--color-primary, #f04494); +} +.aksrt-forum-topic-row__footer .but-plate, +.aksrt-forum-topic-row__board.but { + display: inline-flex; + align-items: center; + max-width: 46%; + padding: 4px 10px; + border-radius: 999px; + background: var(--surface-4, #f3f4f6); + color: var(--color-ink-2, #5c6370); + text-decoration: none; + font-size: 12px; + font-weight: 600; +} +.aksrt-forum-topic-row__actions { + display: inline-flex; + gap: 12px; + color: var(--color-ink-3, #8a909c); + font-size: 13px; +} +.aksrt-forum-topic-row__actions a { color: inherit; text-decoration: none; } +.aksrt-forum-topic-row.mini { display: flex; gap: 12px; align-items: flex-start; } + .aksrt-forum-board-card__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); diff --git a/assets/css/frontend/product-archive.css b/assets/css/frontend/product-archive.css index fdc26368..8ae89bc3 100644 --- a/assets/css/frontend/product-archive.css +++ b/assets/css/frontend/product-archive.css @@ -733,3 +733,23 @@ .aksrt-revenue-page__item .is-in{color:#27ae60;font-weight:700} .aksrt-revenue-page__item .is-out{color:#e74c3c;font-weight:700} .aksrt-product-editor__hint{font-size:13px;color:var(--color-ink-4,#8a909c);margin:0 0 10px} + +/* ===== zibll-parity cat filter / skeleton ===== */ +.aksrt-shop-cat-filter.shop-cat-filter{margin-top:14px} +.shop-cat-filter-tab{display:flex;gap:10px;overflow-x:auto;list-style:none;margin:0 0 10px;padding:0 0 4px;-webkit-overflow-scrolling:touch} +.shop-cat-filter-tab li{flex:0 0 auto} +.shop-cat-filter-tab a, +.shop-cat-filter-tab span, +.shop-cat-filter-child a, +.shop-cat-filter-child span{display:inline-flex;align-items:center;padding:6px 12px;border-radius:999px;font-size:13px;font-weight:600;text-decoration:none;color:var(--color-ink-2,#5c6370);background:var(--surface-4,#f3f4f6)} +.shop-cat-filter-tab .active span, +.shop-cat-filter-child .focus-color, +.shop-cat-filter-orderby .focus-color{color:var(--color-primary,#f04494);background:color-mix(in srgb,var(--color-primary,#f04494) 12%, #fff)} +.shop-cat-filter-child{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:0 0 10px} +.shop-cat-filter-orderby{gap:8px} +.shop-cat-filter-orderby .opacity5{opacity:.55;margin-right:4px;font-size:13px} +.aksrt-product-archive__skeleton{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;padding:8px 0 16px} +.aksrt-product-card--placeholder{min-height:220px;border-radius:12px;background:linear-gradient(90deg,#f3f4f6 25%,#e5e7eb 37%,#f3f4f6 63%);background-size:400% 100%;animation:aksrtShopSk 1.2s ease infinite} +@keyframes aksrtShopSk{0%{background-position:100% 0}100%{background-position:0 0}} +@media (max-width:992px){.aksrt-product-archive__skeleton{grid-template-columns:repeat(2,minmax(0,1fr))}} +@media (max-width:560px){.aksrt-product-archive__skeleton{grid-template-columns:1fr}} diff --git a/assets/css/frontend/remote-modal.css b/assets/css/frontend/remote-modal.css new file mode 100644 index 00000000..044a33e5 --- /dev/null +++ b/assets/css/frontend/remote-modal.css @@ -0,0 +1,300 @@ +/** + * Remote modal + ajax list primitives (shared). + * + * @package AKSRT + */ + +/* ---- scroll lock ---- */ +html.aksrt-remote-modal-open, +body.aksrt-remote-modal-open { + overflow: hidden; +} + +/* ---- remote modal shell ---- */ +.aksrt-remote-modal { + position: fixed; + inset: 0; + z-index: 100050; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease, visibility 0.2s ease; + pointer-events: none; +} + +.aksrt-remote-modal.is-open { + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +.aksrt-remote-modal__overlay { + position: absolute; + inset: 0; + background: rgba(15, 18, 28, 0.48); + backdrop-filter: blur(2px); +} + +.aksrt-remote-modal__dialog { + position: relative; + z-index: 1; + width: min(440px, calc(100vw - 32px)); + max-height: min(88vh, 720px); + overflow: auto; + background: var(--surface-1, #fff); + border-radius: 16px; + box-shadow: 0 18px 48px rgba(15, 18, 28, 0.22); + transform: translateY(12px) scale(0.98); + transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1); + color: var(--color-ink, #1f2937); +} + +.aksrt-remote-modal.is-open .aksrt-remote-modal__dialog { + transform: translateY(0) scale(1); +} + +.aksrt-remote-modal--bottom { + align-items: flex-end; +} + +.aksrt-remote-modal--bottom .aksrt-remote-modal__dialog { + width: 100%; + max-width: 100%; + border-radius: 16px 16px 0 0; + max-height: 90vh; + transform: translateY(24px); +} + +.aksrt-remote-modal--bottom.is-open .aksrt-remote-modal__dialog { + transform: translateY(0); +} + +.aksrt-remote-modal__content { + padding: 18px 18px 16px; +} + +.aksrt-remote-modal__loading, +.aksrt-remote-modal__error-body { + padding: 28px 8px; + text-align: center; + color: var(--color-ink-2, #6b7280); + font-size: 14px; +} + +.aksrt-remote-modal .touch.border-title, +.aksrt-remote-modal .border-title { + font-size: 16px; + font-weight: 700; + margin: 0 0 14px; + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 6px; +} + +.aksrt-remote-modal .abs-close, +.aksrt-remote-modal .aksrt-remote-modal__close { + position: absolute; + top: 10px; + right: 10px; + width: 32px; + height: 32px; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--color-ink-3, #9ca3af); + cursor: pointer; + font-size: 18px; + line-height: 1; +} + +.aksrt-remote-modal .but-average.modal-buts, +.aksrt-remote-modal .modal-buts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-top: 16px; +} + +.aksrt-remote-modal .but-average.modal-buts .but, +.aksrt-remote-modal .modal-buts .but, +.aksrt-remote-modal .modal-buts button { + min-height: 40px; + border-radius: 10px; + border: 1px solid var(--color-line, #e5e7eb); + background: var(--surface-4, #f3f4f6); + color: var(--color-ink, #111); + font-weight: 600; + cursor: pointer; +} + +.aksrt-remote-modal .modal-buts .but.c-blue, +.aksrt-remote-modal .modal-buts button.c-blue, +.aksrt-remote-modal .modal-buts .btn-primary { + background: var(--color-primary, #f04494); + border-color: color-mix(in srgb, var(--color-primary, #f04494) 70%, #000 0%); + color: var(--color-on-primary, #fff); +} + +/* ---- ajax list ---- */ +.ajaxpager.is-ajax-loading { + position: relative; + min-height: 80px; +} + +.ajaxpager .post_ajax_loader, +.ajaxpager .post-ajax-loader { + display: none; +} + +.ajaxpager .post_ajax_loader.is-loading, +.ajaxpager .post-ajax-loader.is-loading { + display: block; +} + +.ajax-item.is-fading { + opacity: 0.35; + transition: opacity 0.15s ease; +} + +/* ---- order review modal content ---- */ +.order-comment-from .shop-score-box { + display: flex; + align-items: center; + gap: 8px; + min-height: 36px; +} + +.order-comment-from .score-title { + width: 4.5em; + flex: 0 0 auto; + font-size: 13px; + color: var(--color-ink-2, #6b7280); +} + +.order-comment-from .score-icon { + display: inline-flex; + gap: 4px; + align-items: center; + height: 30px; +} + +.order-comment-from .icon-star { + display: inline-flex; + width: 22px; + height: 22px; + color: #d1d5db; + cursor: pointer; + transition: color 0.12s ease, transform 0.12s ease; +} + +.order-comment-from .icon-star svg { + width: 22px; + height: 22px; + fill: currentColor; +} + +.order-comment-from .icon-star.active, +.order-comment-from .icon-star.hover { + color: #fc7049; +} + +.order-comment-from .icon-star.hover { + transform: scale(1.08); +} + +.order-comment-from .score-text { + min-width: 3em; + font-size: 13px; + color: var(--color-ink-2, #6b7280); +} + +.order-comment-from textarea.form-control { + width: 100%; + border: 1px solid var(--color-line, #e5e7eb); + border-radius: 10px; + padding: 10px 12px; + font-size: 14px; + resize: vertical; + min-height: 96px; + background: var(--surface-1, #fff); + color: var(--color-ink, #111); +} + +.order-comment-from .form-upload .preview { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.order-comment-from .form-upload .preview .add, +.order-comment-from .form-upload .preview .preview-item { + width: 80px; + height: 80px; + border-radius: 10px; + border: 1px dashed var(--color-line, #d1d5db); + background: var(--surface-4, #f9fafb); + position: relative; + overflow: hidden; + cursor: pointer; +} + +.order-comment-from .form-upload .preview .add::before, +.order-comment-from .form-upload .preview .add::after { + content: ""; + position: absolute; + left: 50%; + top: 50%; + background: var(--color-ink-3, #9ca3af); + transform: translate(-50%, -50%); +} + +.order-comment-from .form-upload .preview .add::before { + width: 22px; + height: 2px; +} + +.order-comment-from .form-upload .preview .add::after { + width: 2px; + height: 22px; +} + +.order-comment-from .form-upload .preview .preview-item img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.order-comment-from .form-upload .preview .preview-item .remove { + position: absolute; + top: 2px; + right: 2px; + width: 20px; + height: 20px; + border: 0; + border-radius: 999px; + background: rgba(0, 0, 0, 0.55); + color: #fff; + font-size: 12px; + line-height: 1; + cursor: pointer; +} + +.order-comment-from .form-upload .hide { + display: none !important; +} + +.order-comment-from .review-error { + color: #ef4444; + font-size: 13px; + margin-top: 8px; +} + +@media (max-width: 768px) { + .aksrt-remote-modal:not(.aksrt-remote-modal--bottom) .aksrt-remote-modal__dialog { + width: calc(100vw - 20px); + } +} diff --git a/assets/js/core/ajax-list.js b/assets/js/core/ajax-list.js new file mode 100644 index 00000000..5d9f9a1e --- /dev/null +++ b/assets/js/core/ajax-list.js @@ -0,0 +1,361 @@ +/** + * AKSRT Ajax List Engine — zibll .ajax-next / ajax-replace / route parity. + * + * DOM contract: + * .ajaxpager list container + * .ajax-item replace/append unit + * .ajax-pag .next-page.ajax-next paginate control + * ajax-replace="true" replace mode + * route / route-title history + document.title + * [win-ajax-replace] partial header sync from response HTML + * + * Transport: + * - Prefer data-aksrt-ajax-url JSON { html, header_html, paginate_html, title } + * - Else fetch href as HTML fragment and parse .ajaxpager / .ajax-item + * + * Events: post_ajax.before / post_ajax.ed on container (bubbles). + * + * @package AKSRT + */ +(function (window, document) { + "use strict"; + + var loadingMap = new WeakMap(); + + function restHeaders() { + var h = { Accept: "application/json, text/html", "X-AKSRT-Ajax": "list" }; + if (window.aksrtData && aksrtData.restNonce) { + h["X-WP-Nonce"] = aksrtData.restNonce; + } + return h; + } + + function closestPager(el) { + if (!el) return null; + return ( + el.closest(".ajaxpager") || + el.closest("[data-aksrt-ajaxpager]") || + el.closest(".aksrt-ajaxpager") + ); + } + + function dispatch(container, name, detail) { + if (!container) return; + try { + container.dispatchEvent( + new CustomEvent(name, { bubbles: true, detail: detail || {} }) + ); + } catch (err) {} + } + + function showLoader(container) { + if (!container) return; + var loader = container.querySelector(".post_ajax_loader, .post-ajax-loader, [data-aksrt-ajax-loader]"); + if (loader) { + loader.classList.add("is-loading"); + loader.hidden = false; + loader.style.display = ""; + return; + } + container.classList.add("is-ajax-loading"); + } + + function hideLoader(container) { + if (!container) return; + var loader = container.querySelector(".post_ajax_loader, .post-ajax-loader, [data-aksrt-ajax-loader]"); + if (loader) { + loader.classList.remove("is-loading"); + if (loader.hasAttribute("data-aksrt-ajax-loader-hide")) { + loader.hidden = true; + loader.style.display = "none"; + } + } + container.classList.remove("is-ajax-loading"); + } + + function parseHtml(html) { + var wrap = document.createElement("div"); + wrap.innerHTML = html || ""; + return wrap; + } + + function extractFromHtml(html, container) { + var doc = parseHtml(html); + var pager = + doc.querySelector(".ajaxpager") || + doc.querySelector("[data-aksrt-ajaxpager]") || + doc.querySelector(".aksrt-ajaxpager"); + var items = []; + var scope = pager || doc; + scope.querySelectorAll(".ajax-item").forEach(function (n) { + items.push(n.outerHTML); + }); + if (!items.length && pager) { + items.push(pager.innerHTML); + } + var pag = scope.querySelector(".ajax-pag"); + var headerBits = {}; + doc.querySelectorAll("[win-ajax-replace]").forEach(function (n) { + var key = n.getAttribute("win-ajax-replace"); + if (key) headerBits[key] = n.outerHTML; + }); + var titleEl = doc.querySelector("title"); + return { + itemsHtml: items.join(""), + paginateHtml: pag ? pag.outerHTML : "", + headerBits: headerBits, + title: titleEl ? titleEl.textContent : "", + fullHtml: pager ? pager.innerHTML : html, + }; + } + + function syncWinReplace(bits) { + if (!bits) return; + Object.keys(bits).forEach(function (key) { + var live = document.querySelector('[win-ajax-replace="' + key + '"]'); + if (!live) return; + var tmp = document.createElement("div"); + tmp.innerHTML = bits[key]; + var next = tmp.firstElementChild; + if (next) live.replaceWith(next); + }); + } + + function applyRoute(link, title) { + if (!link) return; + var route = link.getAttribute("route") || link.getAttribute("data-route"); + if (!(route === "1" || route === "true" || link.hasAttribute("route"))) return; + var href = link.getAttribute("href") || link.getAttribute("ajax-href") || ""; + if (!href) return; + try { + window.history.replaceState({}, "", href); + } catch (err) {} + var rt = + title || + link.getAttribute("route-title") || + link.getAttribute("data-route-title") || + ""; + if (rt) { + try { + document.title = rt; + } catch (e2) {} + } + } + + function replacePaginate(container, paginateHtml) { + if (!container || !paginateHtml) return; + var old = container.querySelector(".ajax-pag"); + if (!old) { + container.insertAdjacentHTML("beforeend", paginateHtml); + return; + } + var tmp = document.createElement("div"); + tmp.innerHTML = paginateHtml; + var next = tmp.firstElementChild; + if (next) old.replaceWith(next); + } + + function inject(container, payload, options) { + options = options || {}; + var replace = options.replace !== false; + if (!container) return; + + var itemsHtml = payload.itemsHtml || payload.html || ""; + if (replace) { + container.querySelectorAll(".ajax-item").forEach(function (n) { + n.remove(); + }); + // If container itself holds only items, clear non-loader children. + if (container.getAttribute("data-aksrt-ajax-clear") === "1") { + Array.prototype.slice.call(container.children).forEach(function (child) { + if ( + child.classList.contains("post_ajax_loader") || + child.classList.contains("post-ajax-loader") || + child.hasAttribute("data-aksrt-ajax-loader") || + child.classList.contains("ajax-pag") + ) { + return; + } + child.remove(); + }); + } + if (itemsHtml) { + var pag = container.querySelector(".ajax-pag"); + if (pag) { + pag.insertAdjacentHTML("beforebegin", itemsHtml); + } else { + container.insertAdjacentHTML("beforeend", itemsHtml); + } + } + } else if (itemsHtml) { + var pag2 = container.querySelector(".ajax-pag"); + if (pag2) { + pag2.insertAdjacentHTML("beforebegin", itemsHtml); + } else { + container.insertAdjacentHTML("beforeend", itemsHtml); + } + } + + if (payload.paginateHtml) { + replacePaginate(container, payload.paginateHtml); + } + if (payload.headerBits) { + syncWinReplace(payload.headerBits); + } + if (payload.headerHtml) { + var headerHost = document.querySelector( + options.headerSelector || "[data-aksrt-ajax-header]" + ); + if (headerHost) headerHost.innerHTML = payload.headerHtml; + } + } + + function normalizeJson(json) { + var data = json && json.data ? json.data : json; + if (!data) return null; + var html = data.html || data.list_html || ""; + var itemsHtml = data.items_html || ""; + if (!itemsHtml && html) { + // If html is only item nodes, use as-is; else extract. + if (html.indexOf("ajax-item") !== -1) { + var parsed = extractFromHtml("
" + html + "
"); + itemsHtml = parsed.itemsHtml || html; + } else { + itemsHtml = html; + } + } + return { + itemsHtml: itemsHtml, + html: html, + paginateHtml: data.paginate_html || data.paginateHtml || "", + headerHtml: data.header_html || data.headerHtml || "", + headerBits: data.header_bits || null, + title: data.title || "", + emptyHtml: data.empty_html || "", + hasMore: typeof data.has_more !== "undefined" ? !!data.has_more : null, + }; + } + + function loadFromLink(link, override) { + override = override || {}; + if (!link) return Promise.resolve(); + + var container = + override.container || + (link.getAttribute("ajaxpager-target") + ? document.querySelector(link.getAttribute("ajaxpager-target")) + : null) || + closestPager(link); + if (!container) { + // Fall back: parent stream list. + container = link.closest(".aksrt-product-grid, .aksrt-forum-topics__list, [data-aksrt-ajax-list]"); + } + if (!container) return Promise.resolve(); + + if (loadingMap.get(container)) return Promise.resolve(); + loadingMap.set(container, true); + + var replaceAttr = link.getAttribute("ajax-replace"); + var replace = + typeof override.replace === "boolean" + ? override.replace + : replaceAttr === "true" || replaceAttr === "1" || replaceAttr === "replace"; + + var href = + override.url || + link.getAttribute("data-aksrt-ajax-url") || + link.getAttribute("ajax-href") || + link.getAttribute("href") || + ""; + + dispatch(container, "post_ajax.before", { link: link, replace: replace, url: href }); + showLoader(container); + if (replace) { + // Soft clear items while loading (keep skeleton if present). + container.querySelectorAll(".ajax-item").forEach(function (n) { + n.classList.add("is-fading"); + }); + } + + return fetch(href, { + method: override.method || "GET", + credentials: "same-origin", + headers: restHeaders(), + }) + .then(function (res) { + var type = (res.headers.get("content-type") || "").toLowerCase(); + if (type.indexOf("application/json") !== -1) { + return res.json().then(function (json) { + if (!res.ok || (json && json.success === false)) { + throw new Error( + (json && (json.message || (json.data && json.data.message))) || "加载失败" + ); + } + return normalizeJson(json); + }); + } + return res.text().then(function (text) { + if (!res.ok) throw new Error("加载失败"); + return extractFromHtml(text, container); + }); + }) + .then(function (payload) { + if (!payload) throw new Error("空响应"); + if (replace && !payload.itemsHtml && payload.emptyHtml) { + inject(container, { itemsHtml: payload.emptyHtml, paginateHtml: payload.paginateHtml }, { replace: true }); + } else { + inject(container, payload, { replace: replace }); + } + applyRoute(link, payload.title); + hideLoader(container); + loadingMap.set(container, false); + dispatch(container, "post_ajax.ed", { + link: link, + replace: replace, + url: href, + payload: payload, + }); + document.dispatchEvent( + new CustomEvent("aksrt:posts:rendered", { + bubbles: true, + detail: { container: container, replace: replace }, + }) + ); + return payload; + }) + .catch(function (err) { + hideLoader(container); + loadingMap.set(container, false); + container.querySelectorAll(".ajax-item.is-fading").forEach(function (n) { + n.classList.remove("is-fading"); + }); + if (typeof window.aksrtToast === "function") { + window.aksrtToast((err && err.message) || "加载失败"); + } + dispatch(container, "post_ajax.error", { link: link, error: err }); + throw err; + }); + } + + document.addEventListener("click", function (e) { + var link = e.target.closest("a.ajax-next, .ajax-next a, button.ajax-next, [data-aksrt-ajax-next]"); + if (!link) return; + // Ignore if inside a component that opts out. + if (link.closest("[data-aksrt-ajax-list='off']")) return; + // Native pagination core may own .aksrt-pagination-ajax buttons. + if (link.closest(".aksrt-pagination-ajax") && !link.classList.contains("ajax-next")) return; + + var href = link.getAttribute("href") || link.getAttribute("ajax-href") || link.getAttribute("data-aksrt-ajax-url"); + if (!href && !link.getAttribute("data-aksrt-ajax-url")) return; + + e.preventDefault(); + loadFromLink(link.tagName === "A" || link.classList.contains("ajax-next") ? link : link.closest(".ajax-next") || link); + }); + + window.AksrtAjaxList = { + load: loadFromLink, + inject: inject, + extractFromHtml: extractFromHtml, + closestPager: closestPager, + }; +})(window, document); diff --git a/assets/js/core/remote-modal.js b/assets/js/core/remote-modal.js new file mode 100644 index 00000000..e07802ce --- /dev/null +++ b/assets/js/core/remote-modal.js @@ -0,0 +1,232 @@ +/** + * AKSRT Remote Modal — zibll RefreshModal parity. + * + * Open a center/bottom modal with static HTML or remote fetch. + * Contract: + * - data-toggle="AksrtRemoteModal" | data-aksrt-remote-modal + * - data-remote / href → fetch HTML + * - data-class → dialog class (modal-mini full-sm …) + * - data-height → min-height hint + * - mobile-bottom="true" → bottom sheet on narrow viewports + * + * @package AKSRT + */ +(function (window, document) { + "use strict"; + + var ROOT_ID = "aksrt-remote-modal-root"; + var active = null; + + function isMobile() { + return window.matchMedia && window.matchMedia("(max-width: 768px)").matches; + } + + function ensureRoot() { + var root = document.getElementById(ROOT_ID); + if (root) return root; + root = document.createElement("div"); + root.id = ROOT_ID; + root.className = "aksrt-remote-modal-host"; + document.body.appendChild(root); + return root; + } + + function restHeaders() { + var h = { Accept: "text/html, application/json" }; + if (window.aksrtData && aksrtData.restNonce) { + h["X-WP-Nonce"] = aksrtData.restNonce; + } + return h; + } + + function lockScroll(lock) { + document.documentElement.classList.toggle("aksrt-remote-modal-open", !!lock); + document.body.classList.toggle("aksrt-remote-modal-open", !!lock); + } + + function close() { + if (!active) return; + active.classList.remove("is-open"); + lockScroll(false); + var node = active; + active = null; + window.setTimeout(function () { + if (node && node.parentNode) node.parentNode.removeChild(node); + }, 220); + document.dispatchEvent(new CustomEvent("aksrt:remote-modal:close")); + } + + function bindChrome(shell) { + shell.addEventListener("click", function (e) { + if (e.target.closest("[data-aksrt-remote-modal-close], [data-dismiss='modal'], .aksrt-remote-modal__close")) { + e.preventDefault(); + close(); + return; + } + if (e.target === shell || e.target.classList.contains("aksrt-remote-modal__overlay")) { + close(); + } + }); + shell.addEventListener("keydown", function (e) { + if (e.key === "Escape") close(); + }); + } + + function open(options) { + options = options || {}; + close(); + + var root = ensureRoot(); + var shell = document.createElement("div"); + var bottom = options.bottom === true || (options.mobileBottom !== false && isMobile()); + shell.className = + "aksrt-remote-modal" + + (bottom ? " aksrt-remote-modal--bottom" : "") + + (options.className ? " " + options.className : ""); + shell.setAttribute("role", "dialog"); + shell.setAttribute("aria-modal", "true"); + + var heightStyle = options.height ? "min-height:" + parseInt(options.height, 10) + "px;" : ""; + shell.innerHTML = + '
' + + '
' + + '
' + + (options.html || "") + + "
"; + + root.appendChild(shell); + bindChrome(shell); + active = shell; + lockScroll(true); + + // Force reflow then open for CSS transition. + void shell.offsetWidth; + shell.classList.add("is-open"); + + var focusable = shell.querySelector( + "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])" + ); + if (focusable) { + try { + focusable.focus(); + } catch (err) {} + } + + document.dispatchEvent( + new CustomEvent("aksrt:remote-modal:open", { detail: { shell: shell, options: options } }) + ); + return shell; + } + + function openRemote(options) { + options = options || {}; + var url = options.url || ""; + if (!url) return Promise.reject(new Error("missing url")); + + var loading = open({ + html: + '
' + + ((window.aksrtData && aksrtData.ajaxLoading) || "加载中…") + + "
", + className: options.className || "modal-mini full-sm", + bottom: options.bottom, + mobileBottom: options.mobileBottom, + height: options.height, + }); + + return fetch(url, { + method: options.method || "GET", + credentials: "same-origin", + headers: Object.assign(restHeaders(), options.headers || {}), + body: options.body, + }) + .then(function (res) { + var type = (res.headers.get("content-type") || "").toLowerCase(); + if (type.indexOf("application/json") !== -1) { + return res.json().then(function (json) { + if (!res.ok || (json && json.success === false)) { + var msg = + (json && (json.message || (json.data && json.data.message))) || + "加载失败"; + throw new Error(msg); + } + var html = + (json && (json.html || (json.data && (json.data.html || json.data)))) || ""; + if (typeof html !== "string") html = ""; + return html; + }); + } + return res.text().then(function (text) { + if (!res.ok) throw new Error("加载失败"); + return text; + }); + }) + .then(function (html) { + if (loading && loading.parentNode) loading.parentNode.removeChild(loading); + if (active === loading) active = null; + return open({ + html: html, + className: options.className || "modal-mini full-sm", + bottom: options.bottom, + mobileBottom: options.mobileBottom, + height: options.height, + }); + }) + .catch(function (err) { + if (loading && loading.parentNode) loading.parentNode.removeChild(loading); + if (active === loading) active = null; + open({ + html: + '
提示
' + + '
' + + (err && err.message ? String(err.message) : "加载失败") + + '
', + className: "modal-mini", + mobileBottom: true, + }); + throw err; + }); + } + + function fromTrigger(el) { + if (!el) return null; + var url = + el.getAttribute("data-remote") || + el.getAttribute("data-aksrt-remote") || + el.getAttribute("href") || + ""; + if (!url || url === "#") return null; + var height = el.getAttribute("data-height") || el.getAttribute("data-aksrt-height"); + return openRemote({ + url: url, + className: el.getAttribute("data-class") || el.getAttribute("data-aksrt-class") || "modal-mini full-sm", + height: height ? parseInt(height, 10) : 0, + mobileBottom: el.getAttribute("mobile-bottom") !== "false", + bottom: el.getAttribute("data-bottom") === "true", + }); + } + + document.addEventListener("click", function (e) { + var trigger = e.target.closest( + '[data-toggle="AksrtRemoteModal"], [data-toggle="RefreshModal"], [data-aksrt-remote-modal], a.aksrt-remote-modal-link' + ); + if (!trigger) return; + if (trigger.getAttribute("data-aksrt-remote-modal") === "off") return; + e.preventDefault(); + fromTrigger(trigger); + }); + + document.addEventListener("keydown", function (e) { + if (e.key === "Escape" && active) close(); + }); + + window.AksrtRemoteModal = { + open: open, + openRemote: openRemote, + close: close, + fromTrigger: fromTrigger, + getActive: function () { + return active; + }, + }; +})(window, document); diff --git a/assets/js/order-center.js b/assets/js/order-center.js index 0116e4e9..45c9406b 100644 --- a/assets/js/order-center.js +++ b/assets/js/order-center.js @@ -67,6 +67,252 @@ return aksrtData.cartUrl || (aksrtData.routes && aksrtData.routes.cart) || "/"; } + /* ── Order review modal (zibll 1:1) ── */ + var STAR_SVG = + ''; + var SCORE_LABELS = ["", "非常差", "较差", "一般", "较好", "非常好"]; + + function formatRemain(sec) { + sec = Math.max(0, sec | 0); + var d = Math.floor(sec / 86400); + var h = Math.floor((sec % 86400) / 3600); + var m = Math.floor((sec % 3600) / 60); + var s = sec % 60; + function z(n) { return n < 10 ? "0" + n : String(n); } + if (d > 0) return d + "天" + z(h) + ":" + z(m) + ":" + z(s); + return z(h) + ":" + z(m) + ":" + z(s); + } + + function bindShopScore(root) { + if (!root) return; + root.querySelectorAll("[data-aksrt-score-box], .shop-score-box").forEach(function (box) { + if (box.dataset.scoreBound === "1") return; + box.dataset.scoreBound = "1"; + var iconBox = box.querySelector(".score-icon"); + var textBox = box.querySelector(".score-text"); + var input = box.querySelector(".score-input"); + if (!iconBox) return; + var value = parseInt(box.getAttribute("score-value") || (input && input.value) || "0", 10) || 0; + var html = ""; + for (var i = 1; i <= 5; i++) { + html += '' + STAR_SVG + ""; + } + iconBox.innerHTML = html; + function paint(score, hover) { + iconBox.querySelectorAll(".icon-star").forEach(function (star) { + var s = parseInt(star.getAttribute("data-score"), 10) || 0; + star.classList.toggle("active", !hover && s <= score); + star.classList.toggle("hover", !!hover && s <= score); + }); + if (textBox) textBox.textContent = SCORE_LABELS[score] || ""; + } + paint(value, false); + iconBox.addEventListener("mouseover", function (e) { + var star = e.target.closest(".icon-star"); + if (!star || !iconBox.contains(star)) return; + paint(parseInt(star.getAttribute("data-score"), 10) || 0, true); + }); + iconBox.addEventListener("mouseleave", function () { + var cur = parseInt(box.getAttribute("score-value") || "0", 10) || 0; + paint(cur, false); + }); + iconBox.addEventListener("click", function (e) { + var star = e.target.closest(".icon-star"); + if (!star || !iconBox.contains(star)) return; + var score = parseInt(star.getAttribute("data-score"), 10) || 0; + box.setAttribute("score-value", String(score)); + if (input) input.value = String(score); + paint(score, false); + }); + }); + } + + function bindReviewImages(form) { + var preview = form.querySelector("[data-aksrt-review-preview]"); + var fileInput = form.querySelector("[data-aksrt-review-file]"); + var addBtn = form.querySelector("[data-aksrt-review-add]"); + if (!preview || !fileInput) return; + var max = parseInt(preview.getAttribute("data-max") || "6", 10) || 6; + var files = []; + + function render() { + preview.querySelectorAll(".preview-item").forEach(function (n) { n.remove(); }); + files.forEach(function (item, idx) { + var node = document.createElement("div"); + node.className = "preview-item"; + node.innerHTML = ''; + node.querySelector("img").src = item.url; + node.querySelector(".remove").addEventListener("click", function (e) { + e.preventDefault(); + e.stopPropagation(); + if (item.url && item.url.indexOf("blob:") === 0) { + try { URL.revokeObjectURL(item.url); } catch (err) {} + } + files.splice(idx, 1); + render(); + }); + preview.insertBefore(node, addBtn); + }); + if (addBtn) addBtn.style.display = files.length >= max ? "none" : ""; + form._aksrtReviewFiles = files.slice(); + } + + function onPick() { + if (files.length >= max) return; + fileInput.click(); + } + if (addBtn) { + addBtn.addEventListener("click", onPick); + addBtn.addEventListener("keydown", function (e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onPick(); + } + }); + } + fileInput.addEventListener("change", function () { + var list = Array.prototype.slice.call(fileInput.files || []); + list.forEach(function (file) { + if (files.length >= max) return; + if (!file.type || file.type.indexOf("image/") !== 0) return; + files.push({ file: file, url: URL.createObjectURL(file) }); + }); + fileInput.value = ""; + render(); + }); + form._aksrtReviewFiles = []; + render(); + } + + function bindReviewCountdown(root) { + var el = root.querySelector("[data-aksrt-review-countdown]"); + if (!el) return; + var remain = parseInt(el.getAttribute("data-aksrt-review-countdown") || "0", 10) || 0; + if (remain <= 0) return; + el.textContent = formatRemain(remain); + var timer = window.setInterval(function () { + remain -= 1; + if (remain <= 0) { + window.clearInterval(timer); + el.textContent = "交易已关闭"; + return; + } + el.textContent = formatRemain(remain); + }, 1000); + root._aksrtReviewTimer = timer; + } + + function fileToDataUrl(file) { + return new Promise(function (resolve, reject) { + var reader = new FileReader(); + reader.onload = function () { resolve(String(reader.result || "")); }; + reader.onerror = function () { reject(new Error("read failed")); }; + reader.readAsDataURL(file); + }); + } + + function openOrderReviewModal(orderNumber, triggerBtn) { + if (!window.AksrtRemoteModal || typeof window.AksrtRemoteModal.openRemote !== "function") { + showToast("评价组件未加载"); + return; + } + if (triggerBtn) triggerBtn.disabled = true; + var url = restRoot() + "/shop/frontend/orders/" + encodeURIComponent(orderNumber) + "/review-modal"; + window.AksrtRemoteModal.openRemote({ + url: url, + className: "modal-mini full-sm", + height: 380, + mobileBottom: true + }).then(function (shell) { + if (triggerBtn) triggerBtn.disabled = false; + if (!shell) return; + var form = shell.querySelector("[data-aksrt-order-review-form]"); + if (!form) return; + bindShopScore(shell); + bindReviewImages(form); + bindReviewCountdown(shell); + + form.addEventListener("submit", function (e) { + e.preventDefault(); + var errBox = form.querySelector("[data-aksrt-review-error]"); + function showErr(msg) { + if (errBox) { + errBox.hidden = false; + errBox.textContent = msg; + } else { + showToast(msg); + } + } + if (errBox) { + errBox.hidden = true; + errBox.textContent = ""; + } + + var productId = parseInt(form.getAttribute("data-product-id") || "0", 10) || 0; + var needShipping = form.getAttribute("data-need-shipping") === "1"; + var scoreProduct = parseInt((form.querySelector('input[name="score[product]"]') || {}).value || "0", 10) || 0; + var scoreService = parseInt((form.querySelector('input[name="score[service]"]') || {}).value || "0", 10) || 0; + var scoreShipping = parseInt((form.querySelector('input[name="score[shipping]"]') || {}).value || "0", 10) || 0; + if (scoreProduct < 1 || scoreService < 1) { + showErr("请对商品进行评分"); + return; + } + if (needShipping && scoreShipping < 1) { + showErr("请对物流服务进行评分"); + return; + } + + var comment = (form.querySelector('textarea[name="comment"]') || {}).value || ""; + var submitBtn = form.querySelector("[data-aksrt-review-submit]"); + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.textContent = "提交中…"; + } + + var pending = form._aksrtReviewFiles || []; + Promise.all(pending.map(function (item) { + return item.file ? fileToDataUrl(item.file) : Promise.resolve(item.url || ""); + })).then(function (images) { + images = images.filter(Boolean); + return request("/shop/frontend/orders/" + orderNumber + "/review", { + product_id: productId, + content: comment, + rating_product: scoreProduct, + rating_service: scoreService, + rating_shipping: needShipping ? scoreShipping : scoreService, + images: images + }); + }).then(function (result) { + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.innerHTML = ' 确认提交'; + } + if (!result || !result.success) { + showErr((result && result.message) || "评价失败"); + return; + } + showToast((result.message) || "评价成功"); + if (window.AksrtRemoteModal) window.AksrtRemoteModal.close(); + var card = findOrderCard(orderNumber); + updateCardStatus(card, "completed", "已完成"); + if (card) { + var btn = card.querySelector("[data-aksrt-order-review]"); + if (btn) btn.remove(); + } + }).catch(function () { + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.innerHTML = ' 确认提交'; + } + showErr("网络错误"); + }); + }); + }).catch(function (err) { + if (triggerBtn) triggerBtn.disabled = false; + showToast((err && err.message) || "无法打开评价"); + }); + } + /* ── 扫码支付弹层(微信 Native code_url 等)── */ function showPaymentQr(content, orderNumber) { var existing = document.getElementById("aksrt-pay-qr-overlay"); @@ -280,47 +526,12 @@ return; } - // Review submit modal + // Review modal (zibll order_comment_modal 1:1 UX) var reviewBtn = event.target.closest("[data-aksrt-order-review]"); if (reviewBtn) { var reviewOrder = reviewBtn.getAttribute("data-aksrt-order-review"); - var card = reviewBtn.closest("[data-aksrt-order-card]"); - var productId = 0; - if (card) { - var firstItem = card.querySelector("[data-product-id]"); - if (firstItem) productId = Number(firstItem.getAttribute("data-product-id") || 0); - } - if (!productId) { - // try parse from item title block dataset if present - var any = card ? card.querySelector(".aksrt-orders-page__card-item") : null; - if (any && any.getAttribute("data-product-id")) { - productId = Number(any.getAttribute("data-product-id") || 0); - } - } if (!reviewOrder) return; - var content = window.prompt("请输入评价内容(可选)", "好评!"); - if (content === null) return; - var rating = window.prompt("商品评分 1-5", "5"); - var r = Math.max(1, Math.min(5, parseInt(rating || "5", 10) || 5)); - reviewBtn.disabled = true; - request("/shop/frontend/orders/" + reviewOrder + "/review", { - product_id: productId, - content: content || "好评!", - rating_product: r, - rating_service: r, - rating_shipping: r - }).then(function (result) { - reviewBtn.disabled = false; - if (!result || !result.success) { - showToast((result && result.message) || "评价失败"); - return; - } - showToast(result.message || "评价已提交"); - updateCardStatus(findOrderCard(reviewOrder), "completed", "已完成"); - }).catch(function () { - reviewBtn.disabled = false; - showToast("网络错误"); - }); + openOrderReviewModal(reviewOrder, reviewBtn); return; } diff --git a/assets/js/product-archive.js b/assets/js/product-archive.js index 74a54470..7f387b44 100644 --- a/assets/js/product-archive.js +++ b/assets/js/product-archive.js @@ -1,5 +1,7 @@ /** - * Product archive — sort + AJAX load more. + * Product archive — cat filter + sort + AJAX load more (zibll ajax-next UX). + * + * Uses REST /shop/frontend/products/archive and optional AksrtAjaxList events. * * @package AKSRT */ @@ -10,7 +12,7 @@ if (!root) return; function restBase() { - var site = (window.aksrtData && aksrtData.siteUrl) ? String(aksrtData.siteUrl).replace(/\/$/, "") : ""; + var site = window.aksrtData && aksrtData.siteUrl ? String(aksrtData.siteUrl).replace(/\/$/, "") : ""; return site + "/wp-json/aksrt/v1"; } @@ -23,12 +25,17 @@ } var grid = root.querySelector("[data-aksrt-product-grid], #aksrt-product-grid"); + var pager = root.querySelector("[data-aksrt-ajaxpager], .ajaxpager"); var countEl = root.querySelector("[data-aksrt-archive-count]"); + var headerHost = root.querySelector("[data-aksrt-ajax-header]"); var loadMoreBtn = root.querySelector("[data-aksrt-shop-load-more]"); + var loader = root.querySelector("[data-aksrt-ajax-loader], .post_ajax_loader"); + var state = { taxonomy: root.getAttribute("data-taxonomy") || "", termId: Number(root.getAttribute("data-term-id") || 0), orderby: root.getAttribute("data-orderby") || "date", + order: (root.getAttribute("data-order") || "DESC").toUpperCase(), page: Number(root.getAttribute("data-page") || 1), maxPages: Number(root.getAttribute("data-max-pages") || 1), paging: root.getAttribute("data-paging") || "number", @@ -38,115 +45,251 @@ autoLoads: 0, }; - function fetchArchive(page, orderby, replace) { + function showLoader(on) { + if (loader) { + loader.hidden = !on; + loader.style.display = on ? "" : "none"; + loader.classList.toggle("is-loading", !!on); + } + if (pager) pager.classList.toggle("is-ajax-loading", !!on); + } + + function updateLoadMore(hasMore) { + var pag = root.querySelector(".ajax-pag"); + if (loadMoreBtn) { + loadMoreBtn.style.display = hasMore ? "" : "none"; + loadMoreBtn.disabled = false; + loadMoreBtn.textContent = hasMore ? "加载更多" : "没有更多了"; + if (!hasMore) loadMoreBtn.classList.add("ajax-nomore"); + else loadMoreBtn.classList.remove("ajax-nomore"); + } + if (pag) pag.style.display = hasMore ? "" : "none"; + } + + function syncOrderbyActive() { + root.querySelectorAll("[data-aksrt-shop-orderby]").forEach(function (a) { + var key = a.getAttribute("data-aksrt-shop-orderby") || ""; + var active = key === state.orderby; + a.classList.toggle("is-active", active); + a.classList.toggle("focus-color", active && key === "price"); + }); + } + + function fetchArchive(page, orderby, order, replace, termId) { if (state.loading) return Promise.resolve(); state.loading = true; + showLoader(true); if (loadMoreBtn) { loadMoreBtn.disabled = true; - loadMoreBtn.textContent = "加载中..."; + loadMoreBtn.textContent = "加载中…"; } var qs = new URLSearchParams(); qs.set("taxonomy", state.taxonomy); - qs.set("term_id", String(state.termId)); + qs.set("term_id", String(typeof termId === "number" ? termId : state.termId)); qs.set("page", String(page)); qs.set("orderby", orderby || state.orderby); + if ((orderby || state.orderby) === "price") { + qs.set("order", order || state.order || "ASC"); + } + + var container = pager || grid || root; + try { + container.dispatchEvent( + new CustomEvent("post_ajax.before", { + bubbles: true, + detail: { page: page, orderby: orderby || state.orderby, replace: !!replace }, + }) + ); + } catch (e0) {} return fetch(restBase() + "/shop/frontend/products/archive?" + qs.toString(), { method: "GET", credentials: "same-origin", headers: { + Accept: "application/json", "X-WP-Nonce": (window.aksrtData && aksrtData.restNonce) || "", + "X-AKSRT-Ajax": "list", }, }) - .then(function (r) { return r.json(); }) + .then(function (r) { + return r.json(); + }) .then(function (result) { state.loading = false; + showLoader(false); if (!result || !result.success || !result.data) { toast((result && result.message) || "加载失败"); - if (loadMoreBtn) { - loadMoreBtn.disabled = false; - loadMoreBtn.textContent = "加载更多"; - } + updateLoadMore(state.page < state.maxPages); return; } var data = result.data; state.page = Number(data.page || page); state.maxPages = Number(data.max_pages || 1); state.orderby = data.orderby || state.orderby; + state.order = String(data.order || state.order || "DESC").toUpperCase(); + if (typeof termId === "number" && termId > 0) { + state.termId = termId; + } + if (data.term && data.term.id) { + state.termId = Number(data.term.id); + } root.setAttribute("data-page", String(state.page)); root.setAttribute("data-max-pages", String(state.maxPages)); root.setAttribute("data-orderby", state.orderby); + root.setAttribute("data-order", state.order); + root.setAttribute("data-term-id", String(state.termId)); if (countEl && typeof data.total !== "undefined") { countEl.textContent = "共 " + data.total + " 件商品"; } + var titleEl = root.querySelector("[data-aksrt-archive-title]"); + if (titleEl && data.term && data.term.name) { + titleEl.textContent = data.term.name; + } + var descEl = root.querySelector("[data-aksrt-archive-desc]"); + if (descEl && data.term) { + descEl.textContent = data.term.description || ""; + descEl.style.display = data.term.description ? "" : "none"; + } - if (grid) { - if (replace) { - grid.innerHTML = data.html || ""; - } else if (data.html) { - grid.insertAdjacentHTML("beforeend", data.html); + if (headerHost && data.header_html) { + // Keep title/count; replace filter widget if present. + var existingFilter = headerHost.querySelector("[data-aksrt-shop-cat-filter], .shop-cat-filter"); + var tmp = document.createElement("div"); + tmp.innerHTML = data.header_html; + var nextFilter = tmp.querySelector("[data-aksrt-shop-cat-filter], .shop-cat-filter") || tmp.firstElementChild; + if (existingFilter && nextFilter) { + existingFilter.replaceWith(nextFilter); + } else if (!existingFilter && nextFilter) { + headerHost.appendChild(nextFilter); } } - root.querySelectorAll("[data-aksrt-shop-orderby]").forEach(function (a) { - a.classList.toggle("is-active", a.getAttribute("data-aksrt-shop-orderby") === state.orderby); - }); - - if (loadMoreBtn) { - var hasMore = state.page < state.maxPages; - loadMoreBtn.style.display = hasMore ? "" : "none"; - loadMoreBtn.disabled = false; - loadMoreBtn.textContent = "加载更多"; + var html = data.html || data.empty_html || ""; + if (grid) { + if (replace) { + grid.innerHTML = html; + } else if (html) { + grid.insertAdjacentHTML("beforeend", html); + } } + + if (data.paginate_html) { + var oldPag = root.querySelector(".ajax-pag"); + var wrap = document.createElement("div"); + wrap.innerHTML = data.paginate_html; + var nextPag = wrap.firstElementChild; + if (oldPag && nextPag) oldPag.replaceWith(nextPag); + else if (!oldPag && nextPag && pager) pager.appendChild(nextPag); + loadMoreBtn = root.querySelector("[data-aksrt-shop-load-more]"); + } + + syncOrderbyActive(); + updateLoadMore(!!data.has_more || state.page < state.maxPages); + + try { + container.dispatchEvent( + new CustomEvent("post_ajax.ed", { + bubbles: true, + detail: { page: state.page, replace: !!replace, data: data }, + }) + ); + document.dispatchEvent( + new CustomEvent("aksrt:posts:rendered", { + bubbles: true, + detail: { container: grid, replace: !!replace }, + }) + ); + } catch (e1) {} }) .catch(function () { state.loading = false; - if (loadMoreBtn) { - loadMoreBtn.disabled = false; - loadMoreBtn.textContent = "加载更多"; - } + showLoader(false); + updateLoadMore(state.page < state.maxPages); toast("网络错误"); }); } + function pushUrl(orderby, order, termUrl) { + try { + var u = termUrl ? new URL(termUrl, window.location.origin) : new URL(window.location.href); + if (orderby) u.searchParams.set("orderby", orderby); + else u.searchParams.delete("orderby"); + u.searchParams.delete("shop_orderby"); + if (orderby === "price" && order) u.searchParams.set("order", order); + else u.searchParams.delete("order"); + u.searchParams.delete("paged"); + window.history.replaceState({}, "", u.toString()); + } catch (err) {} + } + root.addEventListener("click", function (e) { - var sort = e.target.closest("[data-aksrt-shop-orderby]"); - if (sort && root.contains(sort)) { + // Category / sort links with ajax-next — intercept for REST (richer payload). + var termLink = e.target.closest("[data-aksrt-shop-term].ajax-next, a.ajax-next[data-aksrt-shop-term]"); + if (termLink && root.contains(termLink)) { e.preventDefault(); + e.stopPropagation(); + var tid = Number(termLink.getAttribute("data-aksrt-shop-term") || 0); + var href = termLink.getAttribute("href") || ""; + state.autoLoads = 0; + if (tid > 0) state.termId = tid; + pushUrl(state.orderby, state.order, href); + var rt = termLink.getAttribute("route-title"); + if (rt) { + try { + document.title = rt; + } catch (e2) {} + } + fetchArchive(1, state.orderby, state.order, true, tid); + return; + } + + var sort = e.target.closest("[data-aksrt-shop-orderby]"); + if (sort && root.contains(sort) && sort.tagName === "A") { + e.preventDefault(); + e.stopPropagation(); var orderby = sort.getAttribute("data-aksrt-shop-orderby") || "date"; - if (orderby === state.orderby && state.paging === "ajax") return; + var nextOrder = sort.getAttribute("data-aksrt-shop-order") || ""; + if (orderby === "price") { + if (state.orderby === "price" && nextOrder) { + state.order = nextOrder.toUpperCase(); + } else if (state.orderby !== "price") { + state.order = "ASC"; + } else { + state.order = state.order === "ASC" ? "DESC" : "ASC"; + } + } + if (orderby === state.orderby && orderby !== "price" && state.paging === "ajax") return; state.orderby = orderby; state.autoLoads = 0; - try { - var u = new URL(window.location.href); - u.searchParams.set("shop_orderby", orderby); - u.searchParams.delete("paged"); - window.history.replaceState({}, "", u.toString()); - } catch (err) {} - fetchArchive(1, orderby, true); + pushUrl(orderby, state.order); + fetchArchive(1, orderby, state.order, true); return; } var more = e.target.closest("[data-aksrt-shop-load-more]"); if (more && root.contains(more)) { e.preventDefault(); + e.stopPropagation(); if (state.page >= state.maxPages) return; - fetchArchive(state.page + 1, state.orderby, false); + fetchArchive(state.page + 1, state.orderby, state.order, false); } }); if (state.paging === "ajax" && state.ias && "IntersectionObserver" in window && loadMoreBtn) { - var io = new IntersectionObserver(function (entries) { - entries.forEach(function (entry) { - if (!entry.isIntersecting) return; - if (state.loading || state.page >= state.maxPages) return; - if (state.iasMax > 0 && state.autoLoads >= state.iasMax) return; - state.autoLoads += 1; - fetchArchive(state.page + 1, state.orderby, false); - }); - }, { rootMargin: "240px" }); + var io = new IntersectionObserver( + function (entries) { + entries.forEach(function (entry) { + if (!entry.isIntersecting) return; + if (state.loading || state.page >= state.maxPages) return; + if (state.iasMax > 0 && state.autoLoads >= state.iasMax) return; + state.autoLoads += 1; + fetchArchive(state.page + 1, state.orderby, state.order, false); + }); + }, + { rootMargin: "240px" } + ); io.observe(loadMoreBtn); } })(); diff --git a/inc/bootstrap.php b/inc/bootstrap.php index 42f31107..79b751a0 100644 --- a/inc/bootstrap.php +++ b/inc/bootstrap.php @@ -42,6 +42,7 @@ require_once AKSRT_DIR . '/inc/helpers/aksrt-pay.php'; require_once AKSRT_DIR . '/inc/helpers/aksrt-coupon-order.php'; require_once AKSRT_DIR . '/inc/helpers/aksrt-credits.php'; require_once AKSRT_DIR . '/inc/helpers/aksrt-null.php'; +require_once AKSRT_DIR . '/inc/helpers/aksrt-ajax-list.php'; require_once AKSRT_DIR . '/inc/helpers/aksrt-identity.php'; require_once AKSRT_DIR . '/inc/helpers/aksrt-rewards.php'; require_once AKSRT_DIR . '/inc/helpers/menu-decoration.php'; diff --git a/inc/helpers/aksrt-ajax-list.php b/inc/helpers/aksrt-ajax-list.php new file mode 100644 index 00000000..f870ef22 --- /dev/null +++ b/inc/helpers/aksrt-ajax-list.php @@ -0,0 +1,111 @@ +' + . '

' . esc_html((string) $text) . '

'; + } +} + +if (!function_exists('aksrt_get_ajax_next_paginate')) { + /** + * Load-more style paginate block (zibll zib_get_ajax_next_paginate subset). + * + * @param int $count_all Total items. + * @param int $page Current page (1-based). + * @param int $per_page Per page. + * @param string $ajax_url Next page URL (full or REST). + * @param string $pag_class Wrapper class. + * @param string $next_class Link class. + * @param string $label Button label. + */ + function aksrt_get_ajax_next_paginate( + int $count_all, + int $page = 1, + int $per_page = 10, + string $ajax_url = '', + string $pag_class = 'text-center theme-pagination ajax-pag', + string $next_class = 'next-page ajax-next', + string $label = '' + ): string { + $page = max(1, $page); + $per_page = max(1, $per_page); + $max = (int) ceil($count_all / $per_page); + if ($page >= $max || $count_all <= 0) { + return '
'; + } + if ($label === '') { + $label = __('加载更多', 'aksrt'); + } + $next = $page + 1; + $url = $ajax_url !== '' ? $ajax_url : '#'; + // Prefer caller-provided URL already containing next page. + return '
' + . '
' + . '' + . esc_html($label) + . '
'; + } +} + +if (!function_exists('aksrt_get_remote_modal_link')) { + /** + * Build a remote-modal trigger (zibll zib_get_refresh_modal_link subset). + * + * @param array{ + * tag?:string,class?:string,text?:string,url?:string, + * data_class?:string,height?:int,mobile_bottom?:bool, + * query_arg?:array + * } $args + */ + function aksrt_get_remote_modal_link(array $args = []): string { + $tag = sanitize_key((string) ($args['tag'] ?? 'a')); + if (!in_array($tag, ['a', 'button', 'span'], true)) { + $tag = 'a'; + } + $class = trim((string) ($args['class'] ?? '')); + $text = (string) ($args['text'] ?? __('打开', 'aksrt')); + $url = (string) ($args['url'] ?? ''); + if ($url === '' && !empty($args['query_arg']) && is_array($args['query_arg'])) { + // Prefer REST absolute URL if provided via action helper. + $url = add_query_arg($args['query_arg'], admin_url('admin-ajax.php')); + } + $data_class = (string) ($args['data_class'] ?? 'modal-mini full-sm'); + $height = isset($args['height']) ? (int) $args['height'] : 0; + $mobile = !isset($args['mobile_bottom']) || !empty($args['mobile_bottom']); + + $attrs = ' class="' . esc_attr(trim($class . ' aksrt-remote-modal-link')) . '"'; + $attrs .= ' data-toggle="AksrtRemoteModal"'; + $attrs .= ' data-remote="' . esc_url($url) . '"'; + $attrs .= ' data-class="' . esc_attr($data_class) . '"'; + if ($height > 0) { + $attrs .= ' data-height="' . esc_attr((string) $height) . '"'; + } + if ($mobile) { + $attrs .= ' mobile-bottom="true"'; + } + if ($tag === 'a') { + $attrs .= ' href="' . esc_url($url !== '' ? $url : '#') . '"'; + } else { + $attrs .= ' type="button"'; + } + + return '<' . $tag . $attrs . '>' . esc_html($text) . ''; + } +} diff --git a/src/AKSRT/Admin/ShopController.php b/src/AKSRT/Admin/ShopController.php index 864140c9..3e5ed19b 100644 --- a/src/AKSRT/Admin/ShopController.php +++ b/src/AKSRT/Admin/ShopController.php @@ -373,6 +373,12 @@ class ShopController extends ControllerBase { 'permission_callback' => [$this, 'check_logged_in_permission'], ]); + register_rest_route(self::API_NAMESPACE, '/shop/frontend/orders/(?P[A-Za-z0-9]+)/review-modal', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_frontend_product_review_modal'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + register_rest_route(self::API_NAMESPACE, '/shop/frontend/products/(?P\d+)/favorite', [ 'methods' => 'POST', 'callback' => [$this, 'toggle_frontend_product_favorite'], diff --git a/src/AKSRT/Admin/ShopControllerFrontendFlowOperations.php b/src/AKSRT/Admin/ShopControllerFrontendFlowOperations.php index c7fcadcb..1eed7dd9 100644 --- a/src/AKSRT/Admin/ShopControllerFrontendFlowOperations.php +++ b/src/AKSRT/Admin/ShopControllerFrontendFlowOperations.php @@ -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 = '

' . esc_html__('内容空空如也', 'aksrt') . '

'; + } + + // 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'] ?? ''); - ?> - - error($result['message'] ?? __('评价失败', 'aksrt')); } return $this->success([ - 'message' => __('评价已提交', 'aksrt'), + 'message' => __('评价成功', 'aksrt'), 'data' => $result, ], 201); } + /** + * GET review modal HTML for an order (zibll order_comment_modal parity). + */ + public function get_frontend_product_review_modal(\WP_REST_Request $request): \WP_REST_Response { + if (!class_exists('\AKSRT\Shop\ProductReview')) { + return $this->error(__('评价服务不可用', 'aksrt'), 500); + } + $order_number = sanitize_text_field((string) $request->get_param('order_number')); + $result = \AKSRT\Shop\ProductReview::get_instance()->get_review_modal_html( + get_current_user_id(), + $order_number + ); + if (empty($result['success'])) { + return $this->error((string) ($result['message'] ?? __('无法打开评价', 'aksrt'))); + } + return $this->success([ + 'html' => (string) ($result['html'] ?? ''), + 'product_id' => (int) ($result['product_id'] ?? 0), + 'need_shipping' => !empty($result['need_shipping']), + 'img_max' => (int) ($result['img_max'] ?? 0), + 'remain_seconds' => (int) ($result['remain_seconds'] ?? 0), + ]); + } + public function create_frontend_product(\WP_REST_Request $request): \WP_REST_Response { $params = $this->get_json_params($request); $params['author_id'] = get_current_user_id(); diff --git a/src/AKSRT/Core/ScriptsPart1b.php b/src/AKSRT/Core/ScriptsPart1b.php index ba075792..47f1ab73 100644 --- a/src/AKSRT/Core/ScriptsPart1b.php +++ b/src/AKSRT/Core/ScriptsPart1b.php @@ -117,7 +117,7 @@ trait ScriptsPart1b { } // 4a-b. 全局补充样式(骨架屏、空状态、分页、加载指示器) - foreach (['skeleton', 'empty-state', 'pagination', 'ajax-spinner'] as $slug) { + foreach (['skeleton', 'empty-state', 'pagination', 'ajax-spinner', 'remote-modal'] as $slug) { $css_path = get_template_directory() . '/assets/css/frontend/' . $slug . '.css'; if (file_exists($css_path)) { wp_enqueue_style( @@ -419,7 +419,7 @@ trait ScriptsPart1b { wp_enqueue_script( 'aksrt-order-center', $base_uri . '/assets/js/order-center.js', - ['aksrt-script', 'aksrt-weixin-share'], + ['aksrt-script', 'aksrt-weixin-share', 'aksrt-core-remote-modal'], file_exists(get_template_directory() . '/assets/js/order-center.js') ? (string) filemtime(get_template_directory() . '/assets/js/order-center.js') : $version, diff --git a/src/AKSRT/Core/ScriptsPart1c.php b/src/AKSRT/Core/ScriptsPart1c.php index 9aa874be..6cab71c1 100644 --- a/src/AKSRT/Core/ScriptsPart1c.php +++ b/src/AKSRT/Core/ScriptsPart1c.php @@ -118,6 +118,9 @@ trait ScriptsPart1c { 'aksrt-core-comments' => 'comments.js', 'aksrt-core-comment-fixed' => 'comment-fixed-input.js', 'aksrt-core-comment-move' => 'comment-move-form.js', + // Shared zibll-parity list/modal engines (before pagination consumers). + 'aksrt-core-ajax-list' => 'ajax-list.js', + 'aksrt-core-remote-modal' => 'remote-modal.js', 'aksrt-core-pagination' => 'pagination.js', 'aksrt-core-archive' => 'archive.js', 'aksrt-core-footer-tabbar' => 'footer-tabbar.js', diff --git a/src/AKSRT/Forum/Forum.php b/src/AKSRT/Forum/Forum.php index c05b31b6..5c504996 100644 --- a/src/AKSRT/Forum/Forum.php +++ b/src/AKSRT/Forum/Forum.php @@ -250,8 +250,8 @@ class Forum { $this->view_builder()->render_board_cards($boards); } - public function render_topic_row(array $topic): void { - $this->view_builder()->render_topic_row($topic); + public function render_topic_row(array $topic, string $style = 'detail'): void { + $this->view_builder()->render_topic_row($topic, $style); } public function get_board_view(int $board_id, array $args = []): array { diff --git a/src/AKSRT/Forum/ViewBuilder.php b/src/AKSRT/Forum/ViewBuilder.php index eccd3b4a..d8a95569 100644 --- a/src/AKSRT/Forum/ViewBuilder.php +++ b/src/AKSRT/Forum/ViewBuilder.php @@ -435,58 +435,97 @@ class ViewBuilder { /** * Render a single topic row (reusable for AJAX and initial render). + * + * @param array $topic Topic payload. + * @param string $style detail|mini (zibll forum-posts parity). */ - public function render_topic_row(array $topic): void { + public function render_topic_row(array $topic, string $style = 'detail'): void { + $style = $style === 'mini' ? 'mini' : 'detail'; $board = !empty($topic['boards'][0]) ? $topic['boards'][0] : null; $names = $this->forum->get_config()['names'] ?? []; $sticky_level = (int) ($topic['sticky_level'] ?? (!empty($topic['sticky']) ? 1 : 0)); - $row_class = 'aksrt-forum-topic-row'; + $permalink = (string) ($topic['permalink'] ?? '#'); + $cover = is_array($topic['cover'] ?? null) ? $topic['cover'] : null; + $vote = is_array($topic['vote'] ?? null) ? $topic['vote'] : null; + $excerpt = trim((string) ($topic['excerpt'] ?? '')); + $topic_cat = is_array($topic['topic_cat'] ?? null) ? $topic['topic_cat'] : null; + + $row_class = 'aksrt-forum-topic-row aksrt-forum-posts forum-posts ajax-item ' . $style; if ($sticky_level > 0) { $row_class .= ' is-sticky is-sticky-' . $sticky_level; } if (!empty($topic['featured'])) { $row_class .= ' is-featured'; } - $cover = $topic['cover'] ?? null; + + $badges = ''; + if ($sticky_level > 0) { + $badges .= '' + . esc_html((string) ($topic['sticky_label'] ?? __('置顶', 'aksrt'))) . ''; + } + if (!empty($topic['featured'])) { + $badges .= '' + . esc_html__('精华', 'aksrt') . ''; + } + if (($topic['bbs_type'] ?? '') === 'question') { + $q_status = (int) ($topic['question_status'] ?? 0); + $badges .= '' + . esc_html($q_status === 1 + ? (string) ($names['question_ok_badge'] ?? __('已解决', 'aksrt')) + : (string) ($names['question_badge'] ?? __('提问', 'aksrt'))) + . ''; + } + + $title_html = $badges + . '

' + . '' . esc_html((string) ($topic['title'] ?? '')) . '

'; + + if ($style === 'mini') { + ?> +
+
+ + + +
+ +
+ extract_topic_list_images($topic, 4); ?> -
-
- - - -
-
-
- 0) : ?> - + + */ + private function extract_topic_list_images(array $topic, int $max = 4): array { + $max = max(1, min(6, $max)); + $out = []; + $cover = is_array($topic['cover'] ?? null) ? $topic['cover'] : null; + if ($cover) { + if (($cover['type'] ?? '') === 'slide' && !empty($cover['images']) && is_array($cover['images'])) { + foreach ($cover['images'] as $u) { + $u = esc_url_raw((string) $u); + if ($u !== '') { + $out[] = $u; + } + if (count($out) >= $max) { + return $out; + } + } + } elseif (!empty($cover['url'])) { + $out[] = esc_url_raw((string) $cover['url']); + } + } + if (count($out) >= $max) { + return $out; + } + $content = (string) ($topic['content'] ?? ''); + if ($content !== '' && preg_match_all('/]+src=["\']([^"\']+)["\']/i', $content, $m)) { + foreach ($m[1] as $src) { + $src = esc_url_raw((string) $src); + if ($src === '' || in_array($src, $out, true)) { + continue; + } + $out[] = $src; + if (count($out) >= $max) { + break; + } + } + } + return $out; + } + public function get_user_forum_topics(int $user_id, array $args = []): array { $status = $args['status'] ?? 'publish'; $page = max(1, (int) ($args['page'] ?? 1)); diff --git a/src/AKSRT/Shop/Part1.php b/src/AKSRT/Shop/Part1.php index 969597de..5137d6a2 100644 --- a/src/AKSRT/Shop/Part1.php +++ b/src/AKSRT/Shop/Part1.php @@ -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,children: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, ]; } diff --git a/src/AKSRT/Shop/ProductReview.php b/src/AKSRT/Shop/ProductReview.php index 6468e667..e5d8ca40 100644 --- a/src/AKSRT/Shop/ProductReview.php +++ b/src/AKSRT/Shop/ProductReview.php @@ -55,10 +55,12 @@ class ProductReview { $images = []; $max_images = $this->get_max_images(); foreach ((array) ($data['images'] ?? []) as $img) { - if (count($images) >= $max_images) break; - $url = esc_url_raw((string) $img); - if ($url !== '') { - $images[] = $url; + if (count($images) >= $max_images) { + break; + } + $stored = $this->normalize_review_image((string) $img, $user_id); + if ($stored !== '') { + $images[] = $stored; } } @@ -83,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); @@ -301,9 +304,209 @@ class ProductReview { } } - private function get_max_images(): int { - if (!class_exists('\AKSRT\Settings\Settings')) return 9; + /** + * Remaining seconds until auto-positive review, or 0 if unknown/closed. + */ + public function get_auto_review_remaining_seconds(string $order_number): int { + if (!class_exists('\AKSRT\Payment\OrderRepository') || !class_exists('\AKSRT\Settings\Settings')) { + return 0; + } + $order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number); + if (!$order) { + return 0; + } + $anchor = (string) ($order['delivered_at'] ?? $order['completed_at'] ?? $order['updated_at'] ?? $order['created_at'] ?? ''); + if ($anchor === '') { + return 0; + } $shop = \AKSRT\Settings\Settings::get_shop_settings(); - return max(1, min(20, (int) ($shop['timing']['comment_img_num'] ?? 9))); + $max_day = max(1, (int) ($shop['timing']['comment_max_day'] ?? 15)); + $deadline = strtotime($anchor . ' +' . $max_day . ' days'); + if (!$deadline) { + return 0; + } + $remain = $deadline - time(); + return $remain > 0 ? (int) $remain : 0; + } + + /** + * Whether shipping score row should be shown for this order. + */ + public function order_needs_shipping_score(array $order): bool { + $method = is_array($order['shipping_method'] ?? null) ? $order['shipping_method'] : []; + $value = strtolower((string) ($method['value'] ?? $method['type'] ?? $order['delivery_type'] ?? '')); + if ($value === '') { + // Prefer showing logistics when shipping records or fee exist. + if (!empty($order['shipping_records']) || !empty($order['shipping']['count'])) { + return true; + } + $fee = (float) ($order['shipping_fee'] ?? ($order['shipping_method']['fee'] ?? 0)); + return $fee > 0; + } + if (in_array($value, ['virtual', 'auto', 'none', 'offline', 'self', 'pickup', 'no_shipping'], true)) { + return false; + } + return true; + } + + /** + * Build order review modal HTML (zibll order_comment_modal parity). + * + * @return array{success:bool,html?:string,message?:string} + */ + public function get_review_modal_html(int $user_id, string $order_number): array { + if ($user_id <= 0) { + return ['success' => false, 'message' => __('请先登录', 'aksrt')]; + } + if ($order_number === '' || !class_exists('\AKSRT\Payment\OrderRepository')) { + return ['success' => false, 'message' => __('订单不存在', 'aksrt')]; + } + + $order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number); + if (!$order || (int) ($order['user_id'] ?? 0) !== $user_id) { + return ['success' => false, 'message' => __('订单数据异常', 'aksrt')]; + } + + $product_id = 0; + $items = class_exists('\AKSRT\Shop\Shop') + ? Shop::get_instance()->get_order_items($order_number) + : []; + if (!empty($items[0]['product_id'])) { + $product_id = (int) $items[0]['product_id']; + } elseif (!empty($order['product_id'])) { + $product_id = (int) $order['product_id']; + } + + if ($product_id <= 0) { + return ['success' => false, 'message' => __('订单商品无效', 'aksrt')]; + } + + if ($this->has_reviewed($user_id, $order_number, $product_id)) { + return ['success' => false, 'message' => __('订单已评价', 'aksrt')]; + } + + if (!$this->can_review($user_id, $order_number, $product_id)) { + return ['success' => false, 'message' => __('当前订单不可评价', 'aksrt')]; + } + + $shop = class_exists('\AKSRT\Settings\Settings') + ? \AKSRT\Settings\Settings::get_shop_settings() + : []; + $placeholder = (string) ($shop['timing']['comment_placeholder'] ?? ''); + if ($placeholder === '') { + $placeholder = __('展开说说对商品的看法吧~', 'aksrt'); + } + $img_num = max(0, min(9, (int) ($shop['timing']['comment_img_num'] ?? 3))); + $need_shipping = $this->order_needs_shipping_score($order); + $remain = $this->get_auto_review_remaining_seconds($order_number); + + $score_html = ''; + $score_html .= $this->render_score_row(__('商品评分', 'aksrt'), 'product'); + if ($need_shipping) { + $score_html .= $this->render_score_row(__('物流服务', 'aksrt'), 'shipping'); + } + $score_html .= $this->render_score_row(__('服务态度', 'aksrt'), 'service'); + + $img_html = ''; + if ($img_num > 0) { + $img_html = '
'; + $img_html .= '
' . esc_html__('图片', 'aksrt') . '
'; + $img_html .= '
'; + $img_html .= '
'; + $img_html .= '
'; + $img_html .= ''; + $img_html .= '
'; + } + + $title = '' . esc_html__('评价订单', 'aksrt') . ''; + if ($remain > 0) { + $title .= '' + . '' . esc_html($this->format_countdown($remain)) . '' + . esc_html__('后自动好评', 'aksrt') + . ''; + } + + $html = '
' . $title . '
'; + $html .= ''; + $html .= '
'; + $html .= '
' . $score_html . '
'; + $html .= '
'; + $html .= $img_html; + $html .= ''; + $html .= '
'; + + return [ + 'success' => true, + 'html' => $html, + 'product_id' => $product_id, + 'need_shipping' => $need_shipping, + 'img_max' => $img_num, + 'remain_seconds' => $remain, + ]; + } + + private function render_score_row(string $label, string $key): string { + return '
' + . '
' . esc_html($label) . '
' + . '
' + . '
' + . '' + . '
'; + } + + private function format_countdown(int $seconds): string { + $seconds = max(0, $seconds); + $d = intdiv($seconds, 86400); + $h = intdiv($seconds % 86400, 3600); + $m = intdiv($seconds % 3600, 60); + $s = $seconds % 60; + if ($d > 0) { + return sprintf('%d天%02d:%02d:%02d', $d, $h, $m, $s); + } + return sprintf('%02d:%02d:%02d', $h, $m, $s); + } + + /** + * Accept https URL or data:image/*;base64,... and return a public URL. + */ + private function normalize_review_image(string $raw, int $user_id): string { + $raw = trim($raw); + if ($raw === '') { + return ''; + } + if (preg_match('#^https?://#i', $raw)) { + return esc_url_raw($raw); + } + if (!preg_match('#^data:image/(png|jpe?g|gif|webp);base64,#i', $raw, $m)) { + return ''; + } + $binary = base64_decode(substr($raw, strpos($raw, ',') + 1), true); + if ($binary === false || $binary === '') { + return ''; + } + if (strlen($binary) > 5 * 1024 * 1024) { + return ''; + } + $ext = strtolower($m[1]); + if ($ext === 'jpeg') { + $ext = 'jpg'; + } + $filename = 'review-' . $user_id . '-' . wp_generate_password(8, false) . '.' . $ext; + $upload = wp_upload_bits($filename, null, $binary); + if (!empty($upload['error']) || empty($upload['url'])) { + return ''; + } + return esc_url_raw((string) $upload['url']); + } + + private function get_max_images(): int { + if (!class_exists('\AKSRT\Settings\Settings')) { + return 9; + } + $shop = \AKSRT\Settings\Settings::get_shop_settings(); + return max(0, min(20, (int) ($shop['timing']['comment_img_num'] ?? 9))); } } diff --git a/templates/pages/page-orders.php b/templates/pages/page-orders.php index d05a327c..4ce2aa13 100644 --- a/templates/pages/page-orders.php +++ b/templates/pages/page-orders.php @@ -160,7 +160,7 @@ $tabs = [ - + diff --git a/templates/parts/shop/product-card.php b/templates/parts/shop/product-card.php index 37c435e1..26fc3a5f 100644 --- a/templates/parts/shop/product-card.php +++ b/templates/parts/shop/product-card.php @@ -34,7 +34,7 @@ $image = (string) ($product['image'] ?? ''); $excerpt = (string) ($product['excerpt'] ?? ''); $price = (float) ($product['price'] ?? 0); ?> -
+
diff --git a/templates/parts/shop/product-cat-filter.php b/templates/parts/shop/product-cat-filter.php new file mode 100644 index 00000000..e191e21b --- /dev/null +++ b/templates/parts/shop/product-cat-filter.php @@ -0,0 +1,149 @@ + __('最新', 'aksrt'), + 'views' => __('最热', 'aksrt'), + 'favorite_count' => __('收藏', 'aksrt'), + 'score' => __('评分', 'aksrt'), + 'sales' => __('销量', 'aksrt'), +]; + +$base_term_url = !empty($term['url']) ? (string) $term['url'] : ''; +if ($base_term_url === '' && $current_id > 0) { + $link = get_term_link($current_id, 'aksrt_product_cat'); + $base_term_url = is_wp_error($link) ? '' : (string) $link; +} + +$blog = wp_strip_all_tags(get_bloginfo('name')); +?> +
+ + + + + +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + +
+ + +
+ + $label) : + if ($orderby === $key) : ?> + + $key], $base_term_url) : add_query_arg(['orderby' => $key]); + ?> + + + + 'price', 'order' => $next_order], $base_term_url) + : add_query_arg(['orderby' => 'price', 'order' => $next_order]); + ?> + + + + 'price'], $base_term_url) + : add_query_arg(['orderby' => 'price']); + ?> + + + + +
+
diff --git a/templates/shop/product-cat.php b/templates/shop/product-cat.php index 9bc3853d..d15133fa 100644 --- a/templates/shop/product-cat.php +++ b/templates/shop/product-cat.php @@ -1,6 +1,6 @@ get_product_archive_view([ - 'taxonomy' => 'aksrt_product_cat', - 'term_id' => $term ? (int) $term->term_id : 0, - 'paged' => $paged, - 'orderby' => $orderby, -]) : ['products' => [], 'term' => null, 'total' => 0, 'max_pages' => 0, 'orderby' => 'date', 'paging' => 'number']; +$orderby = ''; +$order = ''; +if (isset($_GET['orderby'])) { + $orderby = sanitize_key(wp_unslash((string) $_GET['orderby'])); +} elseif (isset($_GET['shop_orderby'])) { + $orderby = sanitize_key(wp_unslash((string) $_GET['shop_orderby'])); +} +if (isset($_GET['order'])) { + $order = sanitize_key(wp_unslash((string) $_GET['order'])); +} + +$shop = class_exists('\AKSRT\Shop\Shop') ? \AKSRT\Shop\Shop::get_instance() : null; +$view = $shop ? $shop->get_product_archive_view([ + 'taxonomy' => 'aksrt_product_cat', + 'term_id' => $term ? (int) $term->term_id : 0, + 'paged' => $paged, + 'orderby' => $orderby, + 'order' => $order, +]) : ['products' => [], 'term' => null, 'total' => 0, 'max_pages' => 0, 'orderby' => 'date', 'order' => 'DESC', 'paging' => 'number']; $products = $view['products'] ?? []; $total = (int) ($view['total'] ?? 0); @@ -26,95 +37,108 @@ $max_pages = (int) ($view['max_pages'] ?? 0); $term_name = $term->name ?? ''; $term_desc = $term->description ?? ''; $active_orderby = (string) ($view['orderby'] ?? 'date'); +$active_order = strtoupper((string) ($view['order'] ?? 'DESC')); $paging = (string) ($view['paging'] ?? 'number'); $ias_s = !empty($view['ias_s']); $ias_max = (int) ($view['ias_max'] ?? 5); $style = (string) ($view['style'] ?? 'default'); $sales_cfg = is_array($view['sales'] ?? null) ? $view['sales'] : []; - -$order_options = [ - 'date' => __('最新', 'aksrt'), - 'sales' => __('销量', 'aksrt'), - 'price_asc' => __('价格升序', 'aksrt'), - 'price_desc' => __('价格降序', 'aksrt'), - 'comments' => __('评价', 'aksrt'), -]; +$filter = is_array($view['filter'] ?? null) ? $view['filter'] : null; ?>
- + -
+
-
-
-

- -

- -

-
+
+
+

+ +

+ +

- + +
- -
- $product, - 'sales_cfg' => $sales_cfg, - ]); - endforeach; ?> -
+
+ - 1) : ?> - -
- -
- - - - - -
- -

-
- -
+
+ + $product, + 'sales_cfg' => $sales_cfg, + ]); + endforeach; ?> + +

' . esc_html__('内容空空如也', 'aksrt') . '

'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> + +
+ + 1 && $paging === 'ajax') : ?> +
+
+ +
+
+ 1) : ?> + + +
+
get_product_archive_view([ - 'taxonomy' => 'aksrt_product_tag', - 'term_id' => $term ? (int) $term->term_id : 0, - 'paged' => $paged, - 'orderby' => $orderby, -]) : ['products' => [], 'term' => null, 'total' => 0, 'max_pages' => 0, 'orderby' => 'date', 'paging' => 'number']; +$orderby = ''; +$order = ''; +if (isset($_GET['orderby'])) { + $orderby = sanitize_key(wp_unslash((string) $_GET['orderby'])); +} elseif (isset($_GET['shop_orderby'])) { + $orderby = sanitize_key(wp_unslash((string) $_GET['shop_orderby'])); +} +if (isset($_GET['order'])) { + $order = sanitize_key(wp_unslash((string) $_GET['order'])); +} + +$shop = class_exists('\AKSRT\Shop\Shop') ? \AKSRT\Shop\Shop::get_instance() : null; +$view = $shop ? $shop->get_product_archive_view([ + 'taxonomy' => 'aksrt_product_tag', + 'term_id' => $term ? (int) $term->term_id : 0, + 'paged' => $paged, + 'orderby' => $orderby, + 'order' => $order, +]) : ['products' => [], 'term' => null, 'total' => 0, 'max_pages' => 0, 'orderby' => 'date', 'order' => 'DESC', 'paging' => 'number']; $products = $view['products'] ?? []; $total = (int) ($view['total'] ?? 0); @@ -26,95 +37,132 @@ $max_pages = (int) ($view['max_pages'] ?? 0); $term_name = $term->name ?? ''; $term_desc = $term->description ?? ''; $active_orderby = (string) ($view['orderby'] ?? 'date'); +$active_order = strtoupper((string) ($view['order'] ?? 'DESC')); $paging = (string) ($view['paging'] ?? 'number'); $ias_s = !empty($view['ias_s']); $ias_max = (int) ($view['ias_max'] ?? 5); $style = (string) ($view['style'] ?? 'default'); $sales_cfg = is_array($view['sales'] ?? null) ? $view['sales'] : []; -$order_options = [ - 'date' => __('最新', 'aksrt'), - 'sales' => __('销量', 'aksrt'), - 'price_asc' => __('价格升序', 'aksrt'), - 'price_desc' => __('价格降序', 'aksrt'), - 'comments' => __('评价', 'aksrt'), +$order_labels = [ + 'date' => __('最新', 'aksrt'), + 'views' => __('最热', 'aksrt'), + 'favorite_count' => __('收藏', 'aksrt'), + 'score' => __('评分', 'aksrt'), + 'sales' => __('销量', 'aksrt'), ]; +$base_url = $term && !is_wp_error(get_term_link($term)) ? get_term_link($term) : ''; ?>
- + -
+
-
-
-

- -

- -

-
+
+
+

+ +

+ +

- +
+ + $label) : + if ($active_orderby === $key) : ?> + + $key], $base_url) : add_query_arg(['orderby' => $key]); + ?> + + + + 'price', 'order' => $next_order], $base_url) : add_query_arg(['orderby' => 'price', 'order' => $next_order]); + ?> + + + + 'price'], $base_url) : add_query_arg(['orderby' => 'price']); + ?> + + + + +
+
- -
- $product, - 'sales_cfg' => $sales_cfg, - ]); - endforeach; ?> -
+
+ - 1) : ?> - -
- -
- - - - - -
- -

-
- -
+
+ + $product, + 'sales_cfg' => $sales_cfg, + ]); + endforeach; ?> + +

' . esc_html__('内容空空如也', 'aksrt') . '

'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> + +
+ + 1 && $paging === 'ajax') : ?> +
+
+ +
+
+ 1) : ?> + + +
+