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/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..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 = '
'; + $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 = [ - +