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("
");
+ 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/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 '';
+ }
+}
+
+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) . '' . $tag . '>';
+ }
+}
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..18de487e 100644
--- a/src/AKSRT/Admin/ShopControllerFrontendFlowOperations.php
+++ b/src/AKSRT/Admin/ShopControllerFrontendFlowOperations.php
@@ -533,11 +533,35 @@ trait ShopControllerFrontendFlowOperations {
return $this->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/Shop/ProductReview.php b/src/AKSRT/Shop/ProductReview.php
index 6468e667..4a6b2115 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;
}
}
@@ -301,9 +303,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 = '';
+ }
+
+ $title = '' . esc_html__('评价订单', 'aksrt') . '';
+ if ($remain > 0) {
+ $title .= ''
+ . '' . esc_html($this->format_countdown($remain)) . ''
+ . esc_html__('后自动好评', 'aksrt')
+ . '';
+ }
+
+ $html = '' . $title . '
';
+ $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 = [
-
+