feat: brand 白名单下发(站点覆盖/租户头像/CORS) + widget/chat 客户端 key 租户化 + 会话站点标
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
+133
-43
@@ -5,12 +5,18 @@
|
||||
})();
|
||||
|
||||
// Save config immediately (before DOM ready)
|
||||
// Task 10 值管道: data-* 属性(嵌入方显式覆盖,最高优先) > brand 端点
|
||||
// (租户/站点门面) > 中性兜底。此处只同步读原始属性;有效值在 brand
|
||||
// 落定后由 boot() 推导(见文件尾),品牌文案不再硬编码于本文件。
|
||||
var position = script.getAttribute("data-position") || "bottom-right";
|
||||
var theme = script.getAttribute("data-theme") || "light";
|
||||
var title = script.getAttribute("data-title") || "HESUPIPE Support";
|
||||
var titleAttr = script.getAttribute("data-title");
|
||||
var autoOpen = script.getAttribute("data-auto-open") === "true";
|
||||
var whatsapp = script.getAttribute("data-whatsapp") || "https://wa.me/+8615937108317";
|
||||
var email = script.getAttribute("data-email") || "mailto:hesu@hesuu.com";
|
||||
var whatsappAttr = script.getAttribute("data-whatsapp");
|
||||
var emailAttr = script.getAttribute("data-email");
|
||||
var colorAttr = (script.getAttribute("data-color") || "").trim();
|
||||
var avatarAttr = (script.getAttribute("data-avatar") || "").trim();
|
||||
var siteAttr = (script.getAttribute("data-site") || "").trim();
|
||||
var autoInvite = script.getAttribute("data-auto-invite") !== "false";
|
||||
var isMobile = script.getAttribute("data-mobile") === "true";
|
||||
var wsBase = new URL(script.src).origin;
|
||||
@@ -109,14 +115,62 @@
|
||||
|
||||
/* ===== 主题派生单元结束 ===== */
|
||||
var DEFAULT_AVATAR_PATH = "/static/kefu-small.png";
|
||||
var avatarAttr = (script.getAttribute("data-avatar") || "").trim();
|
||||
var avatarUrl = avatarAttr
|
||||
? (/^https?:\/\//i.test(avatarAttr) ? avatarAttr : wsBase + "/" + avatarAttr.replace(/^\//, ""))
|
||||
: wsBase + DEFAULT_AVATAR_PATH;
|
||||
var colorAttr = (script.getAttribute("data-color") || "").trim();
|
||||
var palette = derivePalette(colorAttr || DEFAULT_COLOR);
|
||||
if (script.getAttribute("data-debug") === "true") {
|
||||
window.__chatWidgetDebug = { derivePalette: derivePalette, palette: palette };
|
||||
|
||||
// ---- Task 10: brand 端点取门面 + 有效值推导 ------------------------------
|
||||
// __brand = /api/public/brand 响应(null = 拉取失败/超时 → 中性兜底,不白屏)
|
||||
var __brand = null;
|
||||
var title = "", whatsapp = "", email = "", avatarUrl = "", privacyUrl = "";
|
||||
var welcome = "", placeholder = "Type a message...", topics = [];
|
||||
var palette = null;
|
||||
var brandSlug = "default";
|
||||
|
||||
function fetchBrand() {
|
||||
var url = wsBase + "/api/public/brand" + (siteAttr ? "?site=" + encodeURIComponent(siteAttr) : "");
|
||||
var p = fetch(url, { cache: "no-store" }).then(function (r) {
|
||||
return r.ok ? r.json() : null;
|
||||
}).catch(function () {
|
||||
return null; // fetch 失败 → data-*/中性兜底,挂件照常渲染
|
||||
});
|
||||
// 挂起保护: brand 端点 3s 无响应按失败处理,网络问题不挡挂件挂载
|
||||
return Promise.race([p, new Promise(function (res) { setTimeout(function () { res(null); }, 3000); })]);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s).replace(/[&<>"']/g, function (ch) {
|
||||
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch];
|
||||
});
|
||||
}
|
||||
|
||||
function contactHref(value, kind) {
|
||||
// brand 存裸值(电话/邮箱),data-* 历史上传完整 URL——两者都接受
|
||||
if (!value) return "";
|
||||
if (/^(https?:|mailto:)/i.test(value)) return value;
|
||||
return kind === "wa" ? "https://wa.me/" + value : "mailto:" + value;
|
||||
}
|
||||
|
||||
// boot: brand 落定(成功或失败)后推导有效值再挂载。只改"默认值从哪来",
|
||||
// 不触碰上方主题派生单元(调色板算法零改动,只换输入)。
|
||||
function boot() {
|
||||
var b = (__brand && typeof __brand === "object") ? __brand : {};
|
||||
title = titleAttr || b.title || "Chat with us";
|
||||
whatsapp = contactHref(whatsappAttr || b.whatsapp || "", "wa");
|
||||
email = contactHref(emailAttr || b.email || "", "mail");
|
||||
palette = derivePalette(colorAttr || (b.color || "").trim() || DEFAULT_COLOR);
|
||||
avatarUrl = avatarAttr
|
||||
? (/^https?:\/\//i.test(avatarAttr) ? avatarAttr : wsBase + "/" + avatarAttr.replace(/^\//, ""))
|
||||
: (b.avatar ? wsBase + (b.avatar.charAt(0) === "/" ? b.avatar : "/" + b.avatar)
|
||||
: wsBase + DEFAULT_AVATAR_PATH);
|
||||
welcome = b.welcome || "";
|
||||
topics = Array.isArray(b.topics)
|
||||
? b.topics.filter(function (t) { return typeof t === "string" && t; })
|
||||
: [];
|
||||
placeholder = b.placeholder || "Type a message...";
|
||||
privacyUrl = b.privacy_url || "";
|
||||
brandSlug = String(b.slug || "default").replace(/[^a-zA-Z0-9_-]/g, "") || "default";
|
||||
if (script.getAttribute("data-debug") === "true") {
|
||||
window.__chatWidgetDebug = { derivePalette: derivePalette, palette: palette };
|
||||
}
|
||||
init();
|
||||
}
|
||||
|
||||
var positionStyles = {
|
||||
@@ -146,7 +200,7 @@
|
||||
|
||||
function init() {
|
||||
var host = document.createElement("div");
|
||||
host.id = "cc-widget-host";
|
||||
host.id = "cc-widget-host-" + brandSlug;
|
||||
document.body.appendChild(host);
|
||||
|
||||
var shadow = host.attachShadow({ mode: "open" });
|
||||
@@ -300,32 +354,61 @@
|
||||
var container = document.createElement("div");
|
||||
container.className = "widget-container pos-" + position + (isMobile ? " mobile-host" : "");
|
||||
|
||||
// Task 10: 品牌/联系/隐私/话题分块——值缺位(data-* 与 brand 都没给)则整块
|
||||
// 不渲染(隐私行隐藏、联系按钮隐藏、话题区隐藏),不再硬编码任何租户文案
|
||||
var waBtnHtml = whatsapp
|
||||
? '<a href="' + esc(whatsapp) + '" target="_blank" rel="noopener" class="call-btn whatsapp-btn"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg><span>WhatsApp</span></a>'
|
||||
: "";
|
||||
var emBtnHtml = email
|
||||
? '<a href="' + esc(email) + '" class="call-btn email-btn"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg><span>Email</span></a>'
|
||||
: "";
|
||||
var privacyLineHtml = privacyUrl
|
||||
? 'By chatting, you agree to our <a href="' + esc(privacyUrl) + '" target="_blank" rel="noopener">privacy policy</a>.'
|
||||
: "";
|
||||
var chatFooterHtml = privacyLineHtml
|
||||
? '<div class="chat-footer"><span class="footer-left">' + privacyLineHtml + '</span></div>'
|
||||
: "";
|
||||
var invitePrivacyHtml = privacyLineHtml
|
||||
? '<div class="invite-privacy">' + privacyLineHtml + '</div>'
|
||||
: "";
|
||||
var topicsBtnsHtml = topics.map(function (t) {
|
||||
return '<button class="popular-topic-btn" data-topic="' + esc(t) + '">' + esc(t) + '</button>';
|
||||
}).join("");
|
||||
var inviteTopicsHtml = topics.map(function (t) {
|
||||
return '<button class="invite-topic-btn" data-topic="' + esc(t) + '">' + esc(t) + '</button>';
|
||||
}).join("");
|
||||
var popularTopicsSectionHtml = topics.length
|
||||
? '<div class="popular-topics-fixed" id="w-popular-topics"><div class="popular-topics-label">POPULAR TOPICS</div><div class="popular-topics-grid">' + topicsBtnsHtml + '</div></div><div class="chat-separator-topics"></div>'
|
||||
: "";
|
||||
var welcomeMsgHtml = welcome
|
||||
? '<div class="msg-wrapper"><div class="msg ai">' + esc(welcome) + '</div></div>'
|
||||
: "";
|
||||
|
||||
var panel = document.createElement("div");
|
||||
panel.className = "chat-panel";
|
||||
panel.innerHTML = [
|
||||
'<div class="panel-header">',
|
||||
'<div class="avatar"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="'+avatarUrl+'"></div>',
|
||||
'<div class="info"><h3>' + title + '</h3><div class="status">Online now · Usually replies in minutes</div></div>',
|
||||
'<div class="info"><h3>' + esc(title) + '</h3><div class="status">Online now · Usually replies in minutes</div></div>',
|
||||
'<div class="actions">',
|
||||
'<button class="action-btn" aria-label="Minimize">−</button>',
|
||||
// '<button class="close-btn action-btn" aria-label="Close">×</button>',
|
||||
'</div></div>',
|
||||
'<div class="ended" id="w-ended">Chat ended. We\'ll be in touch!</div>',
|
||||
'<div class="panel-top">',
|
||||
'<div class="help-block"><div style="display:flex;align-items:center;gap:14px;"><div class="help-content"><div class="help-title">How can we help?</div><div class="help-description">Ask about pipe specifications, quotations, MOQ, shipping and delivery.</div></div><div class="help-actions"><a href="' + whatsapp + '" target="_blank" class="call-btn whatsapp-btn"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg><span>WhatsApp</span></a><a href="' + email + '" class="call-btn email-btn"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg><span>Email</span></a></div></div></div>',
|
||||
'<div class="help-block"><div style="display:flex;align-items:center;gap:14px;"><div class="help-content"><div class="help-title">How can we help?</div><div class="help-description">Ask about pipe specifications, quotations, MOQ, shipping and delivery.</div></div><div class="help-actions">' + waBtnHtml + emBtnHtml + '</div></div></div>',
|
||||
'</div>',
|
||||
'<div class="chat-separator"></div>',
|
||||
'<div class="panel-messages" id="w-msgs">',
|
||||
'<div class="msg-wrapper"><div class="msg ai">What kind of piping project are you working on?</div></div>',
|
||||
welcomeMsgHtml,
|
||||
'</div>',
|
||||
'<div class="typing" id="w-typing"><span></span><span></span><span></span></div>',
|
||||
'<div class="popular-topics-fixed" id="w-popular-topics"><div class="popular-topics-label">POPULAR TOPICS</div><div class="popular-topics-grid"><button class="popular-topic-btn" data-topic="Get Quotation">Get Quotation</button><button class="popular-topic-btn" data-topic="Pipe Specifications">Pipe Specifications</button><button class="popular-topic-btn" data-topic="Delivery & Shipping">Delivery & Shipping</button></div></div>',
|
||||
'<div class="chat-separator-topics"></div>',
|
||||
popularTopicsSectionHtml,
|
||||
'<div class="input-container"><div class="input-wrapper">',
|
||||
'<input type="text" id="w-input" placeholder="Type a message...">',
|
||||
'<input type="text" id="w-input" placeholder="' + esc(placeholder) + '">',
|
||||
'<button id="w-send"><span style="font-size:16px;">➤</span><span>Send</span></button>',
|
||||
'</div></div>',
|
||||
'<div class="chat-footer"><span class="footer-left">By chatting, you agree to our <a href="https://hesupipe.com/privacy-policy/" target="_blank" rel="noopener">privacy policy</a>.</span></div>',
|
||||
chatFooterHtml,
|
||||
].join("");
|
||||
|
||||
// Invite card HTML template
|
||||
@@ -342,23 +425,23 @@
|
||||
'<button class="invite-close-btn" aria-label="Close">',
|
||||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>',
|
||||
'</button>',
|
||||
'<a href="' + whatsapp + '" target="_blank" class="invite-action-btn whatsapp-btn">',
|
||||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>',
|
||||
'<span>WhatsApp</span>',
|
||||
'</a>',
|
||||
'<a href="' + email + '" class="invite-action-btn email-btn">',
|
||||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>',
|
||||
'<span>Email</span>',
|
||||
'</a>',
|
||||
'</div>',
|
||||
'</div>',
|
||||
'<div class="invite-body">',
|
||||
'<div class="invite-topics">',
|
||||
'<button class="invite-topic-btn" data-topic="Get Quotation">Get Quotation</button>',
|
||||
'<button class="invite-topic-btn" data-topic="Pipe Specifications">Pipe Specifications</button>',
|
||||
'<button class="invite-topic-btn" data-topic="Delivery & Shipping">Delivery & Shipping</button>',
|
||||
(whatsapp
|
||||
? '<a href="' + esc(whatsapp) + '" target="_blank" rel="noopener" class="invite-action-btn whatsapp-btn">'
|
||||
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>'
|
||||
+ '<span>WhatsApp</span></a>'
|
||||
: ""),
|
||||
(email
|
||||
? '<a href="' + esc(email) + '" class="invite-action-btn email-btn">'
|
||||
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>'
|
||||
+ '<span>Email</span></a>'
|
||||
: ""),
|
||||
'</div>',
|
||||
'</div>',
|
||||
(topics.length
|
||||
? '<div class="invite-body">'
|
||||
+ '<div class="invite-topics">' + inviteTopicsHtml + '</div>'
|
||||
+ '</div>'
|
||||
: ""),
|
||||
'<div class="chat-separator"></div>',
|
||||
'<div class="input-container">',
|
||||
'<div class="invite-actions">',
|
||||
@@ -367,7 +450,7 @@
|
||||
'</button>',
|
||||
'<button class="invite-dismiss-btn">No Thanks</button>',
|
||||
'</div>',
|
||||
'<div class="invite-privacy">By chatting, you agree to our <a href="https://hesupipe.com/privacy-policy/" target="_blank" rel="noopener">privacy policy</a>.</div>',
|
||||
invitePrivacyHtml,
|
||||
'</div>',
|
||||
'</div>'
|
||||
].join("");
|
||||
@@ -429,7 +512,8 @@
|
||||
var inviteTimer = null;
|
||||
var invitePanel = null;
|
||||
var inviteShown = false;
|
||||
var inviteStorageKey = "widget_invite_dismissed_" + window.location.pathname;
|
||||
// Task 10: key 租户化——同浏览器多租户挂件互不踩(不同租户 slug 隔离)
|
||||
var inviteStorageKey = "widget_invite_dismissed_" + brandSlug + "_" + window.location.pathname;
|
||||
var isPermanentlyClosed = false;
|
||||
|
||||
function checkInviteStorage() {
|
||||
@@ -597,10 +681,12 @@
|
||||
wWs = new WebSocket(wsHost + "/ws/chat");
|
||||
|
||||
wWs.onopen = function () {
|
||||
// Task 10: init 带 site(空域名站点如 es 靠 data-site 接入,服务端
|
||||
// 记入 client_info["site"] 作会话站点标;空值服务端忽略)
|
||||
if (wSessionId) {
|
||||
wWs.send(JSON.stringify({ session_id: wSessionId, page_token: pageToken }));
|
||||
wWs.send(JSON.stringify({ session_id: wSessionId, page_token: pageToken, site: siteAttr }));
|
||||
} else {
|
||||
wWs.send(JSON.stringify({ source: "widget", page_token: pageToken }));
|
||||
wWs.send(JSON.stringify({ source: "widget", page_token: pageToken, site: siteAttr }));
|
||||
}
|
||||
// Send any pending message after WebSocket opens
|
||||
if (pendingMessage) {
|
||||
@@ -696,10 +782,14 @@
|
||||
startInviteTimer();
|
||||
}
|
||||
|
||||
// Wait for body if script is in <head>
|
||||
if (document.body) {
|
||||
init();
|
||||
} else {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
// Wait for body if script is in <head>; Task 10: 同时等 brand 落定(成功或
|
||||
// 失败都继续)再挂载——两条件齐备才 init,任一失败路径都不白屏
|
||||
function domReady(fn) {
|
||||
if (document.body) fn();
|
||||
else document.addEventListener("DOMContentLoaded", fn);
|
||||
}
|
||||
fetchBrand().then(function (b) {
|
||||
__brand = b;
|
||||
domReady(boot);
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user