2026-10-12 16:25:32 +08:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > Widget Test — Invite Card + Theme Color + Assertions</ title >
<!-- 断言面板依赖 window.ChatTheme(与 widget.js 内联拷贝做一致性比对) -->
< script src = "/static/js/theme.js" ></ script >
< style >
body {
font-family : Arial , sans-serif ;
padding : 40 px ;
background : #f5f5f5 ;
}
. info {
background : white ;
padding : 20 px ;
border-radius : 8 px ;
margin-bottom : 20 px ;
box-shadow : 0 2 px 8 px rgba ( 0 , 0 , 0 , 0.1 );
}
h1 { color : #333 ; margin-bottom : 10 px ; }
h2 { color : #333 ; margin-bottom : 10 px ; }
p { color : #666 ; line-height : 1.6 ; }
ul { color : #666 ; }
. test-list li { margin-bottom : 8 px ; }
. test-list li :: before { content : "☐ " ; color : #066fc0 ; font-weight : bold ; }
# assertions div { padding : 4 px 0 ; border-bottom : 1 px solid #eee ; font-family : Consolas , monospace ; font-size : 13 px ; }
# assertions div : last-child { border-bottom : none ; }
# colorPicker { width : 48 px ; height : 32 px ; padding : 0 ; border : 1 px solid #ccc ; border-radius : 4 px ; vertical-align : middle ; cursor : pointer ; }
. btn { padding : 8 px 16 px ; background : #066fc0 ; color : white ; border : none ; border-radius : 4 px ; cursor : pointer ; margin-right : 6 px ; }
. btn : hover { opacity : 0.9 ; }
</ style >
</ head >
< body >
< div class = "info" >
< h1 > Widget Invite Card Test Page</ h1 >
< p > This page tests the auto-popup invite card feature.</ p >
< p >< strong > Expected behavior:</ strong ></ p >
< ul class = "test-list" >
< li > Invite card appears after 5 seconds</ li >
2026-10-12 22:52:08 +08:00
< li > Invite card has brand-color gradient header with robot emoji</ li >
< li > Topic buttons come from the tenant brand config (< code > /api/public/brand</ code > → topics)</ li >
2026-10-12 16:25:32 +08:00
< li > Action buttons: "Start Chat" and "No Thanks"</ li >
2026-10-12 22:52:08 +08:00
< li > Click a topic button → invite closes → chat opens → message auto-sends</ li >
2026-10-12 16:25:32 +08:00
< li > Click "Start Chat" → invite closes → chat opens</ li >
< li > Click "No Thanks" → invite permanently closes</ li >
< li > Click "No Thanks" or X → refresh → invite stays hidden AND the old card area stays clickable (no invisible overlay; regression 2026-09-01)</ li >
< li > Click toggle button (while invite visible) → invite closes → chat opens</ li >
< li > Refresh page → invite appears again after 5s (unless "No Thanks" was clicked)</ li >
</ ul >
</ div >
< div class = "info" >
< h2 > Popular Topics in Chat Panel</ h2 >
< ul class = "test-list" >
2026-10-12 22:52:08 +08:00
< li > Chat panel shows "POPULAR TOPICS" section with the tenant's topic buttons</ li >
2026-10-12 16:25:32 +08:00
< li > Click topic → message auto-sends → topics section hides</ li >
</ ul >
</ div >
< div class = "info" >
< h2 > Theme Color Preview</ h2 >
< p > Pick a color and Apply, or use a preset. Each click tears down the old widget (host + script + invite dismissal state) and re-embeds with < code > data-color</ code > . "Test Broken Avatar" points < code > data-avatar</ code > at a missing image to verify the 🤖 emoji fallback.</ p >
< p >
< input type = "color" id = "colorPicker" value = "#066fc0" >
< button class = "btn" onclick = "applyColor()" > Apply</ button >
< button class = "btn" onclick = "applyColor('#066fc0')" > Brand Blue</ button >
< button class = "btn" onclick = "applyColor('#1a73e8')" > Blue</ button >
< button class = "btn" onclick = "applyColor('#16a34a')" > Green</ button >
< button class = "btn" onclick = "applyColor('#7c3aed')" > Purple</ button >
< button class = "btn" onclick = "rebuildWidget({'data-mobile':'false','data-color':document.getElementById('colorPicker').value})" > Desktop Pill</ button >
< button class = "btn" onclick = "applyBrokenAvatar()" > Test Broken Avatar</ button >
</ p >
</ div >
< div class = "info" >
< h2 > Assertion Panel</ h2 >
< p > Runs on every widget (re)embed. Checks: derived default palette vs current hardcoded values (max per-channel Δ ≤ 8), derive(default) === DEFAULT_PALETTE, inline widget.js copy === theme.js copy, invalid input falls back to DEFAULT_PALETTE.</ p >
< div id = "assertions" ></ div >
</ div >
< div class = "info" >
< p >< strong > Current Page:</ strong > < span id = "pathname" ></ span ></ p >
2026-10-12 22:26:15 +08:00
< p >< strong > Storage Key:</ strong > widget_invite_dismissed_< slug> _< span id = "storage-key" ></ span > (Task 10: key 按租户 slug 命名空间化)</ p >
2026-10-12 16:25:32 +08:00
< p >< button class = "btn" onclick = "clearStorage()" > Clear Storage & Refresh</ button ></ p >
</ div >
< script >
document . getElementById ( 'pathname' ). textContent = window . location . pathname ;
document . getElementById ( 'storage-key' ). textContent = window . location . pathname ;
function clearStorage () {
2026-10-12 22:26:15 +08:00
// Task 10: key 已租户化(widget_invite_dismissed_<slug>_<path>)——按前缀清扫全部
try {
Object . keys ( sessionStorage ). forEach ( function ( k ) {
if ( k . indexOf ( 'widget_invite_dismissed_' ) === 0 ) sessionStorage . removeItem ( k );
});
} catch ( e ) {}
2026-10-12 16:25:32 +08:00
location . reload ();
}
/* ===== Teardown / 重建(spec §7.1 三个陷阱:旧 host、旧 script、sessionStorage) ===== */
var BASE_ATTRS = {
"data-position" : "bottom-right" ,
"data-theme" : "light" ,
"data-mobile" : "true" ,
"data-debug" : "true" ,
"data-whatsapp" : "https://wa.me/8613800138000" ,
2026-10-12 22:52:08 +08:00
"data-email" : "mailto:demo@example.com"
2026-10-12 16:25:32 +08:00
};
var rebuildLoading = false , rebuildPending = null ;
2026-10-12 22:26:15 +08:00
function removeWidgetHosts () {
// Task 10: host id 已租户化(cc-widget-host-<slug>),按前缀清扫
var hosts = document . querySelectorAll ( '[id^="cc-widget-host-"]' );
for ( var i = 0 ; i < hosts . length ; i ++ ) hosts [ i ]. remove ();
2026-10-12 22:52:08 +08:00
// 复审: 清掉旧挂件的 debug 钩子——waitForWidget 轮询它,旧钩子残留
// 会让重建中的断言误读"新挂件已挂载"
try { delete window . __chatWidgetDebug ; } catch ( e ) {}
2026-10-12 22:26:15 +08:00
}
2026-10-12 16:25:32 +08:00
function rebuildWidget ( extra ) {
if ( rebuildLoading ) { rebuildPending = extra || {}; return ; } // 快速连点:只记最新意图,当前加载完接力重建
rebuildLoading = true ;
document . getElementById ( "assertions" ). innerHTML = "<div>…rebuilding…</div>" ;
2026-10-12 22:26:15 +08:00
removeWidgetHosts ();
2026-10-12 16:25:32 +08:00
document . querySelectorAll ( "script[src*='widget.js']" ). forEach ( function ( s ) { s . remove (); });
2026-10-12 22:26:15 +08:00
try {
Object . keys ( sessionStorage ). forEach ( function ( k ) {
if ( k . indexOf ( "widget_invite_dismissed_" ) === 0 ) sessionStorage . removeItem ( k );
});
} catch ( e ) {}
2026-10-12 16:25:32 +08:00
var s = document . createElement ( "script" );
s . src = "/static/widget.js?" + Date . now ();
var attrs = Object . assign ({}, BASE_ATTRS , extra || {});
Object . keys ( attrs ). forEach ( function ( k ) { s . setAttribute ( k , attrs [ k ]); });
s . onload = function () {
rebuildLoading = false ;
2026-10-12 22:26:15 +08:00
// Task 10: 挂件改等 brand 端点落定后才挂载——脚本 onload 早于挂载,
// 轮询 __chatWidgetDebug(boot 里同步设置,观察到场即 init 已完成)
// 就绪再清竞态残留并跑断言
waitForWidget ( function () {
var hosts = document . querySelectorAll ( '[id^="cc-widget-host-"]' );
for ( var i = 0 ; i < hosts . length - 1 ; i ++ ) hosts [ i ]. remove (); // 竞态兜底:只保留最后挂载
runAssertions (); // 挂件未就绪(超时)时其内部 !d 分支自行报红
});
2026-10-12 16:25:32 +08:00
var next = rebuildPending ; rebuildPending = null ;
if ( next ) rebuildWidget ( next );
};
s . onerror = function () {
rebuildLoading = false ;
document . getElementById ( "assertions" ). innerHTML = "<div>❌ widget.js 加载失败</div>" ;
var next = rebuildPending ; rebuildPending = null ;
if ( next ) rebuildWidget ( next );
};
document . body . appendChild ( s );
}
2026-10-12 22:26:15 +08:00
function waitForWidget ( cb ) {
var started = Date . now ();
( function poll () {
if ( window . __chatWidgetDebug ) return cb ( true );
if ( Date . now () - started > 5000 ) return cb ( false ); // brand 挂起/失败兜底
setTimeout ( poll , 100 );
})();
}
2026-10-12 16:25:32 +08:00
function applyColor ( c ) {
rebuildWidget ({ "data-color" : c || document . getElementById ( "colorPicker" ). value });
}
function applyBrokenAvatar () {
rebuildWidget ({ "data-color" : "#1a73e8" , "data-avatar" : "/static/nope.png" });
}
/* ===== 断言面板(spec §8.2) ===== */
var CURRENT_HARDCODED = { // 现状代表值(HESUPIPE 品牌蓝 #066fc0 派生),Δ ≤ 8/通道
primary : "#066fc0" , gradientEnd : "#0666bb" , deepText : "#14517a" ,
captionText : "#2174ab" , softBg : "#f3f9fe" , borderSoft : "#bbdcf2"
};
function hexToRgb6 ( h ) {
h = String ( h ). replace ( /^#/ , "" );
return [ parseInt ( h . slice ( 0 , 2 ), 16 ), parseInt ( h . slice ( 2 , 4 ), 16 ), parseInt ( h . slice ( 4 , 6 ), 16 )];
}
function channelDelta ( a , b ) {
var ra = hexToRgb6 ( a ), rb = hexToRgb6 ( b );
return Math . max ( Math . abs ( ra [ 0 ] - rb [ 0 ]), Math . abs ( ra [ 1 ] - rb [ 1 ]), Math . abs ( ra [ 2 ] - rb [ 2 ]));
}
function runAssertions () {
var rows = [];
function check ( name , ok , detail ) { rows . push (( ok ? "✅ " : "❌ " ) + name + ( detail ? " — " + detail : "" )); }
var t = window . ChatTheme , d = window . __chatWidgetDebug ;
if ( ! t || ! d ) {
rows . push ( "❌ theme.js / widget debug 钩子未就绪" );
} else {
var p = t . derivePalette ( "#066fc0" );
Object . keys ( CURRENT_HARDCODED ). forEach ( function ( k ) {
var delta = channelDelta ( p [ k ], CURRENT_HARDCODED [ k ]);
check ( "default " + k + " Δ≤8" , delta <= 8 , p [ k ] + " vs " + CURRENT_HARDCODED [ k ] + ", Δ=" + delta );
});
check ( "derive(default) === DEFAULT_PALETTE" ,
JSON . stringify ( p ) === JSON . stringify ( t . DEFAULT_PALETTE ));
check ( "inline copy === theme.js copy" ,
d . derivePalette . toString (). replace ( /\s+/g , "" ) === t . derivePalette . toString (). replace ( /\s+/g , "" ));
check ( "invalid color → DEFAULT_PALETTE" ,
JSON . stringify ( t . derivePalette ( "not-a-color" )) === JSON . stringify ( t . DEFAULT_PALETTE ));
}
document . getElementById ( "assertions" ). innerHTML = rows . map ( function ( r ) { return "<div>" + r + "</div>" ; }). join ( "" );
}
/* 初始嵌入:打开页面即有挂件 + 断言跑一轮 */
document . addEventListener ( "DOMContentLoaded" , function () { rebuildWidget ({}); });
</ script >
</ body >
</ html >