活動報名系統
DOING Market
發現好活動,遇見好日常
市集・體驗・合作・寄賣・團購,讓生活更有趣
關於 DOING 系統
DOING 是給市集、活動與主辦團隊使用的一站式營運系統,從活動管理、報名收款、現場報到到營運分析,都集中在同一個工作空間。
📅 活動管理場次建立與管理
💳 報名收款線上報名與金流
▦ 現場報到QR Code 報到
📊 數據分析營運統計
LINE 登入我的紀錄
使用 LINE 登入並記住登入狀態
第一次登入若資料未完整,登入後再補會員資料即可
找不到報名紀錄嗎?
我們可以幫你查找遺漏的報名紀錄,或協助解決任何報名問題!
填寫後存檔,下次報名會自動帶入資料。
🔗社群連結
審核必要,FB、IG 或官網至少填一項。
前往繳費
⚠️ 還差最後一步!請把付款資料傳給主辦
系統已記錄您的付款回報(狀態:待確認),但主辦還沒收到您的付款證明。
請完成以下兩步,否則無法完成繳費:
① 複製下方付款卡片
② 傳到官方 LINE,並附上付款截圖
主辦確認入帳後,狀態才會變成「已繳費」。
⚠️ 只截圖這個畫面「不算」完成!一定要按下面①複製、再按②傳到官方 LINE,主辦收到才會確認。
請複製付款卡片內容,並連同付款截圖一起傳至官方 LINE。
取消報名確認
加價選位
請先看上方已編號場地圖,再從下方加價位置清單選號碼。
尚未選擇位置
可左右滑動查看長條圖;按 + 可放大號碼。回到選位後,下方只會開放加價位置可點選。
💬
聯絡該場次官方 LINE
付款、異動、退費、設備、現場問題
›
報名前需要準備哪些資料?
請先準備品牌名稱、聯絡人資料、商品內容、攤位照片或商品照片,以及 FB/IG/官網/作品連結等可供主辦審核的資料。
沒有 FB、IG 或官網連結可以報名嗎?
可以送出報名,但會影響審核。主辦需要確認品牌、商品與擺攤內容,若資料不足,較不容易通過審核。
送出報名後就代表錄取嗎?
不是。送出報名只代表主辦已收到申請,是否錄取仍需等待主辦審核。
商品內容可以和報名資料不同嗎?
不可以。現場展示與販售內容須與報名資料一致,禁止擅自更換商品、併攤、分租或代售未審核品牌商品。
我要去哪裡看目前報名進度?
登入報名系統後,進入「我的紀錄」即可查詢目前進度與報名狀態。
錄取後會怎麼通知?
錄取後會收到信件,也可以到報名系統「我的紀錄」查看付款資訊、應繳金額與付款期限,並依系統顯示完成付款流程。
錄取後沒有在期限內付款會怎樣?
若逾期未完成付款或未完成付款回報,可能會取消錄取資格,並將名額釋出給候補攤商。
付款後要怎麼回報?
付款完成後,請回到「我的紀錄」進行付款回報,並務必通知該活動場次官方 LINE 客服,方便主辦確認款項。
付款狀態會怎麼變?
一般流程為:錄取待付款 → 已回報付款/付款待確認 → 主辦確認後變成已付款。實際狀態請以「我的紀錄」顯示為準。
桌椅或設備要怎麼加購?
報名時請依需求選擇設備數量,系統會顯示單價、數量與小計。送出前請確認設備與金額是否正確。
送出後可以更改設備嗎?
若已送出報名或已完成付款,需聯繫該活動場次官方 LINE 客服,由主辦確認是否還能異動。
攤位是自己選還是主辦安排?
若未開放選位,或未在期限內完成選位,將依繳費順序安排;若該場次開放加價選位,請依系統開放時間選位。
可以自己換攤位或要求固定位置嗎?
不可以。攤位需依系統或現場工作人員指定位置擺設,不得自行換位、超出攤位範圍或影響通道。主辦保有攤位與現場配置最終調整權,攤商須配合位置更動,不得因此要求退費或補償。
場次有供電嗎?
請依各場次公告為準。若該場次無供電,請自行準備行動電源或照明設備,禁止自行使用場地插座。
活動當天什麼時候報到?
原則上活動前 60 分鐘開始報到,例如 11:00 開始活動,報到時間約為 10:00–10:45,實際時間以該場次公告為準。
什麼是開攤照與清場照?
開攤完成後,請在活動開始前 10 分鐘上傳「開攤照」至該活動場次官方 LINE。撤場時請清潔攤位、將垃圾自行帶走,並拍攝「清場照」回傳。開攤照與清場照確認後,才會進入保證金退還流程。
可以提早收攤嗎?
活動結束前 30 分鐘不得收攤。若空攤、早退或未依規定完成簽退,可能影響押金或保證金退還。
保證金怎麼退?
請依規定完成報到、開攤照、撤場清潔、清場照與簽退。工作人員確認無違規、無設備損壞、無垃圾遺留後,依主辦流程退還保證金。
遲到、早退或未報到會怎樣?
遲到、早退、未報到或違反現場規範,可能影響保證金退還;若多次發生,將不再錄取。
報名後想取消怎麼辦?
請先登入系統,進入「我的紀錄」查看該筆報名狀態,依系統顯示操作取消或退費申請。
退費規則是什麼?
活動前 7 日以上取消,扣除 500 元行政費後退還餘額;活動前 3~6 日取消,退還報名費 50%,不退押金;活動前 3 日內或活動當日未出席,不退押金、不退費。
遇到天災、疫情或不可抗力怎麼辦?
若因天災、疫情或不可抗力影響活動,主辦將依公告辦理延期或退費方案,實際處理方式以該場次公告為準。
進場搬運商品或設備須注意什麼?
禁止使用手扶梯搬運道具、商品或設備,請依場地方或現場工作人員指定方式搬運。
現場有哪些基本規範?
請依指定位置設攤,不得超出攤位範圍,不得阻礙通道,不得自行換位,不得使用明火、高溫電器或危險設備,垃圾須自行帶走,並配合現場工作人員指示。
活動可以寄放物品或原地包棚嗎?
依各場次公告為準。若有開放寄放,僅提供已登記攤商使用,且主辦不負保管責任。貴重物品請自行帶走;若可原地包棚,商品須完整包覆,不可裸露在外。
寄放要怎麼登記?
依官方 LINE 大群組、記事本或公告格式留言,例如:場次名稱+品牌名稱+寄放件數+是否原地包棚。
百貨寄賣合作要注意什麼?
百貨寄賣、快閃櫃或寄賣品牌合作,須依該場次寄賣規範辦理。商品須貼上「編號+金額」標籤,合作確認後不得中止、撤件或退費,對帳與撥款依主辦公告流程處理。
滿額抽獎或引客活動需要配合什麼?
若該活動有安排滿額抽獎、引客券或互動任務,請攤商依主辦公告配合執行,例如確認消費、發放券、提供兌換品或引導客人完成互動。
通常會在哪裡公告?
活動公告、重要通知與規範會以該活動場次官方 LINE、LINE 記事本與「全台市集藝文資訊中心」大群組公告為主。報名後請記得加入,避免漏看重要消息。全台市集藝文資訊中心大群組:https://line.me/ti/g2/cp-K_Los4J2zBc6rGcRA14TJCx3e99v0i4p-hQ?utm_source=invitation&utm_medium=link_copy&utm_campaign=default
有問題要聯絡誰?
請優先聯繫「該活動場次」官方 LINE 客服。付款回報、資料異動、取消退費、設備問題、現場問題,皆請透過官方 LINE 聯繫。
營運入口
請選擇要申請營運空間,或以已授權 LINE 帳號登入。
');
w.document.close();
toast&&toast('請長按圖片,選「加入照片」即可存檔',7000);
}else toast&&toast('請允許開啟新分頁,或改按「分享」儲存',7000);
return;
}
const a=document.createElement('a'); a.href=PF.shotDataUrl; a.download=photoFileName();
document.body.appendChild(a); a.click(); document.body.removeChild(a);
toast&&toast('已下載');
}
async function sharePhoto(){
if(!PF.shotDataUrl){ toast&&toast('尚未拍照'); return; }
try{
const blob=await (await fetch(PF.shotDataUrl)).blob();
const file=new File([blob],photoFileName(),{type:'image/png'});
if(navigator.share && (!navigator.canShare || navigator.canShare({files:[file]}))){
await navigator.share({files:[file],title:(PF.frame&&PF.frame.name)||'活動拍照'});
return;
}
}catch(e){ if(e && e.name==='AbortError') return; }
await downloadPhoto();
}
async function api(params){
params = {...(params||{})};
if (!params.tenant && !params.tenantId) params.tenant = TENANT_ID;
const _memberToken=memberSafeToken();
if(_memberToken && !params.member_token && !params.memberToken && !params.token) params.member_token=_memberToken;
if (!params._t) params._t = Date.now();
const qs=Object.entries(params).map(([k,v])=>k+'='+encodeURIComponent(v)).join('&');
// 逾時保護:沒有這個的話,連線卡住就永遠不回,畫面上的按鈕會一直停用(使用者以為「按不下去」)
const ctrl=(typeof AbortController!=='undefined')?new AbortController():null;
const _to=Number(params.__timeout)||20000;
if(params.__timeout) delete params.__timeout;
const timer=setTimeout(()=>{ try{ ctrl&&ctrl.abort(); }catch(e){} }, _to);
try{
const r=await fetch(API_BASE+'?'+qs,{cache:'no-store',signal:ctrl?ctrl.signal:undefined});
if(!r.ok){toast&&toast('⚠️ 連線失敗,請稍後再試');return{error:'fail'};}
return await r.json();
}
catch(e){
const aborted=(e&&(e.name==='AbortError'));
toast&&toast(aborted?'⚠️ 連線逾時,請檢查網路後再試一次':'⚠️ 網路連線失敗');
return {error: aborted?'timeout':'fail'};
}
finally{ clearTimeout(timer); }
}
async function apiPost(body, timeoutMs=30000){
body={...(body||{})};
if (!body.tenant && !body.tenantId) body.tenant = TENANT_ID;
const _memberToken=memberSafeToken();
if(_memberToken && !body.member_token && !body.memberToken && !body.token) body.member_token=_memberToken;
const ctrl = new AbortController();
const timer = setTimeout(()=>ctrl.abort(), timeoutMs);
try{
const r=await fetch(API_BASE+'?_t='+Date.now(),{method:'POST',headers:{'Content-Type':'application/json;charset=utf-8','Cache-Control':'no-store'},body:JSON.stringify(body),signal:ctrl.signal,cache:'no-store'});
const txt=await r.text();
let data={};
try{ data=txt?JSON.parse(txt):{}; }catch(e){ data={error:'後端回傳格式錯誤', raw:txt}; }
if(!r.ok){ toast('⚠️ 連線失敗'); return data.error?data:{error:'連線失敗'}; }
return data;
}catch(e){
console.error('[apiPost error]', e);
if(e && e.name==='AbortError'){
toast('⚠️ 送出逾時,請稍後查詢我的紀錄');
return {error:'送出逾時:後端沒有在 15 秒內回應。系統可能仍在處理,請先到「我的紀錄」確認是否已出現報名;若沒有,請截圖此畫面給官方 LINE。'};
}
toast('⚠️ 網路錯誤');
return {error:'網路錯誤,請稍後再試'};
}finally{
clearTimeout(timer);
}
}
function isAmbiguousRegisterTransportError(message){
const s=String(message||'');
return ['送出逾時','網路錯誤','連線失敗','後端回傳格式錯誤'].some(x=>s.includes(x));
}
// POST 回覆若在手機網路中途遺失,先回查正式 registrations;查到就視為成功,
// 避免資料已入庫、畫面卻叫使用者重送而造成重複報名。
async function recoverCommittedRegistration(sessionId,email,phone){
try{
const rows=await api({action:'getMyRegs',...(memberSafeToken()?{member_token:memberSafeToken()}:{email,phone}),_t:Date.now(),__timeout:12000});
if(!Array.isArray(rows)) return null;
return rows.find(r=>String(r.sessionId||r.session_id||'')===String(sessionId)
&& !['不錄取','已取消'].includes(String(r.status||r.reviewStatus||''))
&& !['已退費','已退款'].includes(String(r.transferStatus||r.transfer_status||''))) || null;
}catch(_e){ return null; }
}
function openModal(id){const el=$(id);if(el){el.classList.add('show');el.classList.add('open');}}
function closeModal(id){const el=$(id);if(el){el.classList.remove('show');el.classList.remove('open');}}
function openLine(){ if(!LINE_URL){ toast('此主辦尚未設定 LINE,請聯繫主辦'); return; } window.open(LINE_URL,'_blank','noopener'); }
function openCooperationEmail(){
const subject = encodeURIComponent('合作邀請/商業合作洽詢');
const body = encodeURIComponent('您好,我想洽詢活動邀約/異業合作/商業合作,以下為合作需求說明:\n\n聯絡人:\n單位/品牌名稱:\n聯絡電話:\n合作需求:\n預計活動時間/地點:\n');
const gmailUrl = `https://mail.google.com/mail/?view=cm&fs=1&to=ndiangrace@gmail.com&su=${subject}&body=${body}`;
const mailtoUrl = `mailto:ndiangrace@gmail.com?subject=${subject}&body=${body}`;
const opened = window.open(gmailUrl, '_blank', 'noopener');
if(!opened){ window.location.href = mailtoUrl; }
}
function esc(v){return String(v??'').replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'"').replace(//g,'>');}
// ── 頁面切換 ──
// ── 統一返回 ──
function goBack(){
history.back();
}
// ── UI v2:底部五分類導覽 ──
async function navToTab(tab){
try{
localStorage.setItem('doing_market_current_tab', tab);
localStorage.setItem('doing_market_page_state', JSON.stringify({tab}));
}catch(e){}
['botHome','botMember','botSupport'].forEach(id=>{
const el=document.getElementById(id);
if(el) el.classList.remove('active');
});
const tabMap={home:'botHome',member:'botMember',support:'botSupport'};
const activeBtn=document.getElementById(tabMap[tab]);
if(activeBtn) activeBtn.classList.add('active');
if(tab==='home'){
navTo('home');
return;
}
if(tab==='member'){
const email=String(ST.memberEmail||memberSafeEmail()||'').trim();
const phone=String(memberSafePhone(email)||(ST.member&&ST.member.phone)||'').trim();
const token=memberSafeToken();
navTo('member');
if(token||(email&&phone)){
memberShowArea('regs');
await safeLoadMemberPage(email,{phone,forceRegs:true,navOptions:{noPush:true}});
}else{
memberShowLogin();
const mailInput=$('memberLookupEmail');
const phoneInput=$('memberLookupPhone');
const err=$('memberLoginErr');
if(mailInput) mailInput.value=email||memberSafeEmail()||'';
if(phoneInput) phoneInput.value=phone||'';
if(err&&email&&!phone){
err.style.display='block';
err.textContent='請輸入手機號碼完成身分驗證,即可查看原有報名紀錄。';
}
}
return;
}
if(tab==='support'){
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
const pg=document.getElementById('pageSupport');
if(pg) pg.classList.add('active');
}
}
// ── 探索頁篩選 ──
// ── 近期活動渲染(首頁第四區塊)──
function renderRecentSessions(){
const el=document.getElementById('recentSessionList');
if(!el) return;
const allSessions = (window.ST && ST.allSessionsData) ? ST.allSessionsData : [];
let sessions = allSessions
.filter(s=>{
if(s.status==='closed'||s.status==='archived'||s.status==='cancelled'||s.isHidden||s.testMode||s.internal) return false;
return true;
});
sessions = sortSessionsByUpcoming(sessions);
if(!sessions.length){
el.innerHTML='
目前無近期活動
';
return;
}
el.innerHTML = sessions.map(s=>renderActCard(s,'full')).join('');
}
// ── 活動卡片渲染(共用)──
function renderActCard(s, mode){
const isFull = mode==='full';
const cls = isFull ? 'act-card full' : 'act-card';
const id = s.id||s.sessionId||'';
const name = s.name||s.title||'未命名活動';
const dateStr = s.date||s.startDate||s.eventDate||'';
const timeStr = s.startTime||s.time||'';
const loc = s.location||s.venue||s.place||'';
const fee = s.baseFee!=null ? 'NT$'+Number(s.baseFee).toLocaleString()+(s.hasFeeRange?'起':'') : (s.feeText||s.fee||'依項目計算');
const regType = s.regType||s.type||s.category||'';
const cover = s.cover||'';
const isOpen = s.regOpen!==false && s.status!=='closed' && s.status!=='cancelled';
const isClosed = !isOpen;
// 狀態文字
let statusTxt='報名中', statusCls='';
if(s.status==='closed'||s.regOpen===false){ statusTxt='已截止'; statusCls='closed'; }
else if(s.isFull||s.remainSlots===0){ statusTxt='額滿'; statusCls='full-s'; }
else if(s.regStartDate && new Date(s.regStartDate)>new Date()){ statusTxt='即將開放'; statusCls='soon'; }
else if(s.regType==='合作洽詢'||s.regType==='審核制'){ statusTxt='審核制'; }
else if(s.status==='coming'){ statusTxt='即將開始'; statusCls='soon'; }
// 分類標籤 class
let tagCls='market';
if(regType.includes('寄賣')) tagCls='consign';
else if(regType.includes('體驗')) tagCls='exp';
else if(regType.includes('合作')) tagCls='collab';
// 圖片
const imgHtml = cover
? `
`
: `
🎪
`;
// 報名按鈕
const regBtnTxt = isClosed ? statusTxt : '立即報名';
const regBtnCls = isClosed ? 'act-card-reg disabled' : 'act-card-reg';
const regAction = isClosed ? '' : `onclick="openSessionById('${id}')"`;
return `
${name}
${dateStr?`📅 ${dateStr}${timeStr?' '+timeStr:''}`:''}
${loc?`📍 ${loc}`:''}
${(typeof myRegBadge==='function'?myRegBadge(s):'')}
${(typeof frontBundleBadgeHtml==='function'?frontBundleBadgeHtml(s):'')}
${regType?`
${regType}`:''}
${(typeof photoFrameBtnHtml==='function'?photoFrameBtnHtml(s):'')}
`;
}
// ── 分享(卡片版)──
function shareSessionCard(id, name, event){
if(event) event.stopPropagation();
openSharePanel('session', id, name);
}
// ── openSessionById(開啟單場報名表)──
function uiNotice(message){
return new Promise(function(resolve){
var old=document.getElementById('uiNoticeOverlay'); if(old){ try{old.remove();}catch(e){} }
var ov=document.createElement('div'); ov.id='uiNoticeOverlay';
ov.style.cssText='position:fixed;inset:0;z-index:100000;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;padding:20px';
var box=document.createElement('div'); box.style.cssText='width:min(100%,420px);background:#fff;border-radius:18px;padding:20px;box-shadow:0 18px 48px rgba(0,0,0,.22)';
var msg=document.createElement('div'); msg.style.cssText='white-space:pre-wrap;margin-bottom:18px;font-weight:800;line-height:1.7;color:#222'; msg.textContent=String(message||'');
var ok=document.createElement('button'); ok.type='button'; ok.textContent='知道了'; ok.style.cssText='width:100%;padding:13px;border-radius:12px;border:none;background:#628E87;color:#fff;font-size:17px;font-weight:900;cursor:pointer;font-family:inherit';
var done=function(){ try{ov.remove();}catch(e){} resolve(true); }; ok.onclick=done; ov.onclick=function(e){ if(e.target===ov) done(); };
box.appendChild(msg); box.appendChild(ok); ov.appendChild(box); document.body.appendChild(ov);
});
}
function showMemberSocialReviewNoticeOnce(){
if(ST._memberSocialReviewNoticeShown) return Promise.resolve();
ST._memberSocialReviewNoticeShown=true;
return uiNotice('FB、IG 或官網至少建議填寫一項\n方便主辦審核品牌與商品內容。\n若完全未填寫,審核將不會通過。');
}
function openSessionById(id){
if(!id) return;
openSession(id);
}
function navTo(tab, opts={}){
if(typeof removePayCartBar==='function' && tab!=='member') removePayCartBar();
const el=$('page'+tab.charAt(0).toUpperCase()+tab.slice(1));
if(!el) return;
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
el.classList.add('active');
window.scrollTo(0,0);
// 儲存完整狀態(含 portal 分類)
try{
const state = { tab };
if(tab==='portal' && ST.currentCatFilter) state.catFilter = ST.currentCatFilter;
localStorage.setItem('doing_market_current_tab', tab);
localStorage.setItem('doing_market_page_state', JSON.stringify(state));
}catch(e){}
// 返回首頁時清除背景和報名表狀態
if(tab==='home'){
const pp=$('pagePortal');if(pp)pp.style.background='';
document.body.style.background='var(--wh)';
try{ localStorage.removeItem('doing_market_page_state'); localStorage.setItem('doing_market_page_state', JSON.stringify({tab:'home'})); }catch(e){}
cleanFrontHomeUrl();
}
if(!opts.noPush){
if(tab==='home') history.pushState({page:tab}, '', location.pathname + location.search + location.hash);
else history.pushState({page:tab}, '', location.href);
}
}
function memberSafeEmail(){
try{return String(localStorage.getItem('doing_market_member_email')||'').trim();}catch(e){return '';}
}
const MEMBER_TOKEN_KEY='doing_market_member_token';
function handleDoingLineCallback(){
try{
const u=new URL(location.href);
const token=String(u.searchParams.get('member_token')||'').trim();
const pending=String(u.searchParams.get('line_pending')||'').trim();
const memberStatus=String(u.searchParams.get('member_status')||'').trim();
const err=String(u.searchParams.get('member_login_error')||u.searchParams.get('login_error')||'').trim();
['member_token','member_status','member_login_error','login_error','line_pending'].forEach(k=>u.searchParams.delete(k));
if(token){
memberSaveToken(token);
sessionStorage.removeItem('doing_line_pending');
history.replaceState({},'',u.pathname+(u.searchParams.toString()?'?'+u.searchParams.toString():'')+u.hash);
setTimeout(()=>safeLoadMemberPage('',{navOptions:{noPush:true},forceRegs:memberStatus!=='profile_required'}),40);
return;
}
if(pending){
sessionStorage.setItem('doing_line_pending',pending);
history.replaceState({},'',u.pathname+(u.searchParams.toString()?'?'+u.searchParams.toString():'')+u.hash);
setTimeout(()=>{
navTo('member',{noPush:true}); memberShowLogin();
const box=$('doingLineFirstBind'), normal=$('memberLookupBtn'), errEl=$('memberLoginErr');
if(box) box.style.display='block'; if(normal) normal.style.display='none';
const em=$('doingLineBindEmail'); if(em && !em.value) em.value=memberSafeEmail();
if(errEl){errEl.style.display='block';errEl.style.color='#555';errEl.textContent='LINE 驗證成功,第一次使用請完成會員綁定。';}
},40);
return;
}
if(err){
history.replaceState({},'',u.pathname+(u.searchParams.toString()?'?'+u.searchParams.toString():'')+u.hash);
setTimeout(()=>{navTo('member',{noPush:true});memberShowLogin();const e=$('memberLoginErr');if(e){e.style.display='block';e.style.color='#c0392b';e.textContent='LINE 登入失敗:'+err;}},40);
}
}catch(_e){}
}
async function completeDoingLineBind(){
const pending=String(sessionStorage.getItem('doing_line_pending')||'').trim();
const email=String(($('doingLineBindEmail')||{}).value||'').trim();
const phone=String(($('doingLineBindPhone')||{}).value||'').trim();
const err=$('memberLoginErr'), btn=$('doingLineBindBtn');
if(!pending){ if(err){err.style.display='block';err.textContent='LINE 綁定已失效,請重新使用 LINE 登入。';} return; }
if(!/\S+@\S+\.\S+/.test(email)){ if(err){err.style.display='block';err.textContent='請填寫正確 Email。';} return; }
if(!phone){ if(err){err.style.display='block';err.textContent='請填寫手機號碼。';} return; }
if(btn){btn.disabled=true;btn.textContent='綁定中…';}
try{
const res=await apiPost({action:'completeLineMemberLogin',linePendingToken:pending,email,phone},20000);
if(!res||res.error) throw new Error((res&&res.error)||'LINE 綁定失敗');
const token=String(res.token||res.member_token||'').trim();
if(!token) throw new Error('登入憑證未建立');
memberSaveToken(token); memberSaveEmail(email); memberSavePhone(phone,email);
ST.member=res.member||{email,phone}; ST.memberEmail=email; ST._verifiedMemberPhone=phone;
sessionStorage.removeItem('doing_line_pending');
const box=$('doingLineFirstBind'), normal=$('memberLookupBtn'); if(box) box.style.display='none'; if(normal) normal.style.display='block';
await safeLoadMemberPage(email,{navOptions:{noPush:true},forceRegs:true});
}catch(e){ if(err){err.style.display='block';err.style.color='#c0392b';err.textContent=e.message||'LINE 綁定失敗';} }
finally{ if(btn){btn.disabled=false;btn.textContent='完成綁定並登入';} }
}
window.completeDoingLineBind=completeDoingLineBind;
function memberSafeToken(){
try{return String(localStorage.getItem(MEMBER_TOKEN_KEY)||'').trim();}catch(e){return '';}
}
function memberSaveToken(token){
const value=String(token||'').trim();
try{
if(value) localStorage.setItem(MEMBER_TOKEN_KEY,value);
else localStorage.removeItem(MEMBER_TOKEN_KEY);
}catch(e){}
ST.memberToken=value;
}
function memberClearToken(){
memberSaveToken('');
}
function memberSaveEmail(email){
const next=String(email||'').trim();
try{localStorage.setItem('doing_market_member_email', next); localStorage.removeItem('tb_member');}catch(e){}
}
function memberSafePhone(email){
const expectedEmail=String(email||(ST&&ST.memberEmail)||memberSafeEmail()||'').trim().toLowerCase();
const memoryPhone=String((ST&&ST.member&&ST.member.phone)||'').trim();
const memoryEmail=String((ST&&ST.member&&ST.member.email)||(ST&&ST.memberEmail)||'').trim().toLowerCase();
if(memoryPhone&&(!expectedEmail||!memoryEmail||memoryEmail===expectedEmail)) return memoryPhone;
return '';
}
function memberSavePhone(phone,email){
const value=String(phone||'').trim();
const ownerEmail=String(email||(ST&&ST.member&&ST.member.email)||(ST&&ST.memberEmail)||memberSafeEmail()||'').trim().toLowerCase();
ST.member={...(ST.member||{}),email:(ST.member&&ST.member.email)||ownerEmail,phone:value};
}
function memberShowLogin(){
const loginArea=$('memberLoginArea');
const memberArea=$('memberArea');
if(loginArea) loginArea.style.display='block';
if(memberArea) memberArea.style.display='none';
}
function memberShowArea(mode){
const loginArea=$('memberLoginArea');
const memberArea=$('memberArea');
if(loginArea) loginArea.style.display='none';
if(memberArea) memberArea.style.display='block';
switchMemberMode(mode||'regs');
['botHome','botMember','botSupport'].forEach(id=>{
const el=document.getElementById(id);
if(el) el.classList.remove('active');
});
const btn=document.getElementById('botMember');
if(btn) btn.classList.add('active');
}
function switchMemberMode(mode){
const regsDiv = $('myRegsMode');
const profileDiv = $('profileMode');
const tabRegs = $('tabMyRegs');
const tabProfile = $('tabProfile');
const hero = $('memberHeroCard');
const heroIp = $('memberHeroIp');
const editBtn = $('memberHeroEditBtn');
const isRegs = mode === 'regs';
if(regsDiv) regsDiv.style.display = isRegs ? 'block' : 'none';
if(profileDiv) profileDiv.style.display = isRegs ? 'none' : 'block';
if(tabRegs){
tabRegs.classList.toggle('active-record', isRegs);
tabRegs.classList.toggle('active-profile', false);
}
if(tabProfile){
tabProfile.classList.toggle('active-profile', !isRegs);
tabProfile.classList.toggle('active-record', false);
}
if(hero){
hero.classList.toggle('m-hero-record', isRegs);
hero.classList.toggle('m-hero-profile', !isRegs);
}
if(editBtn){
editBtn.classList.toggle('m-btn-edit-green', isRegs);
editBtn.classList.toggle('m-btn-edit-purple', !isRegs);
}
if(heroIp){ heroIp.src = isRegs ? 'IP會員.png' : 'IP資料.png'; }
if(!isRegs) renderMemberProfile();
}
function memberVal(m, ...keys){
for(const k of keys){
const v=m && m[k];
if(v!==undefined && v!==null && String(v).trim()!=='') return String(v);
}
return '';
}
function memberDisplayValue(m, keys, fallback='—'){
const val = Array.isArray(keys) ? memberVal(m, ...keys) : memberVal(m, keys);
const str = String(val || '').trim();
return str || fallback;
}
function memberProfileSocialHtml(type, url, label){
const clean = String(url || '').trim();
if(!clean) return '';
const icon = type === 'fb' ? 'f' : type === 'ig' ? '◎' : '🔗';
return `${icon}`;
}
function memberProfileRow(icon, label, value){
return `
${icon}
${escHtml(label)}
${value}
`;
}
function renderMemberProfile(){
const m = ST.member || {};
const email = String(m.email || ST.memberEmail || memberSafeEmail() || '').trim();
const el = $('memberProfileContent');
if(!el) return;
const name = memberDisplayValue(m, ['name','displayName'], '—');
const phone = memberDisplayValue(m, ['phone'], '—');
const brand = memberDisplayValue(m, ['brand','brandName','brand_name'], '—');
const fb = String(memberVal(m,'fb','fb_url') || '').trim();
const ig = String(memberVal(m,'ig','ig_url') || '').trim();
const web = String(memberVal(m,'collabUrl','collab_url','website','web') || '').trim();
const socialHtml = [
memberProfileSocialHtml('fb', fb, 'Facebook'),
memberProfileSocialHtml('ig', ig, 'Instagram'),
memberProfileSocialHtml('web', web, '官網')
].filter(Boolean).join('') || '尚未填寫';
const invoiceType = memberDisplayValue(m, ['invoiceType','invoice_type'], '電子發票');
const taxId = memberDisplayValue(m, ['taxId','tax_id'], '—');
const company = memberDisplayValue(m, ['company','invoiceTitle','invoice_title'], brand !== '—' ? brand : '—');
const carrier = memberDisplayValue(m, ['invoiceCarrier','invoice_carrier'], '—');
const invoiceEmail = memberDisplayValue(m, ['invoiceEmail','invoice_email'], email || '—');
const hasName = !!memberVal(m,'name','displayName');
const hasBrand = !!memberVal(m,'brand','brandName','brand_name');
const hasSocial = !!(fb || ig || web);
const warn = (!hasName || !hasBrand || !hasSocial) ? `
請補齊會員資料:${!hasName?'姓名 ':''}${!hasBrand?'品牌名稱 ':''}${!hasSocial?'FB、IG 或官網 ':''}
補齊並儲存後,報名表會自動帶入資料。
` : '';
el.innerHTML = `
${warn}
${memberProfileRow('♡','姓名', escHtml(name))}
${memberProfileRow('✉','Email', escHtml(email || '—'))}
${memberProfileRow('☎','手機', escHtml(phone))}
${memberProfileRow('◇','品牌名稱', escHtml(brand))}
${memberProfileRow('◎','社群 / 官網', `
${socialHtml}
`)}
${memberProfileRow('▣','發票類型', escHtml(invoiceType))}
${memberProfileRow('▤','統編', escHtml(taxId))}
${memberProfileRow('▥','抬頭', escHtml(company))}
${memberProfileRow('✉','載具 / Email', escHtml(carrier !== '—' ? carrier : invoiceEmail))}
✅
會員資料只需填寫一次,
下次報名將自動帶入,省時又方便!
資料如有異動,請記得更新唷~
`;
}
function syncMemberIntoCurrentRegForm(member){
const m=member||ST.member||{};
const setText=(id,val)=>{const el=$(id); if(el) el.textContent=val||'';};
const setVal=(id,val)=>{const el=$(id); if(el) el.value=val||'';};
const brand=memberVal(m,'brand','brandName','brand_name');
const name=memberVal(m,'name','displayName');
const phone=memberVal(m,'phone');
const fb=memberVal(m,'fb','fb_url');
const ig=memberVal(m,'ig','ig_url');
setText('disp_brand',brand); setText('disp_name',name); setText('disp_phone',phone);
setVal('f_brand',brand); setVal('f_name',name); setVal('f_phone',phone); setVal('f_fb',fb); setVal('f_ig',ig);
setVal('f_taxId',memberVal(m,'taxId','tax_id'));
setVal('f_company',memberVal(m,'company','invoiceTitle','invoice_title'));
setVal('f_invoiceTitle',memberVal(m,'company','invoiceTitle','invoice_title'));
setVal('f_brandIntro',memberVal(m,'brandIntro','brand_intro','brandDesc'));
setVal('f_sellItem',memberVal(m,'sellItem','sell_items','sell_category','sellCat'));
setVal('f_invoiceCarrier',memberVal(m,'invoiceCarrier','invoice_carrier'));
setVal('f_invoiceEmail',memberVal(m,'invoiceEmail','invoice_email') || memberVal(m,'email') || ST.memberEmail || '');
}
function preFillMemberFields(){
try{
const m=ST.member||{};
syncMemberIntoCurrentRegForm(m);
const email=(m.email||ST.memberEmail||memberSafeEmail()||'').trim();
const fEmail=$('f_email'); if(fEmail)fEmail.value=email;
const dispEmail=$('disp_email'); if(dispEmail)dispEmail.textContent=email;
}catch(e){ console.warn('[preFillMemberFields]',e); }
}
function fillMemberCard(member){
const m=member||{};
const email=String(m.email||ST.memberEmail||memberSafeEmail()||'').trim();
const name=(m.name||m.realName||email||'會員');
const brand=(m.brand||m.brandName||m.brand_name||'');
const phone=(m.phone||'');
const fb=(m.fb||m.fb_url||'');
const ig=(m.ig||m.ig_url||'');
// 更新「我的紀錄」頁的顯示
const nameEl=$('memberName'); if(nameEl) nameEl.textContent=name;
const emailEl=$('memberEmail2'); if(emailEl) emailEl.textContent=email;
const brandEl=$('memberBrandShow'); if(brandEl) brandEl.textContent=brand?('品牌:'+brand):'';
// 同步更新報名表的顯示欄位
const dispBrand=$('disp_brand'); if(dispBrand) dispBrand.textContent=brand;
const dispName=$('disp_name'); if(dispName) dispName.textContent=name;
const dispPhone=$('disp_phone'); if(dispPhone) dispPhone.textContent=phone;
// 同步更新報名表的隱藏欄位(送出時使用)
const fBrand=$('f_brand'); if(fBrand) fBrand.value=brand;
const fName=$('f_name'); if(fName) fName.value=name;
const fPhone=$('f_phone'); if(fPhone) fPhone.value=phone;
const fFb=$('f_fb'); if(fFb) fFb.value=fb;
const fIg=$('f_ig'); if(fIg) fIg.value=ig;
}
// ── 會員 email 唯一來源 ──
// 過去各處自行拼 ST.memberEmail / ST.member.email / localStorage,順序不一,
// 只要其中一個是空的就會誤判成「未登入」(曾造成取消報名後名單整個空白)。
// 一律改用本函式取值,不要再自行組合。
function getMemberEmail(){
const a = (ST && ST.memberEmail) ? String(ST.memberEmail).trim() : '';
if(a) return a;
const b = (ST && ST.member && ST.member.email) ? String(ST.member.email).trim() : '';
if(b) return b;
try{ return String(localStorage.getItem('doing_market_member_email')||'').trim(); }catch(e){ return ''; }
}
// ── 會員手機唯一來源 ──
// 過去手機同時來自「呼叫端參數 / localStorage / ST.member」,呼叫端各自拼。
// 取消報名、退款申請、付款回報後重新載入名單時漏傳手機 → 後端 hGetMyRegs 直接擋下,
// 回「請提供 Email 與手機,才能查詢我的紀錄」,被誤會成報名紀錄不見了。
// 一律改用本函式取值,不要再自行組合。
function getMemberPhone(){
const a = (ST && ST.member && ST.member.phone) ? String(ST.member.phone).trim() : '';
if(a) return a;
return memberSafePhone();
}
async function safeGetMyRegs(email, phone){
const el=$('myRegsList');
const helper=$('memberRecordHelper');
const token=memberSafeToken();
// 呼叫端沒帶手機就自動補;補不到才顯示「請補上手機」,不讓後端回誤導紅字。
const ph = String(phone || getMemberPhone() || '').trim();
if(helper) helper.classList.remove('show');
if(!token&&!ph){
ST.regs=[];
if(el) el.innerHTML='
請補上手機號碼查詢報名紀錄。
系統需要 Email+手機比對,避免資料被其他人看到。
';
if(helper) helper.classList.add('show');
return [];
}
if(el) el.innerHTML='
報名紀錄載入中…
';
try{
const regs=token
? await api({action:'getMyRegs',memberToken:token,_t:Date.now()})
: await api({action:'getMyRegs',email,phone:ph,_t:Date.now()});
if(regs && regs.error){
if(token&&String(regs.error).includes('登入憑證已失效')) memberClearToken();
ST.regs=[];
if(el){
el.innerHTML=`
${escHtml(regs.error)}
請確認 Email 與手機是否與報名時一致。
`;
}
if(helper) helper.classList.add('show');
return [];
}
const list=Array.isArray(regs)?regs:[];
ST.regs=list;
try{ await loadMemberNotifications(); }catch(_e){}
renderMyRegs(list);
try{ if(Array.isArray(ST.sessions) && ST.sessions.length){ ['renderRecentSessions','renderSessions','renderAllSessions','renderHomeFallbackSessions'].forEach(fn=>{ try{ if(typeof window[fn]==='function') window[fn](); }catch(_e){} }); } }catch(_e){}
return list;
}catch(e){
console.warn('[getMyRegs failed]',e);
if(el) el.innerHTML='
報名紀錄暫時載入失敗,請稍後再試。
資料沒有消失,請截圖給官方 LINE。
';
if(helper) helper.classList.add('show');
return [];
}
}
async function safeLoadMemberPage(email, opts={}){
navTo('member', opts.navOptions||{});
email=String(email||ST.memberEmail||memberSafeEmail()||'').trim();
let lookupPhone=String((opts&&opts.phone)||memberSafePhone(email)||(ST.member&&ST.member.phone)||'').trim();
const savedToken=memberSafeToken();
if(savedToken){
memberShowArea('regs');
try{
const restored=await api({action:'memberSession',memberToken:savedToken,_t:Date.now()});
if(restored&&restored.member&&!restored.error){
ST.member={...(restored.member||{})};
ST.memberEmail=String(ST.member.email||'').trim();
ST._verifiedMemberPhone=String(ST.member.phone||'').trim();
memberSaveEmail(ST.memberEmail);
memberSavePhone(ST._verifiedMemberPhone,ST.memberEmail);
if($('loginEmail')) $('loginEmail').value=ST.memberEmail;
if($('memberLookupEmail')) $('memberLookupEmail').value=ST.memberEmail;
if($('memberLookupPhone')) $('memberLookupPhone').value=ST._verifiedMemberPhone;
fillMemberCard(ST.member);
const hasName=!!String(ST.member.name||ST.member.displayName||'').trim();
const hasBrand=!!String(ST.member.brand||ST.member.brand_name||'').trim();
ST._profileIncomplete=!hasName||!hasBrand;
const list=await safeGetMyRegs(ST.memberEmail,ST._verifiedMemberPhone);
if(ST._profileIncomplete && !opts.forceRegs){switchMemberMode('profile');toast('請先補齊會員資料');}
else if(opts.forceRegs) switchMemberMode('regs');
return list;
}
const invalidToken=restored&&/登入|憑證|失效|LINE/.test(String(restored.error||''));
if(invalidToken){
memberClearToken();ST.member=null;memberShowLogin();
const err=$('memberLoginErr');if(err){err.style.display='block';err.textContent='登入已失效,請重新使用 LINE 登入。';}
}else{
const listEl=$('myRegsList');if(listEl) listEl.innerHTML='
登入狀態暫時無法確認,請檢查網路後再試一次。
憑證仍已保留,不需要重新登入。
';
}
return [];
}catch(e){
console.warn('[platform member restore failed]',e);
const listEl=$('myRegsList');if(listEl) listEl.innerHTML='
登入狀態暫時無法確認,請檢查網路後再試一次。
憑證仍已保留,不需要重新登入。
';
return [];
}
}
if(!email){
memberShowLogin();
const el=$('myRegsList'); if(el) el.innerHTML='';
return;
}
if(!/\S+@\S+\.\S+/.test(email)){
toast('請輸入有效 Email');
memberShowLogin();
return;
}
ST.memberEmail=email;
memberSaveEmail(email);
if($('loginEmail')) $('loginEmail').value=email;
if($('memberLookupEmail')) $('memberLookupEmail').value=email;
if($('memberLookupPhone')) $('memberLookupPhone').value=lookupPhone||memberSafePhone(email)||'';
// 記得 Email 但本次瀏覽尚未完成手機驗證時,回到登入框;不可顯示成空白紀錄。
if(!lookupPhone){
memberShowLogin();
const err=$('memberLoginErr');
if(err){
err.style.display='block';
err.textContent='請輸入手機號碼完成身分驗證,即可查看原有報名紀錄。';
}
return [];
}
memberShowArea('regs');
fillMemberCard(ST.member && (ST.member.email||ST.memberEmail) ? ST.member : {email});
// 會員資料與報名紀錄並行讀取,避免頁面切回「我的紀錄」時再次串行等待。
const regsPromise=safeGetMyRegs(email,lookupPhone);
try{
const member=await api({action:'getMember',email,phone:lookupPhone,_t:Date.now()});
if(member && !member.error){
ST.member={...(ST.member||{}),...member,email:member.email||email};
lookupPhone=String(lookupPhone||member.phone||'').trim();
if(lookupPhone) memberSavePhone(lookupPhone,email);
if($('memberLookupPhone')) $('memberLookupPhone').value=lookupPhone;
fillMemberCard(ST.member);
const m = ST.member;
const hasName = !!String(m.name||m.displayName||'').trim();
const hasBrand = !!String(m.brand||m.brand_name||'').trim();
const hasSocial = !!String(m.fb||m.fb_url||m.ig||m.ig_url||m.collabUrl||m.collab_url||m.website||m.web||'').trim();
ST._profileIncomplete = !hasName || !hasBrand;
if(ST._profileIncomplete && !opts.forceRegs){
const missing=[];
if(!hasName) missing.push('姓名');
if(!hasBrand) missing.push('品牌名稱');
switchMemberMode('profile');
toast('請先補齊會員資料:' + missing.join('、'));
}
}else{
if(!ST.member) ST.member={email,phone:lookupPhone};
fillMemberCard(ST.member);
}
}catch(e){
console.warn('[getMember failed]',e);
if(!ST.member) ST.member={email,phone:lookupPhone};
fillMemberCard(ST.member);
toast('會員資料暫時載入失敗,報名紀錄會繼續載入');
}
// 手機為空的情況由 safeGetMyRegs 統一處理(同一提示只留一份)。
const list=await regsPromise;
if(opts.forceRegs) switchMemberMode('regs');
return list;
}
async function memberLogin(){
const btn=$('loginBtn');
const email=String(($('loginEmail')?.value||ST.memberEmail||memberSafeEmail()||'')).trim();
if(btn){btn.disabled=true;btn.textContent='查詢中…';}
try{ await safeLoadMemberPage(email,{navOptions:{noPush:true}}); }
finally{ if(btn){btn.disabled=false;btn.textContent='登入 / 查詢報名';} }
}
async function memberEmailPhoneLogin(){
const btn=$('memberLookupBtn'),err=$('memberLoginErr');
if(err){err.style.display='none';err.textContent='';}
if(btn){btn.disabled=true;btn.textContent='前往 LINE…';}
try{
const u=new URL(API_BASE+'/auth/line/start');
u.searchParams.set('tenant',TENANT_ID);
u.searchParams.set('mode','member');
u.searchParams.set('return_url',location.href);
location.href=u.toString();
}catch(e){
if(err){err.style.display='block';err.textContent=e.message||'LINE 登入啟動失敗';}
if(btn){btn.disabled=false;btn.textContent='使用 LINE 登入';}
}
}
// 查無報名紀錄時的說明:避免使用者以為要重填一次資料而卡在原地
function showNoRecordHelp(email, phone){
const err=$('memberLoginErr');
if(!err) return;
err.style.display='block';
err.innerHTML='查無符合的報名紀錄。
'
+'請確認 Email 與手機和報名當時填寫的一樣('+esc(String(email||''))+'/'+esc(String(phone||''))+')。
'
+'如果您還沒有報名過,請回到「報名」分頁選擇場次報名;這裡只查詢已完成的報名。';
}
function goMemberPage(){
return safeLoadMemberPage(ST.memberEmail||memberSafeEmail());
}
window.safeLoadMemberPage=safeLoadMemberPage;
window.memberLogin=memberLogin;
window.goMemberPage=goMemberPage;
function isMemberPageActive(){
const p=$('pageMember');
return !!(p && p.classList.contains('active'));
}
async function syncMemberRecordsFromDb(reason){
const email=String(ST.memberEmail||memberSafeEmail()||'').trim();
if(!email || !isMemberPageActive()) return [];
console.log('[AA9 sync myRegs]', reason||'manual');
return safeGetMyRegs(email);
}
function scheduleMemberRecordsSync(reason, delay=120){
clearTimeout(ST.__memberSyncTimer);
ST.__memberSyncTimer=setTimeout(()=>syncMemberRecordsFromDb(reason).catch(e=>console.warn('[member sync failed]',e)), delay);
}
window.syncMemberRecordsFromDb=syncMemberRecordsFromDb;
document.addEventListener('visibilitychange',()=>{ if(!document.hidden) scheduleMemberRecordsSync('visibility'); });
window.addEventListener('focus',()=>scheduleMemberRecordsSync('focus',250));
window.addEventListener('pageshow',()=>scheduleMemberRecordsSync('pageshow',250));
// ── 首頁載入 ──
function renderAnnouncements(list){
const el=$('announcementBar');
if(!el) return;
if(!list.length){ el.style.display='none'; return; }
el.style.display='block';
el.innerHTML = list.map(a=>`
`).join('');
}
// 主題底下的開放場次數(主題是系列,不是單一場次,故不顯示日期時間)
function eventSessionCount(eventId){
const all = (ST.allSessionsData||[]).filter(s=>String(s.eventId||'')===String(eventId) && isOpenSession(s));
return all.length;
}
function isOpenSession(s){
const st=String(s?.status||'').trim();
return !st || st==='報名中' || st==='開放中';
}
function mergeSessions(list){
const old=Array.isArray(ST.allSessionsData)?ST.allSessionsData:[];
const combined=[...old, ...(Array.isArray(list)?list:[])].filter(Boolean);
ST.allSessionsData=[...new Map(combined.map(x=>[String(x.id),x])).values()];
ST.sessions=ST.allSessionsData.filter(isOpenSession);
return ST.sessions;
}
function sessionStartMs(s){
if(!s) return Number.POSITIVE_INFINITY;
let rawDate='';
let rawTime='';
const dates=Array.isArray(s.dates)?s.dates:[];
if(dates.length){
const sorted=dates.slice().sort((a,b)=>String(a.date||'').localeCompare(String(b.date||'')));
rawDate=sorted[0]?.date||'';
rawTime=sorted[0]?.start||sorted[0]?.startTime||'';
}
rawDate = rawDate || s.date || s.startDate || s.eventDate || s.sessionDate || '';
rawTime = rawTime || s.startTime || s.time || '';
let date=String(rawDate||'').trim();
if(!date) return Number.POSITIVE_INFINITY;
date=date.replace(/[./]/g,'-');
const now=new Date();
if(/^\d{1,2}-\d{1,2}$/.test(date)) date=now.getFullYear()+'-'+date;
const m=date.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
if(!m) {
const t=Date.parse(date);
return Number.isFinite(t)?t:Number.POSITIVE_INFINITY;
}
const y=m[1], mo=String(m[2]).padStart(2,'0'), da=String(m[3]).padStart(2,'0');
let time=String(rawTime||'').trim();
const tm=time.match(/(\d{1,2}):(\d{2})/);
time=tm?`${String(tm[1]).padStart(2,'0')}:${tm[2]}`:'00:00';
const t=Date.parse(`${y}-${mo}-${da}T${time}:00`);
return Number.isFinite(t)?t:Number.POSITIVE_INFINITY;
}
function sortSessionsByUpcoming(list){
const today=new Date(); today.setHours(0,0,0,0);
const base=today.getTime();
return (Array.isArray(list)?list:[]).slice().sort((a,b)=>{
const am=sessionStartMs(a), bm=sessionStartMs(b);
const af=am>=base?0:1, bf=bm>=base?0:1;
if(af!==bf) return af-bf;
if(af===0) return am-bm;
return bm-am;
});
}
// ── 場次列表/日曆模式(只使用場次正式日期,不自行推算)──
function activityViewState(){
if(!ST.activityViewData) ST.activityViewData={home:[],portal:[]};
if(!ST.activityCalendarMonth) ST.activityCalendarMonth={home:'',portal:''};
if(!ST.activityCalendarSelected) ST.activityCalendarSelected={home:'',portal:''};
return ST.activityViewData;
}
function normalizeActivityDate(value){
const raw=String(value||'').trim().replace(/[./]/g,'-');
const m=raw.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
if(!m) return '';
const mo=String(Number(m[2])).padStart(2,'0');
const da=String(Number(m[3])).padStart(2,'0');
const d=new Date(Number(m[1]),Number(m[2])-1,Number(m[3]));
if(d.getFullYear()!==Number(m[1])||d.getMonth()!==Number(m[2])-1||d.getDate()!==Number(m[3])) return '';
return `${m[1]}-${mo}-${da}`;
}
function sessionActivityDateRows(s){
let rows=Array.isArray(s&&s.dates)?s.dates:null;
if(!rows){
const raw=s&&(s.dates_json!=null?s.dates_json:s.datesJson);
if(Array.isArray(raw)) rows=raw;
else if(typeof raw==='string'&&raw.trim()){
try{ const parsed=JSON.parse(raw); if(Array.isArray(parsed)) rows=parsed; }catch(e){}
}
}
if(!Array.isArray(rows)||!rows.length){
const date=normalizeActivityDate(s&&(s.date||s.startDate||s.eventDate||s.sessionDate));
return date?[{date,time:String((s&&(s.startTime||s.time))||'').trim()}]:[];
}
const found=new Map();
rows.forEach(row=>{
const obj=(row&&typeof row==='object')?row:{date:row};
const date=normalizeActivityDate(obj.date||obj.startDate||obj.day);
if(!date) return;
const time=String(obj.start||obj.startTime||obj.time||s.startTime||s.time||'').trim();
if(!found.has(date)) found.set(date,{date,time});
});
return [...found.values()].sort((a,b)=>a.date.localeCompare(b.date)||a.time.localeCompare(b.time));
}
function activityCalendarEntries(sessions){
const entries=[];
const seen=new Set();
(Array.isArray(sessions)?sessions:[]).forEach(s=>{
sessionActivityDateRows(s).forEach(row=>{
const key=String(s.id||s.name||'')+'|'+row.date;
if(seen.has(key)) return;
seen.add(key);
entries.push({date:row.date,time:row.time||'',session:s});
});
});
return entries.sort((a,b)=>a.date.localeCompare(b.date)||a.time.localeCompare(b.time)||String(a.session.name||'').localeCompare(String(b.session.name||''),'zh-Hant'));
}
function currentLocalDateKey(){
const d=new Date();
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
}
function activityMonthKeyFromDate(date){ return String(date||'').slice(0,7); }
function activityScopeElements(scope){
return scope==='portal'
? {host:$('portalSessionList'),bar:$('portalActivityViewBar')}
: {host:$('eventsList'),bar:$('homeActivityViewBar')};
}
function preferredActivityView(){
try{ return localStorage.getItem('2bl_activity_view')==='calendar'?'calendar':'list'; }catch(e){ return 'list'; }
}
function setActivitySessions(scope,sessions){
const deduped=[...new Map((Array.isArray(sessions)?sessions:[]).filter(Boolean).map((s,i)=>[String(s.id||s.sessionId||s.name||i),s])).values()];
activityViewState()[scope]=sortSessionsByUpcoming(deduped);
renderActivitySessions(scope);
}
function switchActivityView(scope,mode){
const next=mode==='calendar'?'calendar':'list';
try{ localStorage.setItem('2bl_activity_view',next); }catch(e){}
renderActivitySessions(scope,next);
}
function moveActivityCalendarMonth(scope,delta){
activityViewState();
const key=ST.activityCalendarMonth[scope]||activityMonthKeyFromDate(currentLocalDateKey());
const m=key.match(/^(\d{4})-(\d{2})$/);
const d=m?new Date(Number(m[1]),Number(m[2])-1+Number(delta||0),1):new Date();
ST.activityCalendarMonth[scope]=`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`;
ST.activityCalendarSelected[scope]='';
renderActivitySessions(scope,'calendar');
}
function goActivityCalendarToday(scope){
activityViewState();
const today=currentLocalDateKey();
ST.activityCalendarMonth[scope]=activityMonthKeyFromDate(today);
ST.activityCalendarSelected[scope]=today;
renderActivitySessions(scope,'calendar');
}
function selectActivityCalendarDate(scope,date){
activityViewState();
ST.activityCalendarMonth[scope]=activityMonthKeyFromDate(date);
ST.activityCalendarSelected[scope]=date;
renderActivitySessions(scope,'calendar');
}
function activityCalendarEventHtml(entry,detail){
const s=entry.session||{};
const theme=(typeof getFrontSessionThemeInfo==='function'?getFrontSessionThemeInfo(s):{cls:'theme-none'});
const time=String(entry.time||'').match(/\d{1,2}:\d{2}/)?.[0]||'';
if(detail){
const location=s.location||s.venue||s.place||'';
return ``;
}
return ``;
}
function renderActivityCalendar(scope,sessions){
activityViewState();
const entries=activityCalendarEntries(sessions);
const today=currentLocalDateKey();
let monthKey=ST.activityCalendarMonth[scope];
if(!/^\d{4}-\d{2}$/.test(monthKey||'')){
const firstUpcoming=entries.find(x=>x.date>=today)||entries[0];
monthKey=activityMonthKeyFromDate(firstUpcoming?firstUpcoming.date:today);
ST.activityCalendarMonth[scope]=monthKey;
}
const [year,month]=monthKey.split('-').map(Number);
const first=new Date(year,month-1,1);
const start=new Date(year,month-1,1-first.getDay());
const byDate=new Map();
entries.forEach(entry=>{ if(!byDate.has(entry.date)) byDate.set(entry.date,[]); byDate.get(entry.date).push(entry); });
let selected=ST.activityCalendarSelected[scope];
if(!selected||activityMonthKeyFromDate(selected)!==monthKey){
selected=(byDate.has(today)&&activityMonthKeyFromDate(today)===monthKey)?today:((entries.find(x=>activityMonthKeyFromDate(x.date)===monthKey)||{}).date||'');
ST.activityCalendarSelected[scope]=selected;
}
const weekdays=['日','一','二','三','四','五','六'].map(x=>`
週${x}
`).join('');
let days='';
for(let i=0;i<42;i++){
const d=new Date(start.getFullYear(),start.getMonth(),start.getDate()+i);
const key=`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
const dayEntries=byDate.get(key)||[];
const cls=['activity-calendar-day',d.getMonth()!==month-1?'is-other':'',key===today?'is-today':'',key===selected?'is-selected':''].filter(Boolean).join(' ');
days+=`
${d.getDate()}${dayEntries.length?`${dayEntries.length}場`:''}
${dayEntries.map(x=>activityCalendarEventHtml(x,false)).join('')}
`;
}
const selectedEntries=selected?(byDate.get(selected)||[]):[];
const detail=selected?`
${selected.replace(/-/g,'/')} · ${selectedEntries.length}場活動
${selectedEntries.length?selectedEntries.map(x=>activityCalendarEventHtml(x,true)).join(''):'
當天沒有開放報名的活動
'}
`:'';
return `
${weekdays}${days}
${detail}`;
}
function renderActivitySessions(scope,forcedMode){
const data=activityViewState()[scope]||[];
const mode=forcedMode||preferredActivityView();
const els=activityScopeElements(scope);
if(!els.host||!els.bar) return;
els.host.classList.add('activity-list-host');
els.host.classList.toggle('calendar-mode',mode==='calendar');
const calendarHtml=mode==='calendar'?renderActivityCalendar(scope,data):'';
const monthKey=ST.activityCalendarMonth[scope]||activityMonthKeyFromDate(currentLocalDateKey());
const monthText=monthKey.replace('-', '年')+'月';
els.bar.innerHTML=`
${mode==='calendar'?`
${monthText}
`:''}`;
if(mode==='calendar'){
els.host.innerHTML=calendarHtml;
}else{
els.host.innerHTML=data.length?data.map(s=>renderSessionCard(s)).join(''):'
目前尚無開放報名
';
}
}
window.switchActivityView=switchActivityView;
window.moveActivityCalendarMonth=moveActivityCalendarMonth;
window.goActivityCalendarToday=goActivityCalendarToday;
window.selectActivityCalendarDate=selectActivityCalendarDate;
function renderHomeFallbackSessions(){
const el=$('eventsList');
if(!el) return;
const open=sortSessionsByUpcoming((ST.allSessionsData||[]).filter(isOpenSession));
setActivitySessions('home',open);
}
async function filterCat(type, el){
ST.currentCatFilter = type;
document.querySelectorAll('.cat-btn').forEach(b=>b.classList.remove('active'));
if(el) el.classList.add('active');
const title = $('portalTitle');
if(title) title.textContent = type;
const list = $('portalSessionList');
if(list) list.innerHTML = '
載入中…
';
const _catBgMap={'市集報名':'#EEF5F0','體驗活動':'#EEF6FA','通路寄賣':'#F5F4E6','合作洽詢':'#F7F4E8','團購':'#EDF5F3'};
const _catBg=_catBgMap[type]||'';
const _portalPageEl=$('pagePortal');
if(_portalPageEl) _portalPageEl.style.background=_catBg;
document.body.style.background=_catBg||'var(--wh)';
navTo('portal');
window.scrollTo(0,0);
let data=[];
try{
const sessions = await api({action:'getSessions', portal:type, _t:Date.now()});
data = Array.isArray(sessions) ? sessions.filter(isOpenSession) : [];
mergeSessions(data);
}catch(e){ console.error('filterCat api error', e); }
if(!data.length){
data=(ST.allSessionsData||[]).filter(s=>{
const ps=Array.isArray(s.portals)?s.portals:String(s.portals||'').split(',').map(x=>x.trim()).filter(Boolean);
return isOpenSession(s) && ps.includes(type);
});
}
if(!list) return;
data=sortSessionsByUpcoming(data);
setActivitySessions('portal',data);
}
async function openEvent(eventId){
const evt=(ST.events||[]).find(e=>String(e.id)===String(eventId));
ST.currentEvent=evt||null;
const title=$('portalTitle');
if(title) title.textContent=evt?evt.title:'場次列表';
const list=$('portalSessionList');
if(list) list.innerHTML='
載入中…
';
const _evtBgMap={'耶市集':'#E6F1ED','幻日祭':'#EBE2E5'};
const _evtBg=evt?Object.entries(_evtBgMap).find(([k])=>(evt.title||'').includes(k)):null;
const _bg=_evtBg?_evtBg[1]:'';
const _portalPage=$('pagePortal');
if(_portalPage) _portalPage.style.background=_bg;
document.body.style.background=_bg||'var(--wh)';
navTo('portal');
let sessions=[];
try{
const res=await api({action:'getSessions',eventId,_t:Date.now()});
sessions=Array.isArray(res)?res.filter(isOpenSession):[];
mergeSessions(sessions);
}catch(e){ console.error('openEvent error',e); }
if(!sessions.length){
sessions=(ST.allSessionsData||[]).filter(s=>isOpenSession(s) && String(s.eventId||'')===String(eventId));
}
ST.sessions=sortSessionsByUpcoming(sessions);
sessions=ST.sessions;
if(!list) return;
setActivitySessions('portal',sessions);
}
function myRegForSession(s){
if(!ST.memberEmail || !Array.isArray(ST.regs) || !ST.regs.length) return null;
const sid=String(s && s.id || ''); const sname=String(s && s.name || '');
const list=ST.regs.filter(r=>{
const rid=String(r.sessionId || r.session_id || '');
const rname=String(r.sessionName || r.sesName || '');
return (sid && rid===sid) || (sname && rname===sname);
});
if(!list.length) return null;
// 優先顯示仍有效的報名;若全都取消/退費,就顯示最新那筆(讓你知道曾報過、可重報)
const active=list.filter(r=> !(typeof isCancelledReg==='function' && isCancelledReg(r)));
return active.length ? active[active.length-1] : list[list.length-1];
}
function myRegBadge(s){
const r=myRegForSession(s); if(!r) return '';
const p=mReviewPill(r);
const t=String(p.text||'');
let label;
if(t==='已取消') label='已取消·可重報';
else if(t==='未錄取') label='未錄取';
else label='你已報名·'+t;
return `${p.icon?p.icon+' ':''}${esc(label)}`;
}
/* 20260705 前台主題色:同一主題活動固定同色,不用場次名稱判斷 */
function getFrontSessionThemeInfo(s){
try{
const eventId=String((s&&s.eventId) || '').trim();
const evt=(ST.events||[]).find(e=>String(e.id)===eventId);
const rawTitle=String((evt&&(evt.title||evt.name)) || (s&&(s.eventTitle||s.event_title||s.themeTitle||s.theme_title)) || '').trim();
const title=rawTitle || (eventId ? '主題活動' : '無主題');
let cls='theme-none';
if(rawTitle.indexOf('幻日祭')>-1) cls='theme-huanri';
else if(rawTitle.indexOf('耶市集')>-1) cls='theme-yes';
else if(rawTitle.indexOf('兔彼樂的市集小旅行')>-1) cls='theme-travel';
else if(rawTitle.indexOf('翻轉市集')>-1) cls='theme-flip';
else if(!eventId) cls='theme-none';
else cls='theme-none';
return {cls,title};
}catch(_e){
return {cls:'theme-none',title:'無主題'};
}
}
// ── BUNDLE_ACTIVE_GUARD_20260809 ───────────────────────────────
function frontDateKeyTaipei(){
try{return new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Taipei',year:'numeric',month:'2-digit',day:'2-digit'}).format(new Date());}
catch(_e){return new Date().toISOString().slice(0,10);}
}
function frontBundleSessionAvailable(bs){
if(!bs)return false;
if(bs.available===false)return false;
const status=String(bs.status||'').trim();
if(['關閉','已關閉','停用','封存','已封存','取消','已取消'].includes(status))return false;
const ds=Array.isArray(bs.dates)?bs.dates:[];
const keys=ds.map(d=>String((d&&typeof d==='object')?(d.date||d.day||''):d).slice(0,10)).filter(Boolean).sort();
if(keys.length&&keys[keys.length-1]{
const list=Array.isArray(b&&b.sessions)?b.sessions:[];
return list.length>=2 &&
list.some(x=>String((x&&x.id)||'')===sid) &&
list.every(frontBundleSessionAvailable);
});
}
function frontBundleBadgeHtml(s){
try{
const sid=String((s&&s.id)||'').trim();
if(!sid) return '';
const bundles=activeBundlesForSession(s);
let addCount=0;
bundles.forEach(b=>{
const list=Array.isArray(b&&b.sessions)?b.sessions:[];
const hasCurrent=list.some(x=>String((x&&x.id)||'')===sid);
if(!hasCurrent) return;
const otherCount=list.filter(x=>String((x&&x.id)||'')!==sid).length;
if(otherCount>addCount) addCount=otherCount;
});
if(addCount<=0) return '';
return `🎫 可加報 ${addCount} 場|組合優惠`;
}catch(_e){ return ''; }
}
function renderSessionCard(s){
const themeInfo=getFrontSessionThemeInfo(s);
const themeClass=themeInfo.cls||'theme-none';
const full = Number(s.limit)>0 && Number(s.count)>=Number(s.limit);
const scheduleUnavailable = s.registrationOpen===false;
const closed = s.status==='關閉' || s.status==='已關閉' || full || scheduleUnavailable;
const dates=Array.isArray(s.dates)?s.dates:[];
let dateStr='';
if(dates.length>1){
const fmt=d=>d?String(d).slice(5).replace('-','/'):'';
dateStr=fmt(dates[0].date)+'~'+fmt(dates[dates.length-1].date);
}else if(dates.length===1){
const d=dates[0]||{};
dateStr=(d.date||'').slice(5).replace('-','/')+(d.start?' '+d.start:'')+(d.end?'~'+d.end:'');
}else if(s.date){
dateStr=String(s.date).slice(5).replace('-','/');
}
const placeStr=s.venue||s.location||s.place||'';
const eventId=String(s.eventId||'');
const evt=(ST.events||[]).find(e=>String(e.id)===eventId);
const cover=s.cover||evt?.cover||'';
const bundleBadge=frontBundleBadgeHtml(s);
const myBadge=myRegBadge(s);
const coverEl=cover
? `
`
: `
`;
const unavailableLabel=full?'名額已滿':(scheduleUnavailable?({upcoming:'尚未開放',paused:'暫停報名',ended:'報名已截止'}[s.registrationState]||'目前未開放'):'已關閉');
const scheduleNote=scheduleUnavailable&&s.registrationMessage?`⏱ ${esc(s.registrationMessage)}
`:'';
const actionHtml=closed
? `${unavailableLabel}`
: ``;
return `
${coverEl}
${esc(themeInfo.title||'無主題')}
${esc(s.name||'')}
${dateStr?'📅 '+esc(dateStr):''}${placeStr?'
📍 '+esc(placeStr):''}
${scheduleNote}
${myBadge?`
${myBadge}
`:''}
${bundleBadge?`
${bundleBadge}
`:''}
${actionHtml}
`;
}
function renderAllSessions(){
const el=$('allSessionsList');
if(!el) return;
const list=(ST.allSessionsData||[]).filter(isOpenSession);
if(!list.length){ el.innerHTML='目前無開放場次
'; return; }
el.innerHTML=list.map(s=>renderSessionCard(s)).join('');
}
// ── 會員資料查找步驟(報名前先帶入會員資料)──
// 已報過同名場次時,在報名表最上方顯示明顯提醒(不阻擋,但一定看得到)
function showSameNameRegWarning(reg){
try{
const dynEl=$('dynForm'); if(!dynEl) return;
if(document.getElementById('sameNameRegWarn')) return;
const d=document.createElement('div');
d.id='sameNameRegWarn';
d.style.cssText='background:#FAEEC7;border:2px solid #e0c56a;border-radius:14px;padding:14px;margin-bottom:14px;font-size:16px;line-height:1.7;color:#6b4e00';
d.innerHTML='⚠️ 您已經報名過「'+esc(String(reg.sessionName||reg.sesName||''))+'」
'
+'報名編號:'+esc(String(reg.id||''))+' 狀態:'+esc(String(reg.status||''))
+'
若只是要查看或繳費,請到「我的紀錄」,不需要再報一次。'
+'';
dynEl.prepend(d);
try{ d.scrollIntoView({behavior:'smooth',block:'center'}); }catch(e){}
}catch(e){ console.warn('[showSameNameRegWarning]',e); }
}
function showEmailStep(s){
const dynEl=$('dynForm');
const submitBtn=$('submitBtn');
if(submitBtn) submitBtn.style.display='none';
const feeBox=$('feeBox');
if(feeBox) feeBox.style.display='none';
if(!dynEl) return;
dynEl.innerHTML=`
`;
const btn=$('memberLookupContinueBtn');
if(btn) btn.addEventListener('click', checkMemberStep);
['step1Email','step1Phone'].forEach(id=>{
const el=$(id); if(el) el.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); btn&&btn.click(); } });
});
}
// 新會員專用:先把會員資料填完整,再回到報名表繼續選擇內容
// Email 已有會員但手機不一致:直接講清楚怎麼處理,並可當場改手機重試
function showPhoneMismatchStep(email, phone){
ST._phoneMismatchEmail=String(email||'').trim();
const dynEl=$('dynForm'); if(!dynEl) return;
const submitBtn=$('submitBtn'); if(submitBtn) submitBtn.style.display='none';
const feeBox=$('feeBox'); if(feeBox) feeBox.style.display='none';
dynEl.innerHTML=`
手機號碼與資料庫不一致
這個 Email(${esc(email)})已經有會員資料,
但您輸入的手機 ${esc(phone)} 與當初留的號碼不同,因此無法直接報名。
請擇一處理:
1. 改用當初報名時留的手機號碼再試一次
2. 手機號碼換了 → 請聯繫主辦協助更新,之後就能正常報名
`;
try{ dynEl.scrollIntoView({behavior:'smooth',block:'start'}); }catch(e){}
}
function retryPhoneMismatch(){
const v=String(($('pm_phone')||{}).value||'').trim();
const err=$('pm_err');
if(!v){ if(err) err.textContent='請輸入手機號碼'; return; }
const email=String(ST._phoneMismatchEmail||ST.memberEmail||memberSafeEmail()||'').trim();
memberSavePhone(v);
showEmailStep(ST.currentSession);
const pIn=$('step1Phone'), eIn=$('step1Email');
if(pIn) pIn.value=v;
if(eIn) eIn.value=email;
setTimeout(()=>{ const btn=$('memberLookupContinueBtn'); if(btn) btn.click(); else checkMemberStep(); },0);
}
function goSupportFromForm(){
try{ closeRegFormModal({force:true}); }catch(e){}
try{ if(typeof navToTab==='function') navToTab('support'); }catch(e){}
}
function showNewMemberProfileStep(s){
const dynEl=$('dynForm');
const submitBtn=$('submitBtn'); if(submitBtn) submitBtn.style.display='none';
const feeBox=$('feeBox'); if(feeBox) feeBox.style.display='none';
if(!dynEl) return;
const V=ST.member||{};
const email=ST.memberEmail||V.email||'';
dynEl.innerHTML=`
`;
const btn=$('saveMemberProfileBtn');
if(btn) btn.addEventListener('click',()=>saveMemberProfileToDb({silent:false}));
showMemberSocialReviewNoticeOnce();
try{ dynEl.scrollIntoView({behavior:'smooth',block:'start'}); }catch(e){}
}
const DOING_VENDOR_CATEGORIES=new Set(['餐飲美食','手作設計','文創選物','服飾配件','生活用品','親子兒童','寵物相關','收藏娛樂','美類','美業服務','體驗/服務','其他']);
function doingVendorCategory(value){const v=String(value||'').trim();return DOING_VENDOR_CATEGORIES.has(v)?v:'其他';}
function doingProfileMemberPayload(profile){return {...(profile||{}),brand:(profile&&profile.brand)||'',brand_name:(profile&&profile.brand_name)||(profile&&profile.brand)||'',brandIntro:(profile&&profile.brandIntro)||'',sellCat:(profile&&profile.sellCat)||'',sellItem:(profile&&profile.sellItem)||'',fb:(profile&&profile.fb)||'',ig:(profile&&profile.ig)||'',photo:(profile&&profile.photo)||'',company:(profile&&profile.company)||'',taxId:(profile&&profile.taxId)||''};}
async function saveDoingPlatformMemberProfile(values){
const email=String(values.email||ST.memberEmail||memberSafeEmail()||'').trim();
const authPhone=String(ST._verifiedMemberPhone||ST.member?.phone||memberSafePhone(email)||values.phone||'').trim();
if(!email||!authPhone) return {error:'請先完成 LINE 會員登入'};
const payload={
action:'saveMember',email,authPhone,phone:String(values.phone||authPhone).trim(),
name:String(values.name||'').trim(),brand:String(values.brand||'').trim(),brandIntro:String(values.brandIntro||'').trim(),
sellItem:String(values.sellItem||values.sellCat||'').trim(),sellCat:doingVendorCategory(values.category||values.sellItem||values.sellCat||''),
fb:String(values.fb||'').trim(),ig:String(values.ig||'').trim(),collabUrl:String(values.web||values.collabUrl||'').trim(),
photo:String(values.photo||'').trim(),company:String(values.company||'').trim(),taxId:String(values.taxId||'').trim(),lineId:String(values.lineId||'').trim(),city:String(values.city||'').trim()
};
const saved=await apiPost(payload,20000);
if(saved&&saved.error) return saved;
const phone=String(payload.phone||authPhone).trim();
const fresh=await api({action:'getMember',email,phone,_t:Date.now()});
if(fresh&&!fresh.error){
const member=doingProfileMemberPayload(fresh);
const complete=!!String(member.name||'').trim()&&!!String(member.brand||member.brand_name||'').trim();
return {success:true,profileComplete:complete,member};
}
return {success:true,profileComplete:!!(saved&&saved.profileComplete),member:doingProfileMemberPayload((saved&&saved.member)||values)};
}
async function saveMemberProfileToDb({silent=false}={}){
const g=id=>String(($(id)||{}).value||'').trim();
const err=$('np_err');
const status=$('np_save_status');
const btn=$('saveMemberProfileBtn');
const email=g('np_email'), requestedPhone=g('np_phone');
const verifiedPhone=String(ST._verifiedMemberPhone||ST.member?.phone||memberSafePhone()||'').trim();
// 背景儲存不得改登入手機;只有使用者明確儲存並確認後才採用畫面中的新手機。
const phone=(silent&&verifiedPhone)?verifiedPhone:requestedPhone;
if(!silent && verifiedPhone && requestedPhone && requestedPhone!==verifiedPhone){
if(!await uiConfirm('你正在修改登入用手機:\n'+verifiedPhone+' → '+requestedPhone+'\n\n修改後,下次登入要使用新手機。確定要修改嗎?')){if(btn){btn.disabled=false;btn.textContent='儲存資料';}return;}
}
const showError=(msg,focusId)=>{
if(!silent && err) err.textContent=msg;
const el=$(focusId); if(!silent && el){ try{ el.scrollIntoView({behavior:'smooth',block:'center'}); el.focus(); }catch(e){} }
};
if(!email || !/\S+@\S+\.\S+/.test(email)){ showError('請先填寫正確 Email,才能儲存資料','np_email'); return {success:false}; }
if(!phone){ showError('請先填寫手機號碼,才能儲存資料','np_phone'); return {success:false}; }
if(!silent && !g('np_fb')&&!g('np_ig')&&!g('np_web')) await showMemberSocialReviewNoticeOnce();
if(err) err.textContent='';
if(!silent && btn){ btn.disabled=true; btn.textContent='儲存中…'; }
const authPhone=String(ST._verifiedMemberPhone||ST.member?.phone||memberSafePhone()||phone).trim();
try{
const res=await saveDoingPlatformMemberProfile({
email,phone,name:g('np_name'),brand:g('np_brand'),sellItem:g('np_sellItem'),brandIntro:g('np_brandIntro'),
fb:g('np_fb'),ig:g('np_ig'),company:ST.member?.company||'',taxId:ST.member?.taxId||ST.member?.tax_id||'',photo:ST.member?.photo||ST.member?.photo_url||'',lineId:ST.member?.lineId||ST.member?.line_id||'',city:ST.member?.city||''
});
if(!res || res.error){
if(!silent && err) err.textContent=(res&&res.error)||'儲存失敗,請稍後再試';
return {success:false,error:(res&&res.error)||'儲存失敗'};
}
const member=res.member||{};
ST.member={...(ST.member||{}),...member};
ST.memberEmail=String(member.email||email).trim();
ST._verifiedMemberPhone=String(member.phone||phone).trim();
memberSaveEmail(ST.memberEmail); memberSavePhone(ST._verifiedMemberPhone);
if(res.profileComplete){
if(!silent){
if(status){ status.style.display='block'; status.style.background='#DDEAD1'; status.style.color='#2a4d18'; status.textContent='✅ 會員資料已完整儲存,正在進入報名表'; }
toast('會員資料已儲存');
renderDynFormFull(ST.currentSession,false);
setTimeout(()=>{ const d=$('dynForm'); if(d){ try{ d.scrollIntoView({behavior:'smooth',block:'start'}); }catch(e){} } },120);
}
}else if(!silent){
const missing=Array.isArray(res.missingFields)?res.missingFields:[];
if(status){ status.style.display='block'; status.style.background='#FAEEC7'; status.style.color='#6b4e00'; status.textContent='✅ 已儲存到資料庫。尚未完成:'+(missing.join('、')||'請補齊必填資料'); }
toast('資料已儲存,可稍後再回來補填');
}
return {success:true,profileComplete:!!res.profileComplete,missingFields:res.missingFields||[]};
}catch(e){
console.warn('[saveMemberProfileToDb failed]',e);
if(!silent && err) err.textContent='儲存失敗,請檢查網路後再試';
return {success:false,error:e&&e.message?e.message:'儲存失敗'};
}finally{
if(!silent && btn && document.body.contains(btn)){ btn.disabled=false; btn.textContent='儲存'; }
}
}
// 統一處理「下一步」的畫面切換:任何錯誤都要看得到,不能靜靜停在原地
function goRegStep(fn){
try{
if(!ST.currentSession || !ST.currentSession.id){
const st=$('step1Status');
if(st){ st.style.display='block'; st.style.background='#FAE1DD'; st.style.color='#8b1c1c';
st.textContent='場次資料已失效,請關閉本頁後重新點選場次報名。'; }
return;
}
fn();
}catch(e){
console.warn('[goRegStep failed]', e);
const st=$('step1Status');
if(st){ st.style.display='block'; st.style.background='#FAE1DD'; st.style.color='#8b1c1c';
st.textContent='畫面載入失敗:'+((e&&e.message)?e.message:'請重新整理後再試一次'); }
toast&&toast('畫面載入失敗,請重新整理再試');
}
}
async function checkMemberStep(){
const emailEl=$('step1Email');
const phoneEl=$('step1Phone');
const errEl=$('step1Err');
const statusEl=$('step1Status');
const email=(emailEl?.value||'').trim();
const phone=(phoneEl?.value||'').trim();
if(!email || !/\S+@\S+\.\S+/.test(email)){ if(errEl) errEl.textContent='請填寫正確 Email'; return; }
if(!phone){ if(errEl) errEl.textContent='請填寫手機號碼'; return; }
if(errEl) errEl.textContent='';
if(emailEl) emailEl.disabled=true;
if(phoneEl) phoneEl.disabled=true;
const checkBtn=$('memberLookupContinueBtn');
if(checkBtn){ checkBtn.disabled=true; checkBtn.textContent='查詢中…'; }
try{
// 查詢會員資料走 GET;Email 是否存在與手機是否相符走 POST。兩者同時執行,且各自只有一套 handler。
const [member, chk] = await Promise.all([
api({action:'getMember',email,phone,_t:Date.now(),__timeout:12000}),
apiPost({action:'checkMemberEmailPhone',email,phone},12000).catch(()=>null)
]);
if((member && member.error) || (chk && chk.error)){
throw new Error((member&&member.error)||(chk&&chk.error)||'會員查詢失敗');
}
if(chk && chk.exists && !chk.match){ showPhoneMismatchStep(email,phone); return; }
ST.memberEmail=email;
memberSaveEmail(email); memberSavePhone(phone);
ST._verifiedMemberPhone=phone;
if(member && !member.error){
ST.member={...member};
ST.memberEmail=String(member.email||email).trim();
ST._verifiedMemberPhone=String(member.phone||phone).trim();
memberSaveEmail(ST.memberEmail); memberSavePhone(ST._verifiedMemberPhone);
const fromMembers=String(member.source||'')==='members';
if(member.profileComplete && fromMembers){
if(statusEl){ statusEl.style.display='block'; statusEl.style.background='#DDEAD1'; statusEl.style.color='#2a4d18'; statusEl.textContent='✅ 已找到完整會員資料'; }
goRegStep(()=>renderDynFormFull(ST.currentSession,false));
}else{
if(statusEl){ statusEl.style.display='block'; statusEl.style.background='#FAEEC7'; statusEl.style.color='#6b4e00'; statusEl.textContent='會員資料尚未完成,已帶入資料庫內容,請補齊後按「儲存」。'; }
goRegStep(()=>showNewMemberProfileStep(ST.currentSession));
}
}else{
ST.member={email,phone};
if(statusEl){ statusEl.style.display='block'; statusEl.style.background='#DCD7ED'; statusEl.style.color='#4a3c75'; statusEl.textContent='第一次使用,請填寫會員資料。資料未完整也可以先儲存。'; }
goRegStep(()=>showNewMemberProfileStep(ST.currentSession));
}
}catch(e){
console.warn('[checkMemberStep failed]',e);
if(statusEl){ statusEl.style.display='block'; statusEl.style.background='#FAE1DD'; statusEl.style.color='#8b1c1c'; statusEl.textContent='查詢失敗,請稍後再試。'; }
}finally{
if(emailEl) emailEl.disabled=false;
if(phoneEl) phoneEl.disabled=false;
if(checkBtn && document.body.contains(checkBtn)){ checkBtn.disabled=false; checkBtn.textContent='查詢 / 繼續 →'; }
}
}
// 手機切到 FB/IG/LINE 複製網址時,若會員表正在畫面上,背景儲存目前欄位。
// 正式來源仍是資料庫;返回後不以 localStorage 假資料覆蓋。
let _memberProfileBackgroundSaving=false;
async function saveVisibleMemberProfileOnBackground(){
if(_memberProfileBackgroundSaving || !$('np_email') || !$('np_phone')) return;
const email=String(($('np_email')||{}).value||'').trim();
// 背景儲存只能沿用已驗證手機,禁止把尚未正式送出的輸入值變成登入手機。
const phone=String(ST._verifiedMemberPhone||ST.member?.phone||memberSafePhone()||'').trim();
if(!email || !/\S+@\S+\.\S+/.test(email) || !phone) return;
_memberProfileBackgroundSaving=true;
try{ await saveMemberProfileToDb({silent:true}); }catch(e){}
finally{ _memberProfileBackgroundSaving=false; }
}
document.addEventListener('visibilitychange',()=>{ if(document.hidden) saveVisibleMemberProfileOnBackground(); });
window.addEventListener('pagehide',()=>{ saveVisibleMemberProfileOnBackground(); });
window.checkMemberStep=checkMemberStep;
window.saveMemberProfileToDb=saveMemberProfileToDb;
function onBundleAddonChange(cb){ document.querySelectorAll('.bundleAddonChk').forEach(x=>{ if(x!==cb) x.checked=false; }); ST.bundleAddon = cb.checked ? cb.value : ''; if(typeof updateFee==='function') updateFee(); }
function renderDynFormFull(s, isNew=false){
ST.seatChoiceIntent='auto';
const V=ST.member||{};
const email=ST.memberEmail||V.email||'';
const vFb=V.fb||V.fb_url||'';
const vIg=V.ig||V.ig_url||'';
const vWeb=V.website||V.web||'';
const vCarrier=V.invoiceCarrier||V.invoice_carrier||'';
const vTaxId=V.taxId||V.tax_id||'';
const vCompany=V.company||'';
const vInvEmail=V.invoiceEmail||V.invoice_email||'';
const isCompany=!!vTaxId;
ST.equipQty={};ST.addonQty={};ST.stallCount=1;
const cover=s.cover||'';
buildFormHero(s,cover);
// 取得日期字串
const dates=s.dates||[];
let dateStr='';
if(dates.length>1){
const fmt=d=>d?String(d).slice(5).replace('-','/'):'';
dateStr=fmt(dates[0].date)+'~'+fmt(dates[dates.length-1].date);
} else if(dates.length===1){
const d=dates[0];
dateStr=(d.date||'').slice(5).replace('-','/')+(d.start?' '+d.start:'')+(d.end?'~'+d.end:'');
}
const sType=s.type||'市集場次';
const isCollab=sType==='合作申請';
const isMkt=sType==='市集場次'||sType==='通路寄賣';
const equipDefs=Object.entries(s.equip||{}).filter(([k,v])=>v&&(v.open===true||v.open==='true'));
const addons=(s.addons||[]).map((a,i)=>({...a,_idx:i})).filter(a=>a.open===true||a.open==='true');
let html='';
// ── 已報名狀態提示(非同步查詢,不阻塞報名表渲染)──
const _email = getMemberEmail();
if (_email && s && s.id) {
api({ action:'getMyRegs', email:_email, phone:memberSafePhone()||'', _t:Date.now() })
.then(regs => {
const list = Array.isArray(regs) ? regs : [];
const existing = list.find(r => String(r.sessionId||r.session_id||'') === String(s.id));
const banner = document.getElementById('regStatusBanner');
if (!banner) return;
if (!existing) { banner.style.display='none'; return; }
const status = existing.status || existing.reviewStatus || '';
const payStatus = existing.payStatus || existing.payment_status || '';
const transfer = existing.transferStatus || existing.transfer_status || '';
let msg='', bg='', icon='';
if (transfer==='已退費'||transfer==='已退款') {
msg=`你曾報名此場次,但已退費,可重新報名。`; bg='#FAEEC7'; icon='⚠️';
} else if (status==='已取消'||status==='不錄取') {
msg=`你曾報名此場次(${status}),可重新報名。`; bg='#FAEEC7'; icon='⚠️';
} else if (status==='已錄取' && (payStatus==='已繳費'||payStatus==='免費')) {
msg=`✅ 你已完成報名並繳費,請等待通知。`; bg='#DDEAD1'; icon='';
} else if (status==='已錄取') {
msg=`✅ 你已報名此場次,目前狀態:${status}(${payStatus})。`; bg='#DDEAD1'; icon='';
} else if (status==='待審核') {
msg=`🕐 你已報名此場次,目前狀態:待審核,請等待主辦審核。`; bg='#D9E5EE'; icon='';
} else {
msg=`你已報名此場次,目前狀態:${status}。`; bg='#DCD7ED'; icon='';
}
banner.style.cssText=`display:block;background:${bg};border-radius:12px;padding:14px 16px;margin:0 16px 12px;font-size:16px;font-weight:700;color:#2C2C2C;line-height:1.6`;
banner.textContent=msg;
})
.catch(()=>{}); // 查詢失敗不影響報名表
}
// ── ① 活動資訊 ──
html+=``;
// ── 多日選擇(如有)──
if(dates.length>1){
html+=``;
}
// ── ② 設備確認 ──
if(!isCollab){
html+=``;
// 加價選位告知(正式規則:報名階段只告知,不選位、不加價、不卡位)
try{
const _myB=activeBundlesForSession(s);
if(_myB.length){
ST.bundleAddon='';
html+=``;
}
}catch(e){}
const seatEnabled = !!(s.seatPricingEnabled || s.seat_pricing_enabled || (s.modules&&s.modules.seatPricing));
if(seatEnabled){
ST.seatChoiceIntent = 'auto';
html+=``;
}
// ── ③ 總計費用 ──
html+=`
3總計費用
攤位小計NT$0
設備小計NT$0
加購小計NT$0
保證金NT$${s.deposit||0}
總計費用
NT$${Number(s.fee)||0}
`;
}
const cardNum=isCollab?2:4;
// ── ④ 會員資料 ──
const socialHtml=(vFb||vIg||vWeb)?`
${vFb?`
f`:''}
${vIg?`
📷`:''}
${vWeb?`
🌐`:''}
`:'';
if(isNew){
html+=``;
} else {
html+=``;
html+=``;
html+=``;
html+=``;
html+=``;
html+=``;
html+=``;
html+=``;
html+=``;
}
html+=``;
// ── ⑤ 發票資訊 ──
const invTypeStr=isCompany?'公司戶(三聯式)':'個人(二聯式)';
html+=``;
// 發票隱藏欄位
html+=``;
html+=``;
html+=``;
html+=``;
html+=``;
// ── 自訂欄位(如有)──
const customFields=(s.customFields||s.modules?.customFields||[]).filter(f=>f&&f.label);
if(customFields.length){
customFields.forEach((f,i)=>{
html+=``;
});
}
// ── ⑥ 合約 ──
html+=``;
// ── ⑦ 備註留言 ──
html+=``;
const dynEl=$('dynForm');
// banner 放在 dynForm 前面(form-hero 和 dynForm 之間)
let bannerEl=document.getElementById('regStatusBanner');
if(!bannerEl){
bannerEl=document.createElement('div');
bannerEl.id='regStatusBanner';
bannerEl.style.display='none';
const dynElParent=dynEl?dynEl.parentNode:null;
if(dynElParent) dynElParent.insertBefore(bannerEl, dynEl);
}
if(dynEl) dynEl.innerHTML=html;
syncMemberIntoCurrentRegForm(ST.member||{});
// 表單欄位變動時自動儲存(刷新還原用)
setTimeout(()=>{
const saveFields = ['f_brand','f_name','f_phone','f_sellItem','f_brandIntro','f_note',
'f_fb','f_ig','f_web','f_taxId','f_company','f_invoiceCarrier','f_invoiceEmail'];
saveFields.forEach(id=>{
const el=$(id);
if(!el) return;
el.addEventListener('input', ()=>{
try{
const existing = JSON.parse(localStorage.getItem('doing_market_page_state')||'{}');
if(existing.tab==='regForm'){
if(!existing.formFields) existing.formFields={};
existing.formFields[id] = el.value;
localStorage.setItem('doing_market_page_state', JSON.stringify(existing));
}
}catch(e){}
});
});
}, 200);
// 合約只保留 dynForm 內的 contractCard,不再顯示第二個合約按鈕。
// 送出按鈕初始 disabled;正式啟用交給 initAgreementArea / onAgreementRead 控制
const subBtn=$('submitBtn');
if(subBtn){
subBtn.style.display='block';
subBtn.disabled=true;
subBtn.classList.add('submit-btn-new');
subBtn.textContent='送出報名';
}
// 阻斷性修正:每次重繪報名表後,都必須重新初始化合約狀態。
// 避免「合約讀完但送出仍 disabled」或沿用上一場次合約狀態。
initAgreementArea(s);
updateFee();
setTimeout(()=>{ preFillMemberFields(); }, 100);
}
// 直接輸入攤數
// 直接輸入設備數
// 直接輸入加購數
// 更新設備列小計
// 發票修改彈窗
function openInvoiceEditModal(){
// 使用現有的 member edit modal,切換到發票頁籤
openMemberEditModal();
setTimeout(()=>{
const tabs=document.querySelectorAll('[data-edit-tab]');
tabs.forEach(t=>{ if(t.dataset.editTab==='invoice') t.click(); });
},200);
}
function adjStall(delta){
const s=ST.currentSession||{};
const sType=s.type||'市集場次';
const isMkt=sType==='市集場次'||sType==='通路寄賣';
// 上限從後台場次設定讀取,不硬編碼
const maxN=Number(s.maxStalls)>0?Number(s.maxStalls):999;
const oldCount=Math.max(Number(ST.stallCount)||1,1);
const nextCount=Math.max(1,Math.min(maxN,oldCount+delta));
if(nextCount===oldCount) return;
// B-04:ST.equipQty 是「總量」。改攤數時若只重算內含量,攤友原本額外加租的數量會被吃掉
// (1攤含1桌+加租1桌=2;改成2攤時應為3桌,不是2桌)。先記下加租量,換算完再加回去。
const extras={};
Object.entries(s.equip||{}).forEach(([k,def])=>{
if(!def || def.open===false || def.open==='false') return;
const oldBase=frontEquipFreeTotal(k,def,s,oldCount);
const oldSelected=Number((ST.equipQty||{})[k]||0);
extras[k]=Math.max(0, oldSelected-oldBase);
});
ST.stallCount=nextCount;
if(!ST.equipQty) ST.equipQty={};
Object.entries(s.equip||{}).forEach(([k,def])=>{
if(!def || def.open===false || def.open==='false') return;
ST.equipQty[k]=frontEquipFreeTotal(k,def,s,nextCount)+(extras[k]||0);
});
// 數字欄位只放純數字,不接「攤/份」單位(單位已寫在標題「報名數量」旁)。
const el=$('stallDisplay');
if(el) el.textContent=String(ST.stallCount);
ensureIncludedEquipQty();
updateFee();
}
function buildFormHero(s, cover){
const dateStr=(s.dates&&s.dates.length)?s.dates.map(d=>d.date+(d.start?' '+d.start:'')+(d.end?'–'+d.end:'')).join(' / '):(s.date||'');
const meta=[dateStr,s.venue].filter(Boolean).join(' ');
if(cover){
$('formHero').innerHTML=`
`;
} else {
$('formHero').innerHTML=`🎪
`;
}
}
// ── 報名表 Modal 開關 ──
function resetRegFormState(options={}){
const keepContent=!!options.keepContent;
const dynEl=$('dynForm');
if(dynEl){
dynEl.style.display='block';
if(!keepContent) dynEl.innerHTML='';
}
// 外層 feeBox 只保留給舊流程,A/B 卡片內的 feeBox 由 renderDynFormFull 自己建立
const outerFeeBox=document.querySelector('#regFormModal .form-wrap > #feeBox');
if(outerFeeBox) outerFeeBox.style.display='none';
const submitBtn=$('submitBtn');
if(submitBtn){
submitBtn.disabled=false;
submitBtn.textContent='送出報名';
submitBtn.style.opacity='1';
submitBtn.style.display='none';
}
// 重置合約同意狀態
ST._agreementRequired = true;
ST._agreementViewed = false;
ST._agreementAccepted = false;
ST._agreementData = null;
const badge = $('contractDoneBadge');
if (badge) badge.style.display = 'none';
const modalBox=document.querySelector('#regFormModal .modal-box');
if(modalBox){ modalBox.querySelectorAll('.success-screen').forEach(el=>el.remove()); }
const formErr=$('formError');
if(formErr){ formErr.style.display='none'; formErr.innerHTML=''; }
ST.__submitting=false;
}
function openRegFormModal(){
resetRegFormState({keepContent:false});
if(ST.currentSession){
if(ST.member && ST.memberEmail){
renderDynFormFull(ST.currentSession, false);
} else {
showEmailStep(ST.currentSession);
}
}
// 記住開啟報名表前所在的列表頁,送出成功後回到場次列表,不再停留報名表。
const activePageBeforeReg = document.querySelector('.page.active');
ST._regFormReturnState = {
pageId: activePageBeforeReg ? activePageBeforeReg.id : '',
portalTitle: ($('portalTitle')?.textContent || '').trim(),
catFilter: ST.currentCatFilter || '',
eventId: ST.currentEvent ? (ST.currentEvent.id || '') : (ST.currentSession ? (ST.currentSession.eventId || '') : '')
};
// 切換到報名表頁面
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
const page=$('regFormModal');
if(page) page.classList.add('active');
window.scrollTo(0,0);
history.pushState({regFormOpen:true}, '', location.href);
// 儲存報名表狀態(刷新還原用)
try{
const state = {
tab: 'regForm',
sessionId: ST.currentSession ? ST.currentSession.id : null,
stallCount: ST.stallCount || 1,
equipQty: ST.equipQty || {},
addonQty: ST.addonQty || {},
};
localStorage.setItem('doing_market_current_tab', 'regForm');
localStorage.setItem('doing_market_page_state', JSON.stringify(state));
}catch(e){}
// 底部無 active
['botHome','botMember','botSupport'].forEach(id=>{
const el=document.getElementById(id);
if(el) el.classList.remove('active');
});
}
function setEditValue(id, value){
const el=$(id);
if(el) el.value = value == null ? '' : String(value);
}
async function openMemberEditModal(forceMsg){
const email = String(ST.memberEmail || memberSafeEmail() || '').trim();
if(!email){ toast('請先登入會員'); return; }
const modal=$('memberEditModal');
if(!modal){ toast('找不到會員編輯視窗'); return; }
modal.classList.add('show');
const btn=$('saveMemberBtn');
if(btn){ btn.disabled=false; btn.textContent='儲存資料'; }
const msg=$('saveSuccessMsg'); if(msg) msg.style.display='none';
// 強制填寫提示
const hintEl=$('memberEditHint');
const closeBtn=modal.querySelector('button[onclick*="closeMemberEditModal"]')||modal.querySelector('button[onclick*="closeModal(\'memberEditModal\')"]');
if(forceMsg){
if(hintEl){ hintEl.textContent=forceMsg; hintEl.style.display='block'; }
if(closeBtn) closeBtn.style.display='none';
// 攔截點擊背景關閉
modal._forceOpen=true;
modal.onclick=function(e){ if(e.target===modal) toast('請先填寫完整資料並儲存'); };
} else {
if(hintEl){ hintEl.style.display='none'; hintEl.textContent=''; }
if(closeBtn) closeBtn.style.display='';
modal._forceOpen=false;
modal.onclick=function(e){ if(e.target===modal) closeMemberEditModal(); };
}
let m = ST.member || {email};
try{
const fresh = await api({action:'getMember', email, phone:getMemberPhone(), _t:Date.now()});
if(fresh && !fresh.error){
m = {...m, ...fresh, email:fresh.email||email};
ST.member = m;
fillMemberCard(m);
}
}catch(e){ console.warn('[openMemberEditModal getMember failed]', e); }
setEditValue('editName', m.name || '');
setEditValue('editPhone', m.phone || '');
setEditValue('editBrand', m.brand || m.brandName || m.brand_name || '');
setEditValue('editBrandIntro', m.brandIntro || m.brand_intro || '');
setEditValue('editFb', m.fb || m.fb_url || '');
setEditValue('editIg', m.ig || m.ig_url || '');
setEditValue('editCollabUrl', m.collabUrl || m.collab_url || '');
setEditValue('editPhoto', m.photo || m.photo_url || '');
setEditValue('editLineId', m.lineId || m.line_id || '');
setEditValue('editInvoiceCarrier', m.invoiceCarrier || m.invoice_carrier || '');
setEditValue('editTaxId', m.taxId || m.tax_id || '');
setEditValue('editCompany', m.company || m.invoiceTitle || m.invoice_title || '');
setEditValue('editInvoiceEmail', m.invoiceEmail || m.invoice_email || email);
}
function closeMemberEditModal(){
const modal=$('memberEditModal');
if(modal) modal.classList.remove('show');
}
async function saveMemberEdit(){
const btn=$('saveMemberBtn');
if(btn){ btn.disabled=true; btn.textContent='儲存中…'; }
const restore=()=>{ if(btn){ btn.disabled=false; btn.textContent='儲存資料'; } };
const email = String(ST.memberEmail||memberSafeEmail()||'').trim();
if(!email){ toast('請先登入'); restore(); return; }
const requestedPhone=String(($('editPhone')?.value||'').trim());
const verifiedPhone=String(ST._verifiedMemberPhone||ST.member?.phone||memberSafePhone()||'').trim();
if(verifiedPhone && requestedPhone && requestedPhone!==verifiedPhone){if(!await uiConfirm('你正在修改登入用手機:\n'+verifiedPhone+' → '+requestedPhone+'\n\n修改後,下次登入要使用新手機。確定要修改嗎?')){restore();return;}}
const body = {
email,
name: ($('editName')?.value||'').trim(),
phone: ($('editPhone')?.value||'').trim(),
brand: ($('editBrand')?.value||'').trim(),
brandIntro: ($('editBrandIntro')?.value||'').trim(),
fb: ($('editFb')?.value||'').trim(),
ig: ($('editIg')?.value||'').trim(),
collabUrl: ($('editCollabUrl')?.value||'').trim(),
photo: ($('editPhoto')?.value||'').trim(),
lineId: ($('editLineId')?.value||'').trim(),
invoiceCarrier:($('editInvoiceCarrier')?.value||'').trim(),
taxId: ($('editTaxId')?.value||'').trim(),
company: ($('editCompany')?.value||'').trim(),
invoiceEmail:($('editInvoiceEmail')?.value||'').trim(),
sellItem: ST.member?.sellItem||ST.member?.sell_items||ST.member?.sellCat||ST.member?.sell_category||''
};
if(!body.fb&&!body.ig&&!body.collabUrl){
toast('請填寫 FB、IG 或官網其中一項');
const first=$('editFb'); if(first){try{first.scrollIntoView({behavior:'smooth',block:'center'});first.focus();}catch(e){}}
restore(); return;
}
try{
const res=await saveDoingPlatformMemberProfile(body);
if(!res || res.error){ toast('❌ 儲存失敗:'+(res?.error||'請稍後再試')); restore(); return; }
const savedPhone = body.phone || getMemberPhone();
memberSavePhone(savedPhone,email);
ST._verifiedMemberPhone=savedPhone;
ST.member={...(ST.member||{}),...(res.member||body),email:(res.member&&res.member.email)||email,phone:(res.member&&res.member.phone)||savedPhone,collabUrl:body.collabUrl,invoiceCarrier:body.invoiceCarrier,invoiceEmail:body.invoiceEmail};
fillMemberCard(ST.member);
syncMemberIntoCurrentRegForm(ST.member);
const msg=$('saveSuccessMsg');
if(msg){ msg.style.display='block'; setTimeout(()=>msg.style.display='none',1800); }
toast('✅ 會員資料已更新');
// 重新檢查完整性
const m2 = ST.member;
const hasName2 = !!String(m2.name||m2.displayName||'').trim();
const hasBrand2 = !!String(m2.brand||m2.brand_name||'').trim();
const hasSocial2 = !!String(m2.fb||m2.fb_url||m2.ig||m2.ig_url||m2.collabUrl||m2.collab_url||m2.website||m2.web||'').trim();
if(!hasName2 || !hasBrand2 || !hasSocial2){
const missing2=[];
if(!hasName2) missing2.push('姓名');
if(!hasBrand2) missing2.push('品牌名稱');
if(!hasSocial2) missing2.push('FB、IG 或官網(至少一項)');
openMemberEditModal('仍缺少以下資料:' + missing2.join('、'));
} else {
ST._profileIncomplete = false;
setTimeout(()=>closeMemberEditModal(), 500);
}
}catch(e){
console.warn('[saveMemberEdit failed]',e);
toast('❌ 網路錯誤,請稍後再試');
}finally{
restore();
}
}
function regFormHasContent(){
try{
const ids=['f_name','f_email','f_phone','f_brand','f_brandIntro','f_taxId','f_invoiceTitle'];
return ids.some(function(id){ const el=$(id); return el && String(el.value||'').trim().length>0; });
}catch(e){ return false; }
}
function closeRegFormModal(opts){
const force=!!(opts&&opts.force);
// 防呆:返回鍵/關閉時,若已填但尚未送出,先確認,避免白填又誤以為已報名
if(!force && !ST.__submitting && regFormHasContent()){
return false; // 交由呼叫端(attemptCloseRegForm / popstate)用自製確認框詢問
}
const page=$('regFormModal');
if(page) page.classList.remove('active');
ST.currentSession=null;
resetRegFormState({keepContent:false});
// 回到上一頁(首頁或場次列表)
const lastTab=localStorage.getItem('doing_market_current_tab')||'home';
if(lastTab==='support'){
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
const pg=$('pageSupport'); if(pg) pg.classList.add('active');
const btn=$('botSupport'); if(btn) btn.classList.add('active');
} else if(lastTab==='member'){
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
const pg=$('pageMember'); if(pg) pg.classList.add('active');
const btn=$('botMember'); if(btn) btn.classList.add('active');
} else {
navTo('home');
const btn=$('botHome'); if(btn){ ['botHome','botMember','botSupport'].forEach(id=>{const e=$(''+id);if(e)e.classList.remove('active');}); btn.classList.add('active'); }
}
window.scrollTo(0,0);
return true;
}
function showRegisterSuccess(res, ses, email){
try{
const regNo = String((res&&(res.regId||res.registrationId||res.id))||'').trim();
const sesName = String((ses&&(ses.name||ses.title))||'').trim();
let d=document.getElementById('regSuccessModal');
if(d) d.remove();
d=document.createElement('div');
d.id='regSuccessModal';
d.style.cssText='position:fixed;inset:0;z-index:9200;background:rgba(20,20,20,.55);display:flex;align-items:center;justify-content:center;padding:16px';
d.innerHTML=''
+'
✅
'
+'
報名已送出成功
'
+(sesName?'
'+esc(sesName)+'
':'')
+(regNo?'
報名編號:'+esc(regNo)+'
':'')
+'
'
+'接下來:
1. 主辦會進行審核,結果將寄到您的 Email
2. 錄取後會收到繳費通知
3. 隨時可到「我的紀錄」查看進度'
+'
'
+(email?'
通知信會寄到:'+esc(email)+'
':'')
+'
'
+'
'
+'
';
document.body.appendChild(d);
document.body.style.overflow='hidden';
}catch(e){ console.warn('[showRegisterSuccess]',e); }
}
function closeRegSuccessAndGoRecords(){
const m=document.getElementById('regSuccessModal'); if(m) m.remove();
document.body.style.overflow='';
try{ if(typeof navToTab==='function') navToTab('member'); }catch(e){}
}
function closeRegFormToSessionListAfterSubmit(sessionSnapshot, email){
hideSubmitOverlay();
const s = sessionSnapshot || ST.currentSession || {};
const returnState = ST._regFormReturnState || {};
const page = $('regFormModal');
if(page){
page.classList.remove('active');
page.classList.remove('show');
}
resetRegFormState({keepContent:false});
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
const portal = $('pagePortal');
if(portal) portal.classList.add('active');
const title = $('portalTitle');
const fallbackTitle = returnState.portalTitle || returnState.catFilter || ST.currentCatFilter || '場次列表';
if(title) title.textContent = fallbackTitle && fallbackTitle !== '報名表' ? fallbackTitle : '場次列表';
const listEl = $('portalSessionList');
const allRaw = [ ...(Array.isArray(ST.sessions) ? ST.sessions : []), ...(Array.isArray(ST.allSessionsData) ? ST.allSessionsData : []) ];
const seen = new Set();
const all = allRaw.filter(item=>{
if(!item || !item.id) return false;
const key = String(item.id);
if(seen.has(key)) return false;
seen.add(key);
return typeof isOpenSession === 'function' ? isOpenSession(item) : true;
});
const eventId = String(s.eventId || returnState.eventId || '');
let sessions = [];
if(eventId){
sessions = all.filter(item=>String(item.eventId || '') === eventId);
}
if(!sessions.length && (returnState.catFilter || ST.currentCatFilter)){
const cat = returnState.catFilter || ST.currentCatFilter;
const typeMap = {'市集報名':['市集場次'],'體驗活動':['活動報名','體驗活動'],'通路寄賣':['通路寄賣'],'合作洽詢':['合作申請']};
const allow = typeMap[cat] || [];
sessions = all.filter(item=>{
const portals = Array.isArray(item.portals) ? item.portals : String(item.portals || '').split(',').map(x=>x.trim()).filter(Boolean);
return portals.includes(cat) || allow.includes(item.type);
});
}
if(!sessions.length){
sessions = (Array.isArray(ST.sessions) && ST.sessions.length ? ST.sessions : all).filter(item=>typeof isOpenSession === 'function' ? isOpenSession(item) : true);
}
if(typeof sortSessionsByUpcoming === 'function') sessions = sortSessionsByUpcoming(sessions);
if(listEl) setActivitySessions('portal',sessions);
ST.currentSession = null;
ST._regFormReturnState = null;
try{
localStorage.setItem('doing_market_current_tab', 'portal');
localStorage.setItem('doing_market_page_state', JSON.stringify({tab:'portal', catFilter:returnState.catFilter || ST.currentCatFilter || '', eventId:eventId || ''}));
}catch(e){}
['botHome','botMember','botSupport'].forEach(id=>{ const el=$(id); if(el) el.classList.remove('active'); });
try{ history.replaceState({page:'portal', afterRegister:true}, '', location.href); }catch(e){}
window.scrollTo(0,0);
toast('✅ 報名已送出');
}
// 返回鍵攔截:如果報名 modal 是開著的,關掉它;否則讓瀏覽器正常返回
window.addEventListener('popstate', function(e){
// Layer 3:報名詳情 Modal
const regDM=document.getElementById('regDetailModal');
if(regDM){ regDM.remove(); return; }
// Layer 2:報名表 Modal 開著 → 關閉 Modal
const modal=$('regFormModal');
if(modal && (modal.classList.contains('show')||modal.classList.contains('active'))){
const closed=closeRegFormModal();
if(closed===false){
try{ history.pushState({page:'regForm'},'',location.href); }catch(e){}
uiConfirm('報名尚未送出,確定要離開嗎?\n離開後已填寫的資料會清除。').then(function(ok){ if(ok) closeRegFormModal({force:true}); });
}
return;
}
// Layer 1:在非首頁 → 回首頁(不讓 browser 離開 app)
const isHome=$('pageHome')?.classList.contains('active');
if(!isHome){
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
$('pageHome').classList.add('active');
const pp=$('pagePortal'); if(pp) pp.style.background='';
document.body.style.background='var(--wh)';
window.scrollTo(0,0);
// 重新 push home state 避免再按返回直接離開
history.pushState({page:'home'}, '', location.href);
return;
}
// 已在首頁 → 讓 browser 正常處理(回上一頁或離開)
});
// 分享當前場次
function shareCurrentSession(){
const s=ST.currentSession;
if(!s) return;
openSharePanel('session', s.id, s.name||'報名表');
}
async function openSession(sessionId){
ST.currentBundle=null; ST.bundleAddon='';
if(!Array.isArray(ST.bundles)||!ST.bundles.length){ try{ const _bl=await api({action:'getBundlesPublic'}); ST.bundles=Array.isArray(_bl)?_bl:[]; }catch(_e){} }
// 點場次必須立即有反應:先用列表快取打開報名表,再背景抓最新資料。
const cached = (ST.allSessionsData||[]).find(x=>String(x.id)===String(sessionId))
|| (ST.sessions||[]).find(x=>String(x.id)===String(sessionId));
const openWithSession = async (s, fromFresh=false)=>{
if(!s || s.error){ toast('場次資料讀取失敗,請重試'); return; }
ST.currentSession=s;
ST.equipQty={};
ST.addonQty={};
ST.stallCount=1;
const evt=(ST.events||[]).find(e=>String(e.id)===String(s.eventId));
const cover=s.cover||evt?.cover||'';
buildFormHero(s,cover);
const titleEl=$('formBackTitle');
if(titleEl) titleEl.textContent=s.name||'報名表';
const modal=$('regFormModal');
if(!modal || !modal.classList.contains('show')) openRegFormModal();
if(s.registrationOpen===false){ showRegistrationUnavailable(s); return; }
if(ST.member && ST.memberEmail){
try{
const _ph=String((ST.member&&ST.member.phone)||memberSafePhone()||'').trim();
const myRegs=await api({action:'getMyRegs',email:ST.memberEmail,phone:_ph,_t:Date.now()});
const list=Array.isArray(myRegs)?myRegs:[];
ST.regs=list;
const alive=rg=>String(rg.status||'')!=='不錄取'&&String(rg.status||'')!=='已取消'&&String(rg.transferStatus||'')!=='已退費';
const dup=list.find(rg=>String(rg.sessionId)===String(s.id)&&alive(rg));
if(dup){ showAlreadyRegistered(s,dup); }
else {
const sameName=list.find(rg=>alive(rg)&&String(rg.sessionName||rg.sesName||'').trim()===String(s.name||'').trim());
renderDynFormFull(s, false);
if(sameName) showSameNameRegWarning(sameName);
}
}catch(e){
renderDynFormFull(s, false);
}
} else {
showEmailStep(s);
}
updateFee();
};
if(cached){
await openWithSession(cached, false);
// 背景更新最新場次資料,不讓使用者覺得點了沒反應。
api({action:'getSession',id:sessionId,_t:Date.now()}).then(fresh=>{
if(!fresh || fresh.error) return;
ST.allSessionsData=[...new Map([...(ST.allSessionsData||[]), fresh].map(x=>[x.id,x])).values()];
if(ST.currentSession && String(ST.currentSession.id)===String(sessionId)){
ST.currentSession=fresh;
// 只在表單還沒進入填寫成功畫面時刷新內容。
const success=document.querySelector('#regFormModal .success-screen');
if(!success){
buildFormHero(fresh, fresh.cover||((ST.events||[]).find(e=>String(e.id)===String(fresh.eventId))||{}).cover||'');
if(fresh.registrationOpen===false) showRegistrationUnavailable(fresh);
else {
if(ST.member && ST.memberEmail) renderDynFormFull(fresh, false);
else showEmailStep(fresh);
updateFee();
}
}
}
}).catch(()=>{});
return;
}
// 沒快取時,顯示明確載入畫面,不要無感等待。
const formHero=$('formHero');
if(formHero) formHero.innerHTML='🎪
';
const titleEl=$('formBackTitle');
if(titleEl) titleEl.textContent='報名表';
ST.currentSession=null;
openRegFormModal();
const dyn=$('dynForm');
if(dyn) dyn.innerHTML='正在讀取場次資料…
';
const s = await api({action:'getSession',id:sessionId,_t:Date.now()});
await openWithSession(s, true);
}
function showRegistrationUnavailable(s){
const dynEl=$('dynForm');
const submitBtn=$('submitBtn');if(submitBtn)submitBtn.style.display='none';
const feeBox=$('feeBox');if(feeBox)feeBox.style.display='none';
if(!dynEl)return;
const title={upcoming:'報名尚未開放',paused:'目前暫停報名',ended:'本場報名已截止',manual_closed:'本場報名已關閉'}[s.registrationState]||'目前未開放報名';
dynEl.innerHTML=`⏱ ${esc(title)}
${esc(s.registrationMessage||title)}
開放時間由主辦依活動排程設定;時間到會自動開放,不需要重新取得連結。
`;
}
function showAlreadyRegistered(s,reg){
const dynEl=$('dynForm');
const submitBtn=$('submitBtn');
if(submitBtn) submitBtn.style.display='none';
const feeBox=$('feeBox'); if(feeBox) feeBox.style.display='none';
if(!dynEl) return;
const statusColor={待審核:'#856404',已錄取:'#155724',不錄取:'#721c24',已取消:'#aaa'}[reg.status]||'#555';
dynEl.innerHTML=`
✋ 您已報名此場次
場次:${esc(s.name||'')}
狀態
${reg.status||'待審核'}
如需修改品牌介紹等資料,請至「我的紀錄」查看。
如需取消,請聯繫主辦單位。
`;
}
function getParticipantCountValue(id, fallback){
const el=$(id);
const n=parseInt(el?.value ?? fallback ?? 0,10);
return Number.isFinite(n) && n>0 ? n : 0;
}
function collectParticipantsForSubmit(showFormError, restoreBtn){
const hasAdult=!!$('f_adult');
const hasChild=!!$('f_child');
if(!hasAdult && !hasChild) return {adultCount:0, childCount:0, childAges:[], totalCount:0};
const adultCount=hasAdult ? getParticipantCountValue('f_adult',1) : 0;
const childCount=hasChild ? getParticipantCountValue('f_child',0) : 0;
const childAges=[];
for(let i=0;i18){
const msg=`請填寫小孩 ${i+1} 的年齡`;
toast(msg);
if(showFormError) showFormError(msg);
$('f_child_age_'+i)?.focus();
if(restoreBtn) restoreBtn();
return null;
}
childAges.push(ageNum);
}
if((adultCount+childCount)<=0){
const msg='請至少填寫 1 位參加者';
toast(msg);
if(showFormError) showFormError(msg);
if(restoreBtn) restoreBtn();
return null;
}
return {adultCount, childCount, childAges, totalCount:adultCount+childCount};
}
// [adjStall 統一由上方新版定義]
function adjAddon(i,delta){
if(!ST.addonQty)ST.addonQty={};
const cur=ST.addonQty[i]||0;
const next=Math.max(0,cur+delta);
ST.addonQty[i]=next;
const el=document.getElementById('addon_num_'+i);
if(el)el.textContent=next;
updateFee();
}
// B-04:內含總量 = 每攤內含數 × 攤位數。內含量的唯一計算入口,
// 其他地方一律呼叫本函式,不可自己再寫一次(過去 adjEquip / updateFee 各算各的,才會少乘攤數)。
function frontEquipFreeTotal(k, def, session, stallCount){
const stalls=Math.max(Number(stallCount ?? ST.stallCount ?? 1)||1, 1);
const inclPerStall=Math.max(0, frontEffectiveEquipIncl(k, def, session?.basicEquip||session?.basic_equip||''));
return inclPerStall*stalls;
}
function ensureIncludedEquipQty(){
const s=ST.currentSession||{};
if(!ST.equipQty) ST.equipQty={};
Object.entries(s.equip||{}).forEach(([k,v])=>{
if(!v || v.open===false || v.open==='false') return;
const free=frontEquipFreeTotal(k,v,s);
const cur=Number(ST.equipQty[k]||0);
if(cur < free) ST.equipQty[k]=free;
const elNum=document.getElementById('eq_num_'+k);
if(elNum) elNum.textContent=ST.equipQty[k]||0;
const elInput=document.getElementById('eq_'+k);
if(elInput) elInput.value=ST.equipQty[k]||0;
});
}
function frontEffectiveEquipIncl(name, def, basicEquipText){
// A→Z 阻斷修正:後台設備設定只要有 incl,就代表該設備「本次內含」;
// 前台顯示「本次含 N」時,不可因 basicEquip 文字沒有寫到該品項而又把內含數量算錢。
// 若未來某場次不含,後台應把該設備 incl 設為 0,而不是依文字猜。
const n=Number(def&&def.incl)||0;
return n>0 ? n : 0;
}
function adjEquip(k,delta){
const cur=ST.equipQty[k]||0;
const s=ST.currentSession;
const equipDef=s?.equip?.[k]||{};
const stalls=ST.stallCount||1;
// 每攤上限(0=不限)
const maxPerStall=equipDef.maxPerStall||0;
const maxTotal=maxPerStall>0?maxPerStall*stalls:50;
const next=Math.max(0,Math.min(maxTotal,cur+delta));
if(next===cur && delta>0 && maxPerStall>0){
toast('每攤最多加購 '+maxPerStall+' 件,超出請租多攤!');
return;
}
ST.equipQty[k]=next;
// 相容 input(id=eq_${k})和 span(id=eq_num_${k})兩種格式
const elInput=document.getElementById('eq_'+k);
if(elInput) elInput.value=next;
const elNum=document.getElementById('eq_num_'+k);
if(elNum) elNum.textContent=next;
// 動態更新說明:已含幾件、額外幾件
const _ed=(s?.equip||{})[k]||{};
const inclFree=frontEquipFreeTotal(k,_ed,s,stalls); // 內含總量=每攤內含 × 攤位數
const extra=Math.max(0,next-inclFree);
const price=Number(_ed.price)||0;
const hintId='eq_hint_'+k.replace(/[^a-zA-Z0-9]/g,'_');
const hintEl=document.getElementById(hintId);
if(hintEl){
if(inclFree>0){
if(next<=inclFree) hintEl.textContent=`✓ 目前含 ${inclFree} 件免費,已含足夠`;
else hintEl.textContent=`✓ 含 ${inclFree} 件免費 + 額外 ${extra} 件 = NT$${extra*price}`;
}
}
updateFee();
}
function updateFee(){
const s=ST.currentSession; if(!s)return;
ensureIncludedEquipQty();
const stalls=ST.stallCount||1;
const dep=(s.deposit||0); // 保證金固定,不乘攤數
// 同步儲存最新狀態(刷新還原用)
try{
const existing = JSON.parse(localStorage.getItem('doing_market_page_state')||'{}');
if(existing.tab==='regForm'){
existing.stallCount = ST.stallCount||1;
existing.equipQty = {...(ST.equipQty||{})};
existing.addonQty = {...(ST.addonQty||{})};
localStorage.setItem('doing_market_page_state', JSON.stringify(existing));
}
}catch(e){}
// 計算日期費用
let fee=0;
const dates=s.dates||[];
if(dates.length>1){
// 多日場次:依勾選日期計算
const selectedDates=dates.filter((_,i)=>document.getElementById('dp_'+i)?.checked);
const tiers=Array.isArray(s.multiDayTiers)?s.multiDayTiers:(Array.isArray(dates[0]&&dates[0].multiDayTiers)?dates[0].multiDayTiers:[]),dayCount=selectedDates.length;
const tier=tiers.filter(t=>t&&t.enabled!==false&&Number(t.minDays)>0&&dayCount>=Number(t.minDays)&&(Number(t.maxDays)<=0||dayCount<=Number(t.maxDays))).sort((a,b)=>Number(b.minDays)-Number(a.minDays))[0];
const allSelected=selectedDates.length===dates.length;
if(tier){
fee=Number(tier.dailyFee||0)*dayCount*stalls;
const disc=document.getElementById('fullPkgHint');if(disc){disc.style.display='block';disc.textContent=`🎉 已套用 ${dayCount} 天優惠:每攤每日 NT$${Number(tier.dailyFee||0).toLocaleString()}`;}
} else if(allSelected && Number(s.fee)>0){
fee=Number(s.fee)*stalls;
// 顯示全報優惠提示
const disc=document.getElementById('fullPkgHint');
if(disc){disc.style.display='block';disc.textContent=`🎉 全程參加優惠 NT$${Number(s.fee).toLocaleString()}/攤`;}
} else {
fee=selectedDates.reduce((sum,d)=>sum+(Number(d.fee)||0),0)*stalls;
const disc=document.getElementById('fullPkgHint');
if(disc) disc.style.display='none';
}
} else if(dates.length===1){
// 單日場次:用 dates[0].fee,沒有才用 s.fee(與 worker calcFee 一致)
fee=(Number(dates[0].fee)>0 ? Number(dates[0].fee) : Number(s.fee)||0)*stalls;
} else {
fee=(Number(s.fee)||0)*stalls;
}
let equipTotal=0;
Object.entries(s.equip||{}).forEach(([k,v])=>{
if(v&&v.open){
const inclTotal=frontEquipFreeTotal(k,v,s,stalls); // 內含總量=每攤內含 × 攤位數
const want=ST.equipQty[k]||0;
const extra=Math.max(0, want-inclTotal); // 只算超過內含總量的部分
equipTotal+=(v.price||0)*extra;
}
});
let addonTotal=0;
(s.addons||[]).forEach((a,i)=>{
if(a && a.open===true) addonTotal+=(Number(a.price)||0)*(ST.addonQty&&ST.addonQty[i]||0);
});
let _bfee=fee, _bdep=dep;
if(ST.bundleAddon){ const _bd=(ST.bundles||[]).find(b=>String(b.id)===String(ST.bundleAddon)); if(_bd){ const _n=(_bd.sessions||[]).length||1; _bfee=Number(_bd.bundlePrice)||0; _bdep=(Number(s.deposit)||0)*_n; } }
const total=_bfee+_bdep+equipTotal+addonTotal;
// 只顯示有值的費用行
// 費用行顯隱(直接用 id)
if($('feeBase')) $('feeBase').textContent='NT$'+_bfee;
const fbRow=$('feeBaseRow'); if(fbRow) fbRow.style.display=_bfee>0?'':'none';
if($('feeEquip')) $('feeEquip').textContent='NT$'+equipTotal;
const feRow=$('feeEquipRow'); if(feRow) feRow.style.display=equipTotal>0?'':'none';
if($('feeAddon')) $('feeAddon').textContent='NT$'+addonTotal;
const faRow=$('feeAddonRow'); if(faRow) faRow.style.display=addonTotal>0?'':'none';
if($('feeDeposit')) $('feeDeposit').textContent='NT$'+_bdep;
const fdRow=$('feeDepRow'); if(fdRow) fdRow.style.display=_bdep>0?'':'none';
if($('feeTotal')) $('feeTotal').textContent='NT$'+total;
// feeBox 在卡片內時永遠顯示(不需要切換)
const feeBoxEl=$('feeBox');
if(feeBoxEl && !feeBoxEl.closest('#dynForm')) feeBoxEl.style.display=total>0?'block':'none';
// 更新設備各列小計(相容 qty-row 與 table 兩種UI格式)
const _s=ST.currentSession||{};
// 攤位小計
const stEl=$('stallTotal');
if(stEl){
const isQtyRow=stEl.classList.contains('qty-total');
stEl.textContent=isQtyRow?'(NT$'+_bfee+')':'NT$'+_bfee;
}
// 設備各列小計
Object.entries(_s.equip||{}).forEach(([k,v])=>{
if(!v||!v.open) return;
const _stalls=ST.stallCount||1;
const _inclTotal=frontEquipFreeTotal(k,v,_s,_stalls);
const _want=ST.equipQty[k]||0;
const _extra=Math.max(0,_want-_inclTotal);
const _sub=(Number(v.price)||0)*_extra;
const _el=$('eqTotal_'+k);
if(_el){
const _isQty=_el.classList.contains('qty-total');
_el.textContent=_isQty?'(NT$'+_sub+')':'NT$'+_sub;
}
});
// 加購各列小計
(_s.addons||[]).forEach((a,i)=>{
if(!a||!(a.open===true||a.open==='true')) return;
const _sub2=(Number(a.price)||0)*(ST.addonQty&&ST.addonQty[i]||0);
const _el2=$('addonTotal_'+i);
if(_el2){
const _isQty2=_el2.classList.contains('qty-total');
_el2.textContent=_isQty2?'(NT$'+_sub2+')':'NT$'+_sub2;
}
});
}
// ── 送出報名 ──
// ── 合約同意機制 ─────────────────────────────────────────────
// 在 renderDynFormFull 完成後由外部呼叫,初始化合約區
async function initAgreementArea(session) {
// 合約 UI 只保留 dynForm 內的 contractCard;此函式保留為相容入口,不再產生第二個合約區。
const submitBtn = $('submitBtn');
const badge = $('contractDoneBadge');
const setUnlocked = () => {
ST._agreementRequired = false;
ST._agreementViewed = true;
ST._agreementAccepted = true;
if (badge) badge.style.display = 'none';
if (submitBtn) { submitBtn.style.display = 'block'; submitBtn.disabled = false; submitBtn.textContent = '送出報名'; submitBtn.style.opacity = '1'; }
};
if (!agreementRequiredOn(session && session.agreementRequired)) {
setUnlocked();
return;
}
ST._agreementRequired = true;
ST._agreementViewed = false;
ST._agreementAccepted = false;
ST._agreementData = {
title: (session && session.agreementTitle) || '報名合約',
content: (session && session.agreementContent) || '',
version: (session && session.agreementVersion) || ''
};
if (submitBtn) { submitBtn.style.display = 'block'; submitBtn.disabled = true; submitBtn.textContent = '送出報名'; }
if (badge) badge.style.display = 'none';
if (session && session.id) {
try {
const ag = await api({ action:'getSessionAgreement', id: session.id });
if (ag && !ag.error) {
if (!agreementRequiredOn(ag.agreementRequired)) {
setUnlocked();
return;
}
ST._agreementData = ag;
const titleEl = $('agreementModalTitle');
if (titleEl) titleEl.textContent = '📋 ' + (ag.title || '報名合約');
}
} catch(e) { console.warn('[initAgreementArea] 合約載入失敗', e); }
}
}
function openAgreementModal() {
const modal = $('agreementModal');
if (!modal) return;
const body = $('agreementModalBody');
if (body) {
const ag = ST._agreementData;
if (ag && ag.content) {
// 原文完整顯示,不改寫、不摘要
body.textContent = ag.content;
} else {
body.innerHTML = '合約內容載入中…
';
// 嘗試即時取得
const ses = ST.currentSession;
if (ses && ses.id) {
api({ action:'getSessionAgreement', id: ses.id }).then(ag => {
if (ag && ag.content) {
ST._agreementData = ag;
body.textContent = ag.content;
const titleEl = $('agreementModalTitle');
if (titleEl) titleEl.textContent = '📋 ' + (ag.title || '報名合約');
} else {
body.innerHTML = '無法載入合約內容,請聯繫主辦。
';
}
}).catch(() => {
body.innerHTML = '合約載入失敗,請重試。
';
});
}
}
}
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
// 標記已查看。正式同意由 modal 內「我同意合約,關閉」完成。
ST._agreementViewed = true;
}
function closeAgreementModal() {
const modal = $('agreementModal');
if (modal) modal.style.display = 'none';
document.body.style.overflow = '';
}
function onAgreementRead() {
const body = $('agreementModalBody');
const txt = (body && body.textContent || '').trim();
// 阻斷性防呆:合約仍在載入或載入失敗時,不可直接放行送出。
if (ST._agreementRequired !== false) {
if (!txt || txt.includes('載入中') || txt.includes('無法載入') || txt.includes('合約載入失敗')) {
toast('合約內容尚未成功載入,請稍後再試或聯繫主辦。', 5000);
return;
}
}
closeAgreementModal();
ST._agreementViewed = true;
ST._agreementAccepted = true;
const badge = $('contractDoneBadge');
if (badge) badge.style.display = 'inline-block';
const card = $('contractCard');
if (card) {
card.style.background = '#e8f5ec';
card.style.border = '2px solid #9fb59c';
}
const submitBtn = $('submitBtn');
if (submitBtn) {
submitBtn.style.display = 'block';
submitBtn.disabled = false;
submitBtn.textContent = '送出報名';
submitBtn.style.opacity = '1';
}
}
function showSubmitOverlay(msg){
let ov=document.getElementById('submitOverlay');
if(!ov){
ov=document.createElement('div');
ov.id='submitOverlay';
ov.style.cssText='position:fixed;inset:0;background:rgba(255,255,255,.88);z-index:999999;display:flex;align-items:center;justify-content:center;padding:24px;text-align:center';
ov.innerHTML='';
document.body.appendChild(ov);
}
const t=document.getElementById('submitOverlayText');
if(t) t.textContent=msg||'報名送出中…';
ov.style.display='flex';
}
function hideSubmitOverlay(){ const ov=document.getElementById('submitOverlay'); if(ov) ov.style.display='none'; }
async function submitReg(ev){
if(ev && ev.preventDefault) ev.preventDefault();
const btn=$('submitBtn');
if(ST.__submitting){ toast('報名送出中,請稍候…'); return false; }
const showFormError=(msg)=>{const el=$('formError');if(el){el.style.display='block';el.innerHTML='❌ 錯誤:'+msg+'
(截圖此錯誤訊息,方便排查)'; try{el.scrollIntoView({behavior:'smooth',block:'center'});}catch(e){}}};
const hideFormError=()=>{const el=$('formError');if(el)el.style.display='none';};
const restoreBtn=()=>{ ST.__submitting=false; hideSubmitOverlay(); if(btn){ btn.disabled=false; btn.textContent='送出報名'; btn.style.opacity='1'; } };
// 按下按鈕立刻給回饋,確認 onclick 有觸發
if(btn){ btn.textContent='確認中…'; btn.style.opacity='.7'; }
try{
const s=ST.currentSession;
if(!s || !s.id){
showFormError('找不到場次資料,請關閉後重新點選場次');
toast('❌ 找不到場次資料');
restoreBtn(); return false;
}
if(s.registrationOpen===false){
showFormError(s.registrationMessage||'本場目前未開放報名');
toast('⏱ '+(s.registrationMessage||'本場目前未開放報名'),6000);
restoreBtn(); return false;
}
const email=($('f_email')?.value||ST.memberEmail||'').trim();
const name=($('f_name')?.value||'').trim();
const phone=($('f_phone')?.value||'').trim();
let ok=true;
if(!name){ const e=$('e_name'); if(e)e.textContent='請填寫姓名'; ok=false; } else { const e=$('e_name'); if(e)e.textContent=''; }
if(!phone){ const e=$('e_phone'); if(e)e.textContent='請填寫手機'; ok=false; } else { const e=$('e_phone'); if(e)e.textContent=''; }
if(!email||!/\S+@\S+\.\S+/.test(email)){ const e=$('e_email'); if(e)e.textContent='請填寫正確 Email'; ok=false; } else { const e=$('e_email'); if(e)e.textContent=''; }
// 會員主資料必填:報名前必須完成會員資料,才可送出本次報名。
const brandVal=($('f_brand')?.value||'').trim();
if(!brandVal){ toast('請填寫攤位/品牌名稱'); showFormError('請填寫攤位/品牌名稱'); $('f_brand')?.focus(); restoreBtn(); return false; }
if(!ok){ toast('請確認必填欄位'); showFormError('請確認上方必填欄位(姓名 / 手機 / Email)'); restoreBtn(); return false; }
let selectedDates=[];
if(Array.isArray(s.dates) && s.dates.length>1){
selectedDates=s.dates.filter((_,i)=>document.getElementById('dp_'+i)?.checked).map(d=>d.date).filter(Boolean);
if(!selectedDates.length){ toast('請至少選擇一個參加日期'); showFormError('請至少勾選一個參加日期'); restoreBtn(); return false; }
}else if(Array.isArray(s.dates) && s.dates.length===1 && s.dates[0]?.date){
selectedDates=[s.dates[0].date];
}
const invoiceType=document.querySelector('input[name="f_invoiceType"]:checked')?.value||'個人';
if(invoiceType==='公司/機關'){
const tax=($('f_taxId')?.value||'').trim();
const title=(($('f_invoiceTitle')?.value||'')||($('f_company')?.value||'')).trim();
if(!/^\d{8}$/.test(tax)){ toast('請填寫正確 8 碼統一編號'); showFormError('請填寫正確 8 碼統一編號'); restoreBtn(); return false; }
if(!title){ toast('請填寫公司抬頭'); showFormError('請填寫公司抬頭'); restoreBtn(); return false; }
}
const participants = collectParticipantsForSubmit(showFormError, restoreBtn);
if(!participants) return false;
// ── 合約同意最終前端防線(後端亦會驗證)──
if (ST._agreementRequired !== false) {
if (!ST._agreementViewed) {
toast('請先點開並閱讀報名合約,勾選同意後才能送出報名。', 6000);
showFormError('請先點開並閱讀報名合約,勾選同意後才能送出報名。');
restoreBtn(); return false;
}
if (!ST._agreementAccepted) {
toast('請勾選同意報名合約後才能送出報名。', 6000);
showFormError('請勾選同意報名合約後才能送出報名。');
restoreBtn(); return false;
}
}
ST.__submitting=true;
if(btn){ btn.disabled=true; btn.textContent='送出中…'; }
showSubmitOverlay('報名送出中…');
const M=ST.member||{};
const body={
action:'register',sessionId:s.id,email,name,phone,stallCount:ST.stallCount||1,invoiceType,
invoiceCarrier:($('f_invoiceCarrier')?.value||'').trim(),taxId:($('f_taxId')?.value||'').trim(),invoiceTitle:(($('f_invoiceTitle')?.value||'')||($('f_company')?.value||'')).trim(),invoiceEmail:(($('f_invoiceEmail')?.value||'').trim()||($('f_personalInvoiceEmail')?.value||'').trim()||email),selectedDates,
brand:($('f_brand')?.value||M.brand||M.brand_name||'').trim(),sellCat:(M.sellCat||M.sell_category||'').trim(),brandIntro:($('f_brandIntro')?.value||M.brandIntro||M.brand_intro||'').trim(),sellItem:($('f_sellItem')?.value||'').trim(),sellLink:M.sellLink||M.sell_link||'',photo:M.photo||M.photo_url||'',equip:{...(ST.equipQty||{})},addonQty:{...(ST.addonQty||{})},fb:($('f_fb')?.value||M.fb||M.fb_url||'').trim(),ig:($('f_ig')?.value||M.ig||M.ig_url||'').trim(),collabUrl:(M.collabUrl||M.collab_url||M.website||M.web||'').trim(),remark:(($('f_note')?.value||'')||($('f_remark')?.value||'')).trim(),stallNumber:'',seatChoiceIntent:(ST.seatChoiceIntent==='paid'?'paid':'auto'),participantsJson:participants,adultCount:participants.adultCount,childCount:participants.childCount,childAges:participants.childAges,customFields:(s.customFields||[]).map((cf,i)=>({label:cf.label,value:document.getElementById('cf_'+i)?.value||''})),
// ── 合約同意(後端必須收到這兩個欄位才建立報名)──
agreementViewed: ST._agreementViewed !== false,
agreementAccepted: ST._agreementAccepted !== false,
agreementTitle: ST._agreementData?.title || '',
agreementVersion: ST._agreementData?.version || '',
};
if(ST.bundleAddon){ body.action='registerBundle'; body.bundleId=ST.bundleAddon; }
// 送出前最後防線:同一場次已有有效報名就擋下,避免卡住兩個名額
try{
const _ph2=String(phone||(ST.member&&ST.member.phone)||memberSafePhone()||'').trim();
if(_ph2){
const nowRegs=await api({action:'getMyRegs',email,phone:_ph2,_t:Date.now()});
if(Array.isArray(nowRegs)){
const exist=nowRegs.find(rg=>String(rg.sessionId)===String(s.id)
&&String(rg.status||'')!=='不錄取'&&String(rg.status||'')!=='已取消'&&String(rg.transferStatus||'')!=='已退費');
if(exist){
showFormError('您已經報名過這個場次(報名編號 '+String(exist.id||'')+'),請到「我的紀錄」查看或繳費,不需要重複報名。');
toast('您已報名過此場次',6000);
restoreBtn();
return false;
}
}
}
}catch(_dupErr){ console.warn('[dup precheck skipped]', _dupErr); }
let res=await apiPost(body);
if(!res){ toast('❌ 後端沒有回傳資料',8000); showFormError('後端沒有回傳資料,請稍後到我的紀錄確認是否已建立報名。'); restoreBtn(); return false; }
if(res.error && isAmbiguousRegisterTransportError(res.error)){
const committed=await recoverCommittedRegistration(s.id,email,phone);
if(committed){
res={success:true,ok:true,id:committed.id||committed.regId||'',status:committed.status||committed.reviewStatus||'待審核',total:committed.totalAmount||committed.amount||0,recovered:true};
}
}
if(res.error){ toast('❌ '+res.error, 8000); showFormError(res.error); restoreBtn(); return false; }
hideFormError();
ST.memberEmail=email;
ST.currentBundle=null;
try{ localStorage.setItem('doing_market_member_email',email||''); }catch(e){}
// 手機正式來源為 members;成功報名後只在本次瀏覽階段保留已驗證身分。
if(phone) memberSavePhone(phone,email);
scheduleMemberRecordsSync('after-register',500);
// 更新本地 member 資料(包含發票資訊,下次報名自動帶入)
const _brand=($('f_brand')?.value||'').trim();
const _taxId=($('f_taxId')?.value||'').trim();
const _company=(($('f_invoiceTitle')?.value||'')||($('f_company')?.value||'')).trim();
const _carrier=($('f_invoiceCarrier')?.value||'').trim();
const _invEmail=($('f_invoiceEmail')?.value||$('f_personalInvoiceEmail')?.value||'').trim();
if(_brand||_taxId||_company){
ST.member={...(ST.member||{}),brand:_brand||ST.member?.brand,sellCat:(ST.member?.sellCat||ST.member?.sell_category||''),company:_company||ST.member?.company,taxId:_taxId||ST.member?.taxId,invoiceCarrier:_carrier||ST.member?.invoiceCarrier,invoiceEmail:_invEmail||ST.member?.invoiceEmail};
try{ localStorage.setItem('doing_market_member_email',email||''); }catch(e){}
}
// 會員資料改成背景更新,不讓 getMember 卡住成功畫面
setTimeout(()=>{
api({action:'getMember',email,phone}).then(updatedMember=>{
if(updatedMember&&!updatedMember.error){
ST.member=updatedMember;
try{localStorage.setItem('doing_market_member_email',email||'');}catch(e){}
}
}).catch(e=>console.warn('[background getMember failed]', e));
},0);
const sessionSnapshot = {...s};
if(btn){ btn.disabled=true; btn.textContent='報名成功'; btn.style.display='none'; }
closeRegFormToSessionListAfterSubmit(sessionSnapshot, email);
try{ if(typeof navToTab==='function') navToTab('member'); }catch(_e){}
showRegisterSuccess(res, sessionSnapshot, email);
ST.__submitting=false;
scheduleMemberRecordsSync('after-register-success',150);
return false;
}catch(e){
console.error('[submitReg fatal]', e);
const errMsg='報名送出失敗:'+(e && e.message ? e.message : String(e));
showFormError(errMsg);
toast('❌ '+errMsg, 8000);
restoreBtn(); return false;
}
}
// ── 會員登出 ──
function memberLogout(){
// 清除所有前台會員 token(報名者登出)
try {
localStorage.removeItem('doing_market_member_token');
sessionStorage.removeItem('doing_line_pending');
localStorage.removeItem('2bl_display_name');
localStorage.removeItem('2bl_display_name_pending');
localStorage.removeItem('tb_member');
localStorage.removeItem('doing_market_member_email');
localStorage.removeItem('doing_market_member_email_v2');
} catch(e){}
memberClearToken();
ST.memberEmail = '';
ST.memberToken = '';
ST.member = null;
// 回到登入畫面
memberShowLogin();
// 更新底部標籤
const lbl = $('botMemberLabel');
if(lbl) lbl.textContent = '📋 我的紀錄';
// 更新 active
['botHome','botMember','botSupport'].forEach(id=>{
const el=document.getElementById(id);
if(el) el.classList.remove('active');
});
const home=document.getElementById('botHome');
if(home) home.classList.add('active');
toast('已登出');
}
function isPaidText(v){ const s=String(v||'').trim(); return ['已繳費','已付款','付款完成','付款成功','paid','confirmed_paid','payment_confirmed'].includes(s) || s.indexOf('已繳費')>=0 || s.indexOf('已付款')>=0 || s==='免費'; }
function isPendingPayText(v){ const s=String(v||'').trim(); return ['待確認','付款待確認','已回報'].includes(s); }
function isCancelledReg(reg){ const st=String(reg?.status||reg?.reviewStatus||'').trim(); const ps=String(reg?.payStatus||'').trim(); return st==='已取消'||ps==='已取消'; }
// 組合套組(bundle_group_id 相同)為綁定優惠:必須「一起繳費」,不可單獨繳其中一場。
// 否則等於用組合價只買單場(與退費同進退規則一致)。
// 組合卡標題:取各場共同的前綴(例如「幻日祭8月美島 特別場」「幻日祭8月高火北」→「幻日祭8月」)
function bundleTitleOf(legs){
const names=(legs||[]).map(x=>String(x.sessionName||x.sesName||'').trim()).filter(Boolean);
if(!names.length) return '組合報名';
let pre=names[0];
for(const n of names.slice(1)){
let i=0;
while(i=2 ? pre+' 組合' : '組合報名';
}
// 組合卡內容:逐場列出日期/地點/設備,並顯示合計金額
function bundleLegsHtml(legs, totalAll){
const rows=(legs||[]).map(x=>{
const nm=escHtml(x.sessionName||x.sesName||'場次');
const date=escHtml(mShortDateRange(mRecordDateText(x))||'—');
const loc=escHtml(mRecordLocationText(x)||'—');
const equip=escHtml(mPreNoticeEquipDisplayText(x));
const stalls=escHtml(mRecordStallText(x));
return `
${nm}
📅 ${date}|${stalls}
◎ ${loc}
設備:${equip}
`;
}).join('');
// 應收(total) 是主辦拆帳看的;已繳(paid) 是實際收到的錢;待補 = 應收 − 已繳。
// 只有「已單場報過、後來併入套組」的人才會出現落差,一般報名 paid=0 或已全繳,不顯示這兩行。
const paidAll=legs.reduce((n,x)=>n+Number(x.paid||0),0);
const dueAll=legs.reduce((n,x)=>n+Number(x.due||0),0);
const extra = paidAll>0 && dueAll>0
? `已繳:NT$ ${paidAll.toLocaleString()} 待補繳:NT$ ${dueAll.toLocaleString()}
`
: '';
return `
${rows}
繳費(含押金):NT$ ${Number(totalAll||0).toLocaleString()}(組合合計)
${extra}
`;
}
function bundleGroupOf(reg){ return String(reg && reg.bundleGroupId || '').trim(); }
function sameBundleRegs(reg){
const g = bundleGroupOf(reg);
if(!g) return [reg];
return (ST.regs||[]).filter(r=>bundleGroupOf(r)===g);
}
function bundleUnpaidRegs(reg){ return sameBundleRegs(reg).filter(r=>canPayReg(r)); }
function regOutstandingAmount(reg){
if(!reg) return 0;
const total=Number(reg.total||reg.totalAmount||reg.amount||0)||0;
const paid=Number(reg.paid||reg.paidAmount||0)||0;
return Number.isFinite(Number(reg.due)) ? Math.max(0,Number(reg.due)) : Math.max(0,total-paid);
}
function canPayReg(reg){
if(!reg || isCancelledReg(reg) || String(reg.status||'')!=='已錄取') return false;
if(isPendingPayText(reg.payStatus)) return false;
if(['申請退費','退費中','已退費','refund_pending','refunded'].includes(String(reg.transferStatus||''))) return false;
const due=regOutstandingAmount(reg);
if(due<=0) return false;
if(String(reg.seatChoiceIntent||'')==='paid' && !['reserved','locked'].includes(String(reg.seatChoiceStatus||''))) return false;
return true;
}
function isRefundingReg(reg){
return !!reg && ['申請退費','退費中','refund_pending'].includes(String(reg.transferStatus||''));
}
function canRefundReg(reg){
if(!reg || isCancelledReg(reg)) return false;
const paidMoney=Number(reg.paid||0)>0 || (isPaidText(reg.payStatus) && String(reg.payStatus||'')!=='免費');
if(!paidMoney) return false;
if(isRefundingReg(reg)) return false;
if(['已退費','refunded'].includes(String(reg.transferStatus||''))) return false;
return true;
}
function canDirectCancelReg(reg){
// 未曾實收才可直接取消;只要已有實收,就必須走退款流程,避免「補選位後待補款」誤出現取消按鈕。
if(!reg || isCancelledReg(reg)) return false;
const paidMoney=Number(reg.paid||0)>0 || (isPaidText(reg.payStatus) && String(reg.payStatus||'')!=='免費');
if(paidMoney) return false;
const st=String(reg.status||'');
if(['不錄取','已取消','已退費'].includes(st)) return false;
const ts=String(reg.transferStatus||'');
if(['申請退費','退費中','已退費'].includes(ts)) return false;
return true;
}
function isExpiredIso(v){ return !!v && Date.parse(v) <= Date.now(); }
function canSelectStallReg(reg){
if(!reg || isCancelledReg(reg)) return false;
if(String(reg.status||'')!=='已錄取') return false;
if(!(reg.seatPricingEnabled===true || reg.seatPricingEnabled==='true')) return false;
if(String(reg.seatChoiceIntent||'auto')!=='paid') return false;
// 付款待確認時禁止變更;已繳費但尚未選位者可選,選位後只補繳加價差額。
if(isPendingPayText(reg.payStatus) || String(reg.payStatus||'')==='免費') return false;
if(reg.stallNumber) return false;
if(['申請退費','退費中','已退費','refund_pending','refunded'].includes(String(reg.transferStatus||''))) return false;
if(String(reg.seatChoiceStatus||'')==='released') return false;
if(reg.seatSelectionDeadline && isExpiredIso(reg.seatSelectionDeadline)) return false;
return true;
}
function stallSelectionHtml(reg){
if(!reg) return '';
const transferStatus=String(reg.transferStatus||'');
const reviewStatus=String(reg.status||'');
// 已取消/退費流程/未錄取/已結案都不再顯示任何選位或自動排位提示。
if(isCancelledReg(reg)
|| ['申請退費','退費中','已退費','refund_pending','refunded'].includes(transferStatus)
|| ['不錄取','已取消','已退費'].includes(reviewStatus)
|| reviewStatus!=='已錄取'
|| !(reg.seatPricingEnabled===true || reg.seatPricingEnabled==='true')) return '';
const deadline = reg.seatHoldExpiresAt || reg.seatSelectionDeadline || '';
const holdHours=Math.max(1,Number(reg.seatHoldHours||24)||24);
const deadlineText = deadline ? new Date(deadline).toLocaleString('zh-TW',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}) : `選位完成後 ${holdHours} 小時內`;
const paid=Number(reg.paid||0)||0;
const due=Number.isFinite(Number(reg.due))?Math.max(0,Number(reg.due)):Math.max(0,(Number(reg.total||0)||0)-paid);
if(reg.stallNumber && String(reg.seatChoiceType||'')==='paid'){
const locked=String(reg.seatChoiceStatus||'')==='locked';
const line=locked ? '付款已完成,位置已正式鎖定。' : (paid>0 ? `請於 ${escHtml(deadlineText)} 前補繳加價差額 NT$ ${due.toLocaleString()}。` : `請於 ${escHtml(deadlineText)} 前完成付款回報。`);
return `✅ 已選加價位置:${escHtml(reg.stallNumber)}
${line}
`;
}
if(String(reg.seatChoiceStatus||'')==='released' || (reg.seatSelectionDeadline && isExpiredIso(reg.seatSelectionDeadline))){
return `加價選位期限已過,系統將依付款確認順序安排位置。
`;
}
if(canSelectStallReg(reg)){
const lead=paid>0 ? '您已完成原費用付款,可再選加價位置;選位後只需補繳差額。' : `本場可加價選位。選位後請於 ${escHtml(deadlineText)} 完成付款。`;
return ``;
}
return `不加價選位將依付款確認順序安排位置。
`;
}
function publishedSeatHtml(reg){
const days=Array.isArray(reg&®.dayPositions)?reg.dayPositions:[];
if(days.length)return `您的攤位:${days.map(x=>`${escHtml(String(x.date||'').replace(/^\d{4}-/,''))}|${escHtml(x.stallNumber||'')}`).join('、')}
`;
if(reg&®.stallNumber)return `您的攤位:${escHtml(reg.stallNumber)}
`;
return '';
}
function payBadge(reg){
if(isCancelledReg(reg)) return {t:'已取消',bg:'#eeeeee',c:'#777777'};
const ts=String(reg.transferStatus||'');
if(['已退費','refunded'].includes(ts)) return {t:'已退費',bg:'#eeeeee',c:'#777777'};
if(['申請退費','退費中','refund_pending'].includes(ts)) return {t:'退費中',bg:'#fff0f0',c:'#c0392b'};
const ps=String(reg.payStatus||'未繳費');
const total=Number(reg.total||0)||0, paid=Number(reg.paid||0)||0;
const due=Number.isFinite(Number(reg.due))?Math.max(0,Number(reg.due)):Math.max(0,total-paid);
if(ps==='免費') return {t:'免費',bg:'#e8f7ee',c:'#2a6a10'};
if(paid>0 && due>0) return {t:'待補款',bg:'#fff8ed',c:'#b45309'};
if(isPaidText(ps) && due<=0) return {t:'已繳費',bg:'#e8f7ee',c:'#2a6a10'};
if(isPendingPayText(ps)) return {t:'待確認',bg:'#fff8ed',c:'#b45309'};
return {t:'未繳費',bg:'#fdecea',c:'#c0392b'};
}
function escHtml(v){return String(v??'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');}
function mFirstVal(obj, keys, fallback=''){
for(const k of keys){
const v=obj && obj[k];
if(v!==undefined && v!==null && String(v).trim()!=='') return v;
}
return fallback;
}
function mFormatDateTime(v){
if(!v) return '';
const s=String(v);
const d=new Date(s);
if(Number.isNaN(d.getTime())) return s;
return d.toLocaleString('zh-TW',{year:'numeric',month:'2-digit',day:'2-digit',weekday:'short',hour:'2-digit',minute:'2-digit',hour12:false}).replace(/\//g,'/');
}
function mSessionForReg(r){
const sid=String(r?.sessionId||r?.session_id||'');
const list=[...(Array.isArray(ST.allSessionsData)?ST.allSessionsData:[]), ...(Array.isArray(ST.sessions)?ST.sessions:[])];
return list.find(s=>String(s.id||s.sessionId||'')===sid) || {};
}
function mSessionDateLine(d){
if(!d) return '';
const date=String(d.date||d.day||'').replace(/-/g,'/');
const start=d.start||d.startTime||'';
const end=d.end||d.endTime||'';
return [date, (start||end)?`${start}${end?' - '+end:''}`:''].filter(Boolean).join(' ');
}
function mRecordDateText(r){
const s=mSessionForReg(r);
const dates=Array.isArray(s.dates)&&s.dates.length?s.dates:(Array.isArray(r.sessionDates)?r.sessionDates:[]);
const selected=Array.isArray(r.selectedDates)?r.selectedDates.map(String).filter(Boolean):[];
if(dates.length){
const chosen=selected.length ? dates.filter(d=>selected.includes(String(d.date||d.day||''))) : dates;
const lines=(chosen.length?chosen:dates).map(mSessionDateLine).filter(Boolean);
if(lines.length) return lines.join('、');
}
const sessionRaw=s.date||s.startDate||s.eventDate||s.sessionDate||'';
const sessionTime=s.startTime||s.time||'';
if(sessionRaw) return [String(sessionRaw).replace(/-/g,'/'), sessionTime].filter(Boolean).join(' ');
const selectedText=selected.length ? selected.join('、') : '';
return selectedText || mFirstVal(r,['dateText','sessionDateText','timeText','eventTime','date','sessionDate'], '') || (r.createdAt ? '報名日期:'+mFormatDateTime(r.createdAt) : '時間請見活動公告');
}
function mRecordLocationText(r){
const s=mSessionForReg(r);
return mFirstVal(r,['location','sessionLocation','venue','sessionVenue','place','address'], '') || mFirstVal(s,['location','sessionLocation','venue','place','address'], '活動地點請見公告');
}
function mRecordCover(r){
const s=mSessionForReg(r);
const skin = window.activeBrandSkin || defaultBrandSkin || {};
return mFirstVal(r,['coverUrl','cover','image','imageUrl','sessionCover','eventCover'], '')
|| mFirstVal(s,['coverUrl','cover','imageUrl','imgUrl','sessionCover','eventCover'], '')
|| skin.mascotPortal
|| 'IP指引.png';
}
function mShortDateRange(v){
const s=String(v===undefined||v===null?'':v).trim();
if(!s) return '未設定';
const dates=[];
const re=/(20\d{2})[\/\-\.年](\d{1,2})[\/\-\.月](\d{1,2})|(^|[^0-9])(\d{1,2})[\/\-\.月](\d{1,2})(?=[^0-9]|$)/g;
let m;
while((m=re.exec(s))){
const mo=m[2]||m[5];
const da=m[3]||m[6];
if(mo&&da) dates.push(Number(mo)+'/'+Number(da));
}
const uniq=[];
dates.forEach(d=>{ if(!uniq.includes(d)) uniq.push(d); });
if(uniq.length>1) return uniq[0]+'-'+uniq[uniq.length-1];
if(uniq.length===1) return uniq[0];
return s.replace(/20\d{2}[\/\-\.年]/g,'').replace(/月/g,'/').replace(/日/g,'').replace(/\s+/g,' ').trim() || '未設定';
}
function mEquipTextIsUseful(v){
const s=String(v===undefined||v===null?'':v).trim();
return !!s && !/^(無|沒有|未加購|不需|none)$/i.test(s);
}
function mRecordIncludedEquipText(r){
return String(mFirstVal(r,['preNoticeIncludedEquipmentText','includedEquipmentText','included_equipment_text','baseEquipmentText','base_equipment_text'],'')||'').trim();
}
function mRecordExtraEquipText(r){
return String(mFirstVal(r,['preNoticeExtraEquipmentText','extraEquipmentText','extra_equipment_text'],'')||'').trim();
}
function mRecordEquipText(r){
// 正式設備顯示優先使用 Worker 從資料庫整理好的總設備:內含數量×攤位數+加購設備。
const direct=String(mFirstVal(r,['preNoticeEquipmentText','totalEquipmentText','equipmentText','wholeEquipmentText','dailyEquipmentText','equipText','equipment_text','daily_equipment_text'],'')||'').trim();
if(mEquipTextIsUseful(direct)) return direct;
// 總設備一律以 Worker 從資料庫算好的為準(內含設備 × 攤位數 + 加租設備)。
// 不可用前端自行推算的加購清單頂替,否則會漏掉場次內含的桌椅(曾造成「只顯示椅子」)。
const included=mRecordIncludedEquipText(r);
const extra=mRecordExtraEquipText(r);
const hasIncluded=mEquipTextIsUseful(included);
const hasExtra=mEquipTextIsUseful(extra);
if(hasIncluded && hasExtra) return `${included},另加 ${extra}`;
if(hasIncluded) return included;
if(hasExtra) return `加購:${extra}`;
return '本場不含設備(請自備)';
}
function mCleanEquipDisplayText(txt){
let s=String(txt||'').trim();
s=s.replace(/^總設備\s*[::]\s*/,'').replace(/^加購\s*[::]\s*/,'').trim();
// 後端算出「無」=該場內含設備為 0 且攤友未加租 → 就是不含設備,不是載入失敗
if(s==='無' || s==='依本場設定') return '本場不含設備(請自備)';
if(!s) return '本場不含設備(請自備)';
return s;
}
function mPreNoticeEquipDisplayText(r){
const raw = mRecordEquipText(r);
return mCleanEquipDisplayText(raw);
}
function mRecordAddonText(r){ return String(mFirstVal(r,['addonText','addonsText','selectedAddonsText','addonQtyText','addon_text','addons_text','addon_qty_text'],'無')||'無'); }
function mRecordStallText(r){
const v=mFirstVal(r,['stallCount','stall_count','stalls','boothCount','booth_count'],'');
return v!=='' ? (String(v).replace(/攤$/,'')+'攤') : '未設定';
}
function mRecordPayText(r){
const v=mFirstVal(r,['total','totalAmount','amount','receivableAmount','registration_total_amount'],'');
if(v!=='' && v!==undefined && v!==null && !Number.isNaN(Number(v))){
let t='NT$ '+Number(v).toLocaleString('zh-TW');
// 已繳與應收有落差(併入套組補差額)時要講清楚,否則攤友會以為要再付一次全額。
const paid=Number(r.paid||0), due=Number(r.due||0);
if(paid>0 && due>0) t+='(已繳 NT$ '+paid.toLocaleString('zh-TW')+',待補繳 NT$ '+due.toLocaleString('zh-TW')+')';
return t;
}
return String(r.payStatus||r.paymentStatus||'請聯繫客服');
}
function canViewPreNoticeReg(reg){
if(!reg || isCancelledReg(reg)) return false;
const ps=String(reg.payStatus||reg.paymentStatus||'').trim();
return isPaidText(ps) || ps==='免費';
}
function officialGroupForReg(regOrId){
const reg=typeof regOrId==='object'&®OrId ? regOrId : findMyRegById(regOrId);
const group=reg&®.officialGroup&&typeof reg.officialGroup==='object' ? reg.officialGroup : null;
if(!group || group.enabled!==true || !/^https:\/\//i.test(String(group.url||''))) return null;
return group;
}
// 保留既有公開函式與 fallback;新增的已繳費卡片只會優先使用該筆報名的受保護設定。
function officialGroupUrl(regOrId){
const protectedGroup=officialGroupForReg(regOrId);
if(protectedGroup&&protectedGroup.url) return protectedGroup.url;
const t=ST.tenant||{};
const fromDb=t.officialGroupUrl || t.official_group_url || t.bigGroupUrl || t.big_group_url || t.groupUrl || t.group_url || '';
if(fromDb) return fromDb;
if(String(TENANT_ID||'')==='tuibile') return 'https://line.me/ti/g2/cp-K_Los4J2zBc6rGcRA14TJCx3e99v0i4p-hQ?utm_source=invitation&utm_medium=link_copy&utm_campaign=default';
return LINE_URL || '';
}
function officialGroupName(regOrId){
const protectedGroup=officialGroupForReg(regOrId);
if(protectedGroup&&protectedGroup.name) return protectedGroup.name;
const t=ST.tenant||{};
return t.officialGroupName || t.official_group_name || '全台市集藝文資訊中心 大群組';
}
function openOfficialGroup(regId){
const group=officialGroupForReg(regId);
const url=(group&&group.url)||officialGroupUrl();
if(!url){ toast('尚未取得已繳費的大群組邀請,請重新整理我的紀錄'); return; }
window.open(url,'_blank','noopener');
}
function officialGroupCardHtml(reg){
const group=officialGroupForReg(reg);
if(!group) return '';
return `
📣 ${escHtml(group.name||'活動大群組')}
${escHtml(group.inviteText||'請點選以下按鈕加入社群!')}
${group.password?`
加入密碼:${escHtml(group.password)}
`:''}
`;
}
function openPreNoticeSupport(id){
// 直接把說明疊在「行前通知」上面(不關閉行前通知,其 z-index 8000 > 5000)。
// 看完關閉說明即回到原本三顆按鈕,可接著點下一個,不再繞回我的紀錄。
try{
if(typeof openSupportFaqCard === 'function' && document.getElementById(id)){
openSupportFaqCard(id);
}else{
toast('說明開啟失敗,請至客服頁查看');
}
}catch(e){
console.warn('[openPreNoticeSupport]', e);
toast('說明開啟失敗,請至客服頁查看');
}
}
async function openPreEventNotice(regId){
const r=findMyRegById(regId);
if(!r){ toast('找不到報名資料,請重新整理我的紀錄'); return; }
const body=$('preNoticeBody');
if(!body) return;
const group=officialGroupForReg(r);
const groupBtn=group?``:'';
body.innerHTML=`
場次${escHtml(r.sessionName||r.sesName||'場次')}
日期${escHtml(mRecordDateText(r))}
地點${escHtml(mRecordLocationText(r))}
報到時間活動前 60 分鐘開始報到
攤位${escHtml(mRecordStallText(r))}
設備${escHtml(mPreNoticeEquipDisplayText(r))}
已完成繳費,請務必查看下方行前規範。後續提醒與現場異動,均以官方大群組統一公告為主。
${groupBtn}
`;
openModal('preNoticeModal');
const mapBox=$('preNoticeSeatMap');
if(!mapBox||!r.seatMapUrl||(!r.stallNumber&&!(Array.isArray(r.dayPositions)&&r.dayPositions.length)))return;
mapBox.innerHTML='🗺️ 位置圖(金色標記是您的位置)
正在載入正式排位…
';
try{
const seatData=await api({action:'getSeatMap',sessionId:r.sessionId,regId:r.id,email:getMemberEmail(),phone:getMemberPhone(),_t:Date.now()});
if(seatData&&seatData.error)throw new Error(seatData.error);
const seats=Array.isArray(seatData&&seatData.seats)?seatData.seats:[];
// 手機位置圖:依「第幾天」分開顯示。每天只把「你那天的攤位」標成金色,其餘為小灰點。
// 兩天不同位置時不會再同時冒出兩個金點;切換日期只換金點。報兩攤則兩格同時金色、緊鄰=連攤。
const splitCodes=v=>String(v==null?'':v).split(/[,,、\s]+/).map(x=>x.trim()).filter(Boolean);
const days=Array.isArray(r.dayPositions)&&r.dayPositions.length
? r.dayPositions.map(d=>({date:String(d.date||''),codes:new Set(splitCodes(d.stallNumber))}))
: [{date:'',codes:new Set(splitCodes(r.stallNumber))}];
const fmtDate=d=>String(d||'').replace(/^\d{4}-/,'').replace(/-/g,'/');
const drawMarkers=codeSet=>seats.filter(s=>Number(s.x)>0||Number(s.y)>0).map(s=>{
const mine=codeSet.has(String(s.code||''));
const size=mine?'min-width:46px;height:34px;padding:3px 6px;font-size:12px;':'width:14px;height:10px;padding:0;font-size:0;line-height:0;';
return `${mine?escHtml(s.code||''):''}`;
}).join('');
const tabsHtml=days.length>1
? ''
+ days.map((d,i)=>``).join('')
+ '
'
: '';
const stageHtml=codeSet=>`})
${drawMarkers(codeSet)}
`;
mapBox.innerHTML=`🗺️ 位置圖(金色是您的攤位${days.length>1?',可切換日期':''})
${tabsHtml}${stageHtml(days[0].codes)}`;
if(days.length>1){
mapBox.querySelectorAll('#preNoticeSeatTabs button').forEach(btn=>{
btn.addEventListener('click',()=>{
const sel=Number(btn.getAttribute('data-day'))||0;
mapBox.querySelectorAll('#preNoticeSeatTabs button').forEach((b,i)=>{b.style.background=(i===sel?'#ffd76a':'#fff');});
const stage=mapBox.querySelector('#preNoticeSeatStage');
if(stage)stage.innerHTML=`
${drawMarkers(days[sel].codes)}`;
});
});
}
}catch(e){
mapBox.innerHTML=`🗺️ 位置圖
})
目前僅顯示正式底圖,請以上方攤位編號為準。
`;
}
}
function mRecordFixedBlockHtml(r){
const date=mShortDateRange(mRecordDateText(r));
const stalls=mRecordStallText(r);
const equip=mRecordEquipText(r);
const addon=mRecordAddonText(r);
const pay=mRecordPayText(r);
return `
${escHtml(date)}${escHtml(stalls)}
設備:${escHtml(mPreNoticeEquipDisplayText(r))}
繳費(含押金):${escHtml(pay)}
`;
}
// ── MEMBER_NOTIFICATIONS_20260809 ───────────────────────────────
let MemberNotifications=[];
function memberRecordUnreadCount(regId){
return (MemberNotifications||[]).filter(n=>!n.isRead&&String(n.registrationId||'')===String(regId||'')).length;
}
function memberRecordUnreadHtml(regId){
const n=memberRecordUnreadCount(regId);
return n?`🔔 ${n} 則新通知
`:'';
}
function memberNotifyCredentials(){
return {email:String(ST.memberEmail||memberSafeEmail()||'').trim(),phone:String(getMemberPhone()||'').trim()};
}
async function loadMemberNotifications(){
const c=memberNotifyCredentials();
if(!c.email||!c.phone){MemberNotifications=[];renderMemberNotifications();return;}
try{
const r=await api({action:'memberNotifications',email:c.email,phone:c.phone,_t:Date.now()});
MemberNotifications=Array.isArray(r)?r:[];
}catch(e){MemberNotifications=[];}
renderMemberNotifications();
}
function renderMemberNotifications(){
const unread=(MemberNotifications||[]).filter(n=>!n.isRead).length;
const badge=$('memberNotifyCount');
if(badge){badge.textContent=String(unread);badge.classList.toggle('hidden',unread<=0);}
const box=$('memberNotifyList');
if(box){
box.innerHTML=MemberNotifications.length
? MemberNotifications.map(n=>`${escHtml(n.title||'通知')}
${escHtml(n.message||'')}
`).join('')
: '目前沒有通知
';
}
if(Array.isArray(ST.regs)&&ST.regs.length) renderMyRegs(ST.regs);
}
function toggleMemberNotifications(){
const p=$('memberNotifyPanel'); if(p)p.classList.toggle('hidden');
}
async function openMemberNotification(id,regId){
const c=memberNotifyCredentials();
try{await apiPost({action:'markMemberNotificationRead',id,email:c.email,phone:c.phone});}catch(e){}
const n=MemberNotifications.find(x=>String(x.id)===String(id)); if(n)n.isRead=true;
renderMemberNotifications();
const p=$('memberNotifyPanel');if(p)p.classList.add('hidden');
if(regId){
setTimeout(()=>{const el=document.querySelector('[data-registration-id="'+CSS.escape(String(regId))+'"]');if(el)el.scrollIntoView({behavior:'smooth',block:'center'});},50);
}
}
async function markAllMemberNotificationsRead(){
const c=memberNotifyCredentials();
try{await apiPost({action:'markMemberNotificationRead',all:true,email:c.email,phone:c.phone});}catch(e){}
MemberNotifications.forEach(n=>n.isRead=true);renderMemberNotifications();
}
function mReviewPill(r){
const st=String(r.status||r.reviewStatus||'待審核').trim();
const ps=String(r.payStatus||'').trim();
const ts=String(r.transferStatus||'').trim();
if(ts==='已轉場') return {text:'已轉場',icon:'→',bg:'#e8f7ee',color:'#2a6a10',border:'#b9d3a5',note:'原場紀錄已保留,請查看新的場次報名'};
if(ts==='已轉活動金') return {text:'已轉活動金',icon:'$',bg:'#eef6ff',color:'#24638a',border:'#b7d8ea',note:'資料庫已確認收款總額(包含押金)已轉入活動金'};
if(['已退費','refunded'].includes(ts) || ['已退費','已退款'].includes(ps)) return {text:'已退費',icon:'✓',bg:'#f3f4f6',color:'#777',border:'#e5e7eb',note:'本筆退費已完成'};
if(['申請退費','退費中','refund_pending'].includes(ts)) return {text:'退費中',icon:'⏱',bg:'#fdecea',color:'#c0392b',border:'#f3b5b5',note:'主辦正在處理退費'};
if(isCancelledReg(r)) return {text:'已取消', icon:'✕', bg:'#f3f4f6', color:'#777', border:'#e5e7eb', note:'此筆報名已取消'};
const total=Number(r.total||0)||0, paid=Number(r.paid||0)||0;
const due=Number.isFinite(Number(r.due))?Math.max(0,Number(r.due)):Math.max(0,total-paid);
if(paid>0 && due>0) return {text:'待補款',icon:'$',bg:'#fff8ed',color:'#d97706',border:'#f5d7a1',note:'尚需補繳 NT$ '+due.toLocaleString()};
if(isPaidText(ps) || ps==='免費') return {text:ps==='免費'?'免費':'已完成', icon:'✓', bg:'#e8f7ee', color:'#2a6a10', border:'#b9d3a5', note:'感謝參與,期待下次見面!'};
if(isPendingPayText(ps)) return {text:'待確認', icon:'⏱', bg:'#fff8ed', color:'#b45309', border:'#f5d7a1', note:'已回報,等待主辦確認'};
if(st==='已錄取') return {text:'待付款', icon:'$', bg:'#fff8ed', color:'#d97706', border:'#f5d7a1', note:r.paymentDeadline?('請於 '+escHtml(r.paymentDeadline)+' 前完成付款'):'請依錄取通知完成付款'};
if(st==='待審核') return {text:'審核中', icon:'⏱', bg:'#eef6e6', color:'#2a6a10', border:'#b9d3a5', note:'主辦單位審核中,請耐心等候'};
if(st==='不錄取') return {text:'未錄取', icon:'–', bg:'#fdecea', color:'#c0392b', border:'#f3b5b5', note:'可提前報名其他場次'};
return {text:st||'處理中', icon:'•', bg:'#eef2ff', color:'#4f46e5', border:'#c7d2fe', note:''};
}
function renderMyRegs(regs){
ST.regs = Array.isArray(regs) ? regs : [];
const el=$('myRegsList');
const helper=$('memberRecordHelper');
if(!el) return;
if(!ST.regs.length){
el.innerHTML='目前沒有報名紀錄。
若剛完成報名,請稍候幾秒後再點一次「我的紀錄」。
';
if(helper) helper.classList.add('show');
handlePendingPayLink(ST.regs);
return;
}
if(helper) helper.classList.add('show');
const creditBalance=Math.max(0,Number(ST.regs[0]&&ST.regs[0].activityCreditBalance||0)||0);
const creditLedger=Array.isArray(ST.regs[0]&&ST.regs[0].activityCreditLedger)?ST.regs[0].activityCreditLedger:[];
const creditSummary=`會員活動金
可用餘額 NT$ ${creditBalance.toLocaleString()}
${creditLedger.length?`
查看活動金明細
${creditLedger.map(x=>`${escHtml(x.note||(x.direction==='debit'?'活動金扣抵':'活動金轉入'))}
${escHtml(String(x.createdAt||'').replace('T',' ').slice(0,16))}|${escHtml(x.status||'')}${x.direction==='debit'?'-':'+'} NT$ ${Number(x.amount||0).toLocaleString()}
`).join('')}`:'
目前沒有活動金異動紀錄
'}
`;
// 組合套組(bundle_group_id 相同)在攤友端合併為「一張卡片」,卡內列出各場日期/地點/設備。
// 拆帳、分場次名單、分別行前通知都是後台的事;攤友看到兩張卡會誤以為可以只退一場來省錢。
const _shown = new Set();
const _units = [];
for(const r of (ST.regs||[])){
const g = bundleGroupOf(r);
if(g){
if(_shown.has(g)) continue;
_shown.add(g);
const legs = (ST.regs||[]).filter(x=>bundleGroupOf(x)===g);
_units.push(legs.length>1 ? {bundle:true, legs, head:legs[0]} : {bundle:false, head:r});
} else {
_units.push({bundle:false, head:r});
}
}
el.innerHTML=creditSummary+_units.map(u=>{
const r=u.head;
const legs=u.bundle?u.legs:[r];
const pill=mReviewPill(r);
const title=u.bundle
? `🎫 ${escHtml(bundleTitleOf(legs))}(${legs.length} 場)`
: escHtml(r.sessionName||r.sesName||'場次');
const locationText=escHtml(mRecordLocationText(r));
const cover=escHtml(mRecordCover(r));
const note=escHtml(pill.note||'');
const totalAll=legs.reduce((s,x)=>s+Number(x.total||0),0);
const canPay=legs.some(x=>canPayReg(x));
const canCancel=legs.every(x=>canDirectCancelReg(x));
const refundTarget=legs.find(x=>canRefundReg(x))||r;
const canRefund=legs.some(x=>canRefundReg(x)), canPreNotice=legs.some(x=>canViewPreNoticeReg(x));
const groupReg=legs.find(x=>officialGroupForReg(x))||null;
const resolution=r.resolution||null;
const resolutionMsg=resolution?`${String(resolution.mode||'')==='transfer'?'已轉移至其他場次':'已轉為活動金'}
${Number(resolution.credit_created||0)>0?'活動金增加 NT$ '+Number(resolution.credit_created).toLocaleString()+'
':''}${Number(resolution.due_amount||0)>0?'新場待補 NT$ '+Number(resolution.due_amount).toLocaleString()+'
':''}${Number(resolution.deposit_refund_due||0)>0?'待退押金 NT$ '+Number(resolution.deposit_refund_due).toLocaleString()+'
':''}目前活動金餘額:NT$ ${Number(r.activityCreditBalance||0).toLocaleString()}
`:'';
const statusMsg = resolutionMsg || (isCancelledReg(r)
? `已取消,報名紀錄保留
`
: (isRefundingReg(r)
? `退費處理中,等待主辦確認退款${legs.length>1?'(組合套組整組一起退)':''}。名額已釋出,無需再操作。
`
: (isPendingPayText(r.payStatus)
? ``
: '')));
let actions='';
if(canPay){
actions+= u.bundle
? ``
: ``;
}
// 組合為綁定優惠:整組一起繳、一起退,故不提供單筆勾選
const cartBox = (canPay && !u.bundle) ? `` : '';
if(canPreNotice){
actions+= u.bundle
? legs.filter(x=>canViewPreNoticeReg(x)).map(x=>``).join('')
: ``;
}
if(canCancel) actions+=``;
if(canRefund) actions+=``;
const bodyBlock = u.bundle ? bundleLegsHtml(legs, totalAll) : mRecordFixedBlockHtml(r);
const seatBlock = u.bundle ? legs.map(x=>stallSelectionHtml(x)).join('') : stallSelectionHtml(r);
const idLine = u.bundle
? `報名編號:${legs.map(x=>escHtml(x.id)).join('、')}
`
: `報名編號:${escHtml(r.id)}
`;
return `
${memberRecordUnreadHtml(r.id)}
${title}
${u.bundle?'
組合優惠:一起繳費、一起退費
':`
◎ ${locationText}
`}
${pill.icon} ${pill.text}
${note}
${bodyBlock}
${statusMsg}
${groupReg?officialGroupCardHtml(groupReg):''}
${seatBlock}
${cartBox}
${actions?`
${actions}
`:''}
${idLine}
`;
}).join('');
el.className='m-record-list m-record-grid';
el.style.cssText='';
handlePendingPayLink(ST.regs);
updatePayCart();
}
// ── 合併繳費(購物車):勾選多筆一起繳,省手續費 ──
function removePayCartBar(){ const b=document.getElementById('payCartBar'); if(b) b.remove(); }
function updatePayCart(){
// 僅在「我的紀錄」頁顯示;其他頁一律移除,避免長駐畫面底部
const cbs=[...document.querySelectorAll('.m-cart-cb')];
if(!cbs.length){ removePayCartBar(); return; }
const picked=cbs.filter(c=>c.checked).map(c=>c.value);
let bar=$('payCartBar');
if(picked.length<2){ if(bar) bar.remove(); return; }
const total=picked.reduce((s,id)=>{ const r=(ST.regs||[]).find(x=>String(x.id)===String(id)); return s+regOutstandingAmount(r); },0);
if(!bar){
bar=document.createElement('div');
bar.id='payCartBar';
bar.style.cssText='position:fixed;left:0;right:0;bottom:0;z-index:900;background:#fff;border-top:2px solid var(--g);box-shadow:0 -4px 20px rgba(0,0,0,.12);padding:12px 16px calc(12px + env(safe-area-inset-bottom));display:flex;align-items:center;gap:12px;justify-content:space-between;flex-wrap:wrap';
document.body.appendChild(bar);
}
bar.innerHTML=`已選 ${picked.length} 場|合計 NT$${total.toLocaleString()}
`;
}
// 組合付款:自動把同組未繳的場次一起帶入合併繳費
function startBundlePay(regId){
const reg=(ST.regs||[]).find(x=>String(x.id)===String(regId));
if(!reg){ toast('找不到報名'); return; }
const group=bundleUnpaidRegs(reg);
if(group.length<=1){
showPayPage(reg.id, reg.sessionName||'', regOutstandingAmount(reg));
return;
}
const total=group.reduce((s,r)=>s+regOutstandingAmount(r),0);
ST_mergedPayIds=group.map(r=>String(r.id));
showPayPage(group[0].id, `組合繳費(共 ${group.length} 場)`, total, {merged:true, reg:group[0]});
}
function getPayCartIds(){ return [...document.querySelectorAll('.m-cart-cb')].filter(c=>c.checked).map(c=>c.value); }
function startMergedPay(){
const ids=getPayCartIds();
if(ids.length<2){ toast('請至少勾選兩場'); return; }
const total=ids.reduce((s,id)=>{ const r=(ST.regs||[]).find(x=>String(x.id)===String(id)); return s+regOutstandingAmount(r); },0);
const first=(ST.regs||[]).find(x=>String(x.id)===String(ids[0]));
ST_mergedPayIds=ids;
showPayPage(ids[0], `合併繳費(共 ${ids.length} 場)`, total, {merged:true, reg:first});
}
function handlePendingPayLink(regs){
const regId = window._pendingPayRegId || '';
if(!regId) return;
const reg = (Array.isArray(regs)?regs:[]).find(r=>String(r.id)===String(regId));
if(!reg) return;
window._pendingPayRegId = '';
setTimeout(()=>{ if(canPayReg(reg)) showPayPage(reg.id, reg.sessionName||reg.sesName||'場次', regOutstandingAmount(reg)); else openRegDetail(reg.id); }, 300);
}
function openSeatMapZoom(kind){
const src = kind==='picker' ? (ST._stallPickerMapUrl||'') : (ST._seatPreviewMapUrl||'');
if(!src){ toast('目前沒有可放大的場地圖'); return; }
ST._seatMapZoomScale=1.8;
const img=$('seatMapZoomImg');
if(img){ img.src=src; img.style.width=(ST._seatMapZoomScale*100)+'%'; }
const title=$('seatMapZoomTitle');
if(title) title.textContent='放大查看場地圖';
openModal('seatMapZoomModal');
setTimeout(()=>{ const sc=$('seatMapZoomScroller'); if(sc){ sc.scrollLeft=Math.max(0,(sc.scrollWidth-sc.clientWidth)/2); sc.scrollTop=0; } },80);
}
function adjustSeatMapZoom(delta){
ST._seatMapZoomScale=Math.max(1, Math.min(4, Number(ST._seatMapZoomScale||1.8)+Number(delta||0)));
const img=$('seatMapZoomImg');
if(img) img.style.width=(ST._seatMapZoomScale*100)+'%';
}
async function previewSeatMap(sessionId){
openModal('seatPreviewModal');
const body=$('seatPreviewBody');
ST._seatPreviewMapUrl='';
if(body) body.innerHTML='載入場地圖…
';
try{
const res=await api({action:'getSeatMap',sessionId,_t:Date.now()});
if(res.error){ body.innerHTML=''+escHtml(res.error)+'
'; return; }
const seats=Array.isArray(res.seats)?res.seats:[];
const map=res.mapUrl||'';
ST._seatPreviewMapUrl=map;
const stats={auto:0,paid:0,service:0,closed:0};
seats.forEach(s=>{ const t=s.type||'auto'; if(stats[t]!=null) stats[t]++; });
const paidSeats=seats.filter(s=>s.type==='paid'&&s.active&&!s.occupied);
const paidList=paidSeats.map(s=>{
const code=String(s.code||s.stallNo||'').trim();
const price=Number(s.price||0);
return `${escHtml(code)}
錄取後可選${price>0?`
+NT$${price}`:''}
`;
}).join('');
const occupiedPaid=seats.filter(s=>s.type==='paid'&&s.active&&s.occupied).length;
const mapBlock=map
? `
`
: `此場次尚未上傳圖片;下方攤位號碼仍由資料庫載入並正常顯示。
`;
body.innerHTML=mapBlock
+`號碼狀態:自動 ${stats.auto}|加價可選 ${paidSeats.length}|加價已占用 ${occupiedPaid}|不開放 ${stats.closed}|服務台 ${stats.service}
本畫面僅供報名前查看位置配置;正式加價選位請依「我的紀錄」顯示的狀態操作。
可選的加價位置
${paidList||'
此場次目前沒有可選的加價位置。
'}
自動排位號碼只供查看,不開放指定;若不加價,系統將依付款確認順序安排位置。
`;
}catch(e){ body.innerHTML='場地圖載入失敗
'; }
}
function normalizeStallStatus(s){
const v=String(s||'').trim();
if(v==='paid') return '已選走';
if(v==='reserved') return '預留中';
return v||'可選';
}
async function openStallPickerForReg(regId){
const reg=(ST.regs||[]).find(r=>String(r.id)===String(regId));
if(!reg){ toast('找不到報名紀錄'); return; }
if(!canSelectStallReg(reg)){ toast('目前狀態不可選位'); return; }
if(reg.stallNumber){ toast('已完成選位,如需換位請聯繫主辦'); return; }
ST._stallPick={reg, selected:[]};
$('stallPickerTitle').textContent='加價選位|'+(reg.sessionName||reg.sesName||'場次');
$('stallGrid').innerHTML='載入攤位中…
';
if($('stallPickerMap')) $('stallPickerMap').innerHTML='';
$('stallConfirmBtn').disabled=true;
$('stallConfirmBtn').style.opacity='.5';
openModal('stallPickerModal');
try{
const res=await api({action:'getSeatMap',sessionId:reg.sessionId,regId:reg.id,email:getMemberEmail(),phone:getMemberPhone(),_t:Date.now()});
if(res.error){ $('stallGrid').innerHTML=''+escHtml(res.error)+'
'; return; }
const paidSeats=(Array.isArray(res.seats)?res.seats:[]).filter(s=>s.type==='paid'&&s.active);
const list=paidSeats.map(s=>s.code);
// B-02:公開 API 不再回傳 regId/email;「這格是不是我的」改由後端驗證後給 mine 旗標。
const taken={}; paidSeats.forEach(s=>{ if(s.occupied) taken[s.code]={status:s.status==='預留'?'reserved':'paid',mine:!!s.mine}; });
ST._stallPick.seatMap=res; ST._stallPick.seatMeta=Object.fromEntries(paidSeats.map(s=>[s.code,s]));
ST._stallPickerMapUrl=res.mapUrl||'';
if($('stallPickerMap')) $('stallPickerMap').innerHTML=res.mapUrl?``:`此場次尚未上傳已編號場地圖,請依號碼清單選擇。
`;
if(!list.length){ $('stallGrid').innerHTML='此場次尚未建立可選攤位
'; return; }
// SEATFULL_20260725:有加價位、但全部已被占用(不是自己的)→ 依規則顯示額滿,不給選。
if(!paidSeats.some(s=>!s.occupied || s.mine)){
$('stallGrid').innerHTML='本場自由選位已額滿。
';
$('stallConfirmBtn').disabled=true; $('stallConfirmBtn').style.opacity='.5';
return;
}
ST._stallPick.stallList=list;
ST._stallPick.taken=taken;
renderStallPickerGrid();
}catch(e){
$('stallGrid').innerHTML='攤位載入失敗
';
}
}
function renderStallPickerGrid(){
const pick=ST._stallPick||{};
const reg=pick.reg||{};
const max=Math.max(1,Number(reg.stallCount||1));
const taken=pick.taken||{};
const selected=pick.selected||[];
$('stallPickerHint').textContent=`請選擇 ${max} 個加價位置。只有加價位置可選;逾時未付款會自動釋出。`;
const summary=$('stallSelectedSummary');
if(summary){
if(selected.length){
const fee=selected.reduce((sum,code)=>sum+Number((pick.seatMeta&&pick.seatMeta[code]&&pick.seatMeta[code].price)||0),0);
summary.style.background='#e8f5ec';
summary.style.borderColor='#9fb59c';
summary.style.color='#1b4332';
summary.innerHTML=`✅ 目前已選:${selected.map(escHtml).join('、')}${fee>0?`|加價 NT$${fee}`:''}`;
}else{
summary.style.background='#fff8ed';
summary.style.borderColor='#f59e0b';
summary.style.color='#7c2d12';
summary.textContent='尚未選擇位置,請點下方號碼。';
}
}
$('stallGrid').innerHTML=(pick.stallList||[]).map(num=>{
const info=taken[num];
const mine=!!(info && info.mine);
const blocked=info && !mine;
const on=selected.includes(num);
const price=(pick.seatMeta&&pick.seatMeta[num]&&Number(pick.seatMeta[num].price)>0)?Number(pick.seatMeta[num].price):0;
const bg=blocked?(info.status==='reserved'?'#fff8ed':'#fdecea'):(on?'#9fb59c':'#fff');
const color=blocked?(info.status==='reserved'?'#b45309':'#c0392b'):(on?'#fff':'var(--dk)');
const border=on?'#1b4332':(blocked?'#f3b5ad':'#cfe5d6');
const label=blocked?normalizeStallStatus(info.status):(on?'✅ 已選':'可選');
const shadow=on?'box-shadow:0 0 0 3px rgba(45,106,79,.18);':'';
return ``;
}).join('');
const btn=$('stallConfirmBtn');
btn.disabled=selected.length!==max;
btn.style.opacity=btn.disabled?'.55':'1';
btn.textContent=selected.length===max?`確認選擇:${selected.join('、')}`:'已選 '+selected.length+'/'+max;
}
function toggleStallPick(num){
const pick=ST._stallPick||{};
const reg=pick.reg||{};
const max=Math.max(1,Number(reg.stallCount||1));
const arr=pick.selected||[];
const idx=arr.indexOf(num);
if(idx>=0) arr.splice(idx,1);
else{
if(arr.length>=max){ toast('選位數量不可超過報名數量'); return; }
arr.push(num);
}
pick.selected=arr;
ST._stallPick=pick;
renderStallPickerGrid();
}
async function submitSelectedStalls(){
const pick=ST._stallPick||{};
const reg=pick.reg||{};
const selected=pick.selected||[];
const max=Math.max(1,Number(reg.stallCount||1));
if(selected.length!==max){ toast('請選滿 '+max+' 個攤位'); return; }
const btn=$('stallConfirmBtn');
if(btn){ btn.disabled=true; btn.textContent='送出中…'; }
try{
const res=await apiPost({action:'claimPaidSeat',regId:reg.id,sessionId:reg.sessionId,seats:selected,email:getMemberEmail(),phone:getMemberPhone()});
if(res.error){ toast('❌ '+res.error); return; }
closeModal('stallPickerModal');
toast('✅ 已完成加價選位');
if(res.message) alert(res.message);
if(ST.memberEmail) await safeGetMyRegs(ST.memberEmail);
}finally{
if(btn){ btn.disabled=false; btn.textContent='確認選位'; }
}
}
function openRegDetail(regId){
const reg=(ST.regs||[]).find(r=>r.id===regId);
if(!reg) return;
const pb=payBadge(reg);
const canPay = canPayReg(reg);
const canCancel = canDirectCancelReg(reg);
const canRefund = canRefundReg(reg);
const canPreNotice = canViewPreNoticeReg(reg);
const modal=document.createElement('div');
modal.id='regDetailModal';
modal.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:9000;display:flex;align-items:flex-end;justify-content:center';
modal.innerHTML=`
${escHtml(reg.sessionName||reg.sesName||'場次')}
繳費${pb.t}
審核:${escHtml(reg.status||'')}
攤位數:${reg.stallCount||1} 攤
${reg.seatPricingEnabled ? (
((Array.isArray(reg.dayPositions)&®.dayPositions.length)||reg.stallNumber)
? publishedSeatHtml(reg)+(reg.seatMapUrl?`
場地圖
})
`:'')
: ((canViewPreNoticeReg(reg) && String(reg.seatChoiceIntent||'auto')!=='paid') ? `
攤位號碼:
將於活動前公布排定後會在此顯示,並隨行前通知一起寄給您
` : '')
) : ''}
${reg.equipmentText?`
設備:${escHtml(reg.equipmentText)}
`:''}
${reg.total>0?`
應繳金額:NT$ ${Number(reg.total||0).toLocaleString()}
`:''}
${reg.transferStatus?`
退款狀態:${escHtml(reg.transferStatus)}
`:''}
報名編號:${reg.id}
${canPay?`
`:''}
${isCancelledReg(reg)?`
已取消,報名紀錄保留
`:(isPendingPayText(reg.payStatus)?`
已回報,等待主辦確認
`:'')}
${officialGroupCardHtml(reg)}
${stallSelectionHtml(reg)}
${canPreNotice?`
`:''}
${canCancel?`
`:''}
${canRefund?`
`:''}
`;
modal.addEventListener('click', function(e){ if(e.target===modal) modal.remove(); });
history.pushState({regDetail:true},'',location.href);
document.body.appendChild(modal);
}
async function cancelMyReg(regId, sesName, isRefund){
const cur=(ST.regs||[]).find(r=>String(r.id)===String(regId));
const gid=cur&&cur.bundleGroupId?String(cur.bundleGroupId):'';
const groupRegs=gid?(ST.regs||[]).filter(r=>String(r.bundleGroupId||'')===gid):[];
const isBundle=groupRegs.length>1;
$('cancelModalSesName').textContent=(isRefund?'申請取消/退款:':'取消報名:')+'「'+sesName+'」';
$('cancelConfirmCheck').checked=false;
$('cancelSubmitBtn').disabled=true;
$('cancelSubmitBtn').textContent=isBundle?(isRefund?'確認申請整組退款':'確認取消整組報名'):(isRefund?'確認申請退費':'確認取消報名');
const refundPolicy=$('cancelRefundPolicy');
const directPolicy=$('cancelDirectPolicy');
const confirmLabel=$('cancelConfirmLabel');
const bundleNotice=$('cancelBundleNotice');
if(refundPolicy) refundPolicy.style.display=isRefund?'block':'none';
if(directPolicy) directPolicy.style.display=isRefund?'none':'block';
if(bundleNotice){
if(isBundle){
const names=groupRegs.map(r=>escHtml(r.sessionName||r.sesName||'場次')).filter(Boolean);
bundleNotice.style.display='block';
bundleNotice.innerHTML=`此筆為組合報名。送出後,系統會由資料庫判斷同組報名並同步處理:
${names.map(n=>`- ${n}
`).join('')}
`;
}else{
bundleNotice.style.display='none';
bundleNotice.innerHTML='';
}
}
if(confirmLabel) confirmLabel.textContent=isBundle
? (isRefund?'我確認要申請整組報名退款':'我確認要取消整組報名')
: (isRefund?'我已了解取消與退款規則,仍要送出退款申請':'我確認要取消此報名');
ST._pendingCancelRegId=regId;
ST._pendingCancelSesName=sesName;
ST._pendingCancelIsRefund=!!isRefund;
openModal('cancelModal');
}
function onCancelCheckChange(){
const checked=$('cancelConfirmCheck').checked;
const btn=$('cancelSubmitBtn');
btn.disabled=!checked;
btn.style.opacity=checked?'1':'0.5';
btn.style.cursor=checked?'pointer':'not-allowed';
}
async function doSubmitCancel(){
const regId=ST._pendingCancelRegId;
if(!regId)return;
const btn=$('cancelSubmitBtn');
if(btn){btn.disabled=true;btn.textContent='處理中…';}
try{
const action=ST._pendingCancelIsRefund?'applyRefund':'cancelReg';
const em=getMemberEmail();
const res=await apiPost({action,regId,email:em,phone:getMemberPhone()});
if(res&&res.error){toast('❌ '+res.error,7000);return;}
closeModal('cancelModal');
const n=Number(res&&(res.affectedCount||res.bundleCount)?(res.affectedCount||res.bundleCount):1);
const groupMsg=n>1?`(同組 ${n} 筆)`:'';
toast(ST._pendingCancelIsRefund?'✅ 已送出退款申請'+groupMsg:'✅ 已取消報名'+groupMsg);
const refreshEmail = getMemberEmail();
if(refreshEmail){ ST.memberEmail = refreshEmail; await safeGetMyRegs(refreshEmail); }
ST._pendingCancelRegId=''; ST._pendingCancelSesName=''; ST._pendingCancelIsRefund=false;
}catch(e){
toast('❌ 取消失敗:'+(e&&e.message?e.message:e),6000);
}finally{
if(btn){
const cur=(ST.regs||[]).find(r=>String(r.id)===String(ST._pendingCancelRegId));
const gid=cur&&cur.bundleGroupId?String(cur.bundleGroupId):'';
const isBundle=gid&&(ST.regs||[]).filter(r=>String(r.bundleGroupId||'')===gid).length>1;
btn.disabled=false;
btn.textContent=isBundle?(ST._pendingCancelIsRefund?'確認申請整組退款':'確認取消整組報名'):(ST._pendingCancelIsRefund?'確認申請退費':'確認取消報名');
}
}
}
// ── 繳費流程 ──
let ST_currentPayRegId = '';
let ST_currentPayAmount = 0;
let ST_mergedPayIds = [];
let ST_currentPaymentCard = '';
let ST_currentPaymentInfo = null;
function findMyRegById(regId){ return (ST.regs||[]).find(r=>String(r.id)===String(regId)) || null; }
function paymentAllowedMethodsFromReg(reg){
// 以後端「可用付款方式」勾選旗標為唯一真實來源(與 worker.js _paymentMethodsAllowed 對齊)。
// 不可用「有沒有連結」推斷是否開放,否則主辦填了連結卻沒勾,攤友會選到被後端打回的方式。
const p=(reg&®.paymentProfile)||{};
const a=(reg&®.allowedPaymentMethods) || p.allowedMethods || {};
return {bank:a.bank!==false, linepay:!!a.linepay, card:!!a.card};
}
function bankInfoHtmlFromPayment(reg){
const p=(reg&®.paymentProfile)||{};
const b=(reg&®.bankAccount)||p.bankAccount||{};
const lines=[];
if(p.paymentProfileName) lines.push('本場收款:'+p.paymentProfileName);
if(b.bankName) lines.push('銀行:'+b.bankName);
if(b.branchName) lines.push('分行:'+b.branchName);
if(b.accountName) lines.push('戶名:'+b.accountName);
if(b.accountNumber) lines.push('帳號:'+b.accountNumber);
return lines.length?lines.map(escHtml).join('
'):'此報名尚未取得收款快照,請聯繫主辦';
}
function paymentMethodOptionsForReg(reg){
const a=paymentAllowedMethodsFromReg(reg);
let opts='';
if(a.bank) opts+='';
if(a.linepay) opts+='';
if(a.card) opts+='';
return opts || '';
}
function showPayPage(regId, sesName, amount, opts){
const merged = !!(opts && opts.merged);
if(!merged) ST_mergedPayIds = []; // 單筆繳費:清除合併狀態,避免殘留造成誤判
const reg=findMyRegById(regId) || {};
ST_currentPayRegId = regId;
// 正式回報金額由 Worker 再從資料庫核算;畫面也顯示同一筆未付差額,避免已繳後加價選位卻提示重繳全額。
const outstanding=regOutstandingAmount(reg);
ST_currentPayAmount = merged ? (Number(amount)||0) : (outstanding>0?outstanding:(Number(amount)||0));
ST_currentPaymentInfo = reg.paymentProfile || null;
$('payModalTitle').textContent = merged ? `合併繳費(共 ${ST_mergedPayIds.length} 場)` : '前往繳費';
const creditBalance=Math.max(0,Number(reg.activityCreditBalance||0)||0);
const creditApplied=Math.max(0,Number(reg.activityCreditApplied||0)||0);
const creditCanUse=!merged&&creditBalance>0&&ST_currentPayAmount>0;
$('payModalInfo').innerHTML = `${escHtml(sesName)}
原應繳:NT$ ${Number(reg.total||reg.totalAmount||0).toLocaleString()}${creditApplied>0?`|已扣活動金:NT$ ${creditApplied.toLocaleString()}`:''}
${creditApplied>0?'扣抵後待繳餘額':'目前待繳'}:NT$ ${ST_currentPayAmount.toLocaleString()}
${creditCanUse?`可用活動金:NT$ ${creditBalance.toLocaleString()}
系統會先扣活動金,再只收剩餘金額。
`:''}`;
const bankEl=$('bankInfoDisplay'); if(bankEl) bankEl.innerHTML=bankInfoHtmlFromPayment(reg);
const payLastEl = $('payLastFive');
const methodEl = $('payMethod');
if(payLastEl) payLastEl.value = '';
if(methodEl){
methodEl.innerHTML=paymentMethodOptionsForReg(reg);
methodEl.value = methodEl.querySelector('option')?.value || '';
}
$('payFormArea').style.display='block';
$('paySuccessArea').style.display='none';
if(typeof clearPayFormError==='function') clearPayFormError();
onPayMethodChange();
$('payModal').classList.add('show');
}
async function applyActivityCreditForCurrent(btn){
const reg=findMyRegById(ST_currentPayRegId);if(!reg){toast('找不到報名資料');return;}
if(btn){btn.disabled=true;btn.textContent='活動金扣抵中…';}
try{
const preview=await apiPost({action:'activityCreditCheckout',regId:reg.id,email:getMemberEmail(),phone:getMemberPhone(),preview:true});
if(!(Number(preview.canApply||0)>0)){toast('目前沒有可扣抵的活動金');return;}
if(!await uiConfirm('本次使用活動金 '+Number(preview.canApply).toLocaleString()+' 元\n扣抵後待繳 '+Number(preview.remaining||0).toLocaleString()+' 元\n\n確定使用?'))return;
const r=await apiPost({action:'activityCreditCheckout',regId:reg.id,email:getMemberEmail(),phone:getMemberPhone()});
toast('已扣抵活動金 NT$ '+Number(r.applied||0).toLocaleString(),6000);
await safeGetMyRegs(getMemberEmail(),getMemberPhone());
const updated=findMyRegById(reg.id);
if(r.fullyPaid||!updated||regOutstandingAmount(updated)<=0){$('payModal').classList.remove('show');toast('活動金已全額付清,本筆不需再匯款',7000);}
else showPayPage(updated.id,updated.sessionName||updated.sesName||'場次',regOutstandingAmount(updated));
}catch(e){showPayFormError('活動金扣抵失敗:'+(e.message||e));}
finally{if(btn){btn.disabled=false;btn.textContent='先使用活動金扣抵';}}
}
function getPayLink(method){
const reg=findMyRegById(ST_currentPayRegId)||{};
const p=reg.paymentProfile||{};
if(method==='LINE Pay'){ const x=p.linepay||{}; return x.url||x.linepayUrl||x.linepay_url||x.link||''; }
if(method==='信用卡付款'){ const x=p.card||{}; return x.url||x.cardUrl||x.card_url||x.ecpayUrl||x.link||''; }
const hit=(PAY_METHODS||[]).find(x=>x && x.name===method);
return (hit&&hit.url)||'';
}
function onPayMethodChange(){
const method = $('payMethod')?.value || 'ATM 轉帳';
const isBank = method.includes('ATM') || method.includes('銀行') || method.includes('轉帳') || method.includes('匯款');
const bankBox=$('bankPayBox'); if(bankBox) bankBox.style.display=isBank?'block':'none';
const lastWrap=$('payLastFiveWrap'); if(lastWrap) lastWrap.style.display=isBank?'block':'none';
const btn=$('immediatePayBtn');
if(btn){ btn.style.display = isBank ? 'none' : 'block'; btn.textContent = '立即付款'; }
}
function openExternalPay(){
const method = $('payMethod')?.value || 'ATM 轉帳';
const isBank = method.includes('ATM') || method.includes('銀行') || method.includes('轉帳') || method.includes('匯款');
if(isBank){ toast('請依上方 ATM 資訊完成轉帳,付款後回來按「我已完成付款,回報付款」',5000); return; }
const url = getPayLink(method);
if(!url){ toast('此報名的收款設定尚未提供此付款方式連結,請改用已開放方式或聯繫主辦'); return; }
toast('付款前提醒:完成後請務必截圖付款證明,再回「我的紀錄」按「我已完成付款,回報付款」,並連同截圖傳到官方 LINE。',7000);
window.open(url,'_blank');
}
function showPayFormError(msg){
const el=$('payFormError');
if(el){ el.textContent=msg; el.style.display='block'; try{ el.scrollIntoView({behavior:'smooth',block:'center'}); }catch(e){} }
else { toast('❌ '+msg); }
}
function clearPayFormError(){ const el=$('payFormError'); if(el){ el.style.display='none'; el.textContent=''; } }
async function submitPaymentForm(){
clearPayFormError();
const method = $('payMethod')?.value || 'ATM 轉帳';
const isBank = method.includes('ATM') || method.includes('銀行') || method.includes('轉帳') || method.includes('匯款');
const lastFive = isBank ? ($('payLastFive')?.value.trim() || '') : '';
const amount = ST_currentPayAmount;
if(isBank && !lastFive){ showPayFormError('請填寫帳號末五碼'); return; }
if(!amount || amount<=0){ showPayFormError('金額異常,請聯繫該場次官方 LINE 客服'); return; }
const btn = $('reportPaymentBtn');
if(btn){ btn.disabled=true; btn.textContent='回報中…'; }
try{
const merged = Array.isArray(ST_mergedPayIds) && ST_mergedPayIds.length>1;
const res = merged
// B-06:正式金額只由後端從 DB 取,前端 amount 只能拿來顯示,不再送出。
? await apiPost({action:'submitPaymentBatch',regIds:ST_mergedPayIds,method,lastFive,email:getMemberEmail(),phone:getMemberPhone()})
: await apiPost({action:'submitPayment',regId:ST_currentPayRegId,method,lastFive,email:getMemberEmail(),phone:getMemberPhone()});
if(res.error){ showPayFormError(res.error); return; }
const cardText = res.lineCardText || res.paymentLineCardText || '';
if(!cardText){ showPayFormError('付款卡片產生失敗,請聯繫該場次官方 LINE 客服'); return; }
ST_currentPaymentCard = cardText;
ST_mergedPayIds = [];
showPaymentSuccess(cardText);
const refreshEmail2 = getMemberEmail();
if(refreshEmail2){ ST.memberEmail = refreshEmail2; await safeGetMyRegs(refreshEmail2); }
}catch(e){
// 原本沒有 catch:一旦發生非預期錯誤,畫面會完全沒反應,
// 攤友以為已經回報成功,主辦端卻仍是「未繳費」。
console.error('[submitPaymentForm]', e);
showPayFormError('回報失敗:'+((e&&e.message)?e.message:'請重新整理後再試一次,或截圖此畫面聯繫官方 LINE')+'(狀態仍為未繳費,請務必重新回報)');
}finally{
if(btn){ btn.disabled=false; btn.textContent='我已完成付款,回報付款'; }
}
}
function showPaymentSuccess(cardText){
$('payModalTitle').textContent='付款回報完成';
$('payFormArea').style.display='none';
$('paySuccessArea').style.display='block';
const card=$('paymentCopyCard');
if(card) card.textContent=cardText||'';
}
function copyPaymentCard(){
const text = ST_currentPaymentCard || $('paymentCopyCard')?.textContent || '';
if(!text){ toast('沒有可複製的付款卡片'); return; }
const done=()=>{
toast('已複製付款卡片,請貼到官方 LINE,並一起傳付款截圖');
const b=document.querySelector('.payment-copy-btn');
if(b){ const o=b.getAttribute('data-label')||b.textContent; b.setAttribute('data-label',o); b.textContent='✓ 已複製!請貼到官方 LINE'; b.disabled=true; setTimeout(()=>{ b.textContent=o; b.disabled=false; },2200); }
};
const fallback=()=>{
try{
const ta=document.createElement('textarea');
ta.value=text; ta.setAttribute('readonly','readonly');
ta.style.position='fixed'; ta.style.left='-9999px'; ta.style.top='0';
document.body.appendChild(ta);
ta.focus(); ta.select(); try{ ta.setSelectionRange(0,text.length); }catch(_e){}
let ok=false; try{ ok=document.execCommand('copy'); }catch(_e){}
document.body.removeChild(ta);
if(ok) done(); else prompt('請長按下方文字全選、複製,貼到官方 LINE:', text);
}catch(_e){ prompt('請長按下方文字全選、複製,貼到官方 LINE:', text); }
};
try{
if(navigator.clipboard && navigator.clipboard.writeText){
navigator.clipboard.writeText(text).then(done).catch(fallback);
} else { fallback(); }
}catch(_e){ fallback(); }
}
// ── URL 深連結:信件按鈕 / 查看報名紀錄 / 前往繳費 ──
(async()=>{
const _p = new URLSearchParams(window.location.search);
const isMemberLink = _p.get('member')==='1' || _p.get('page')==='member' || _p.get('view')==='records';
const _payRegId = _p.get('pay') || _p.get('reg') || _p.get('regId') || '';
const _email = (_p.get('email') || _p.get('mail') || '').trim();
if(_payRegId) window._pendingPayRegId = _payRegId;
if(_email){ ST.memberEmail=_email; memberSaveEmail(_email); if($('loginEmail')) $('loginEmail').value=_email; }
if(isMemberLink || _payRegId){
setTimeout(async()=>{
// B-02:原本會用 regId 向公開 API 反查 Email(等於任何人拿到 regId 就能拿到 Email)。
// 已移除。舊信件裡的 ?pay=REG_ID 仍可用:regId 先記著,等使用者用 Email+手機登入後,
// 由 handlePendingPayLink(regs) 在自己的名單裡比對出這筆,再開啟付款/詳情。
let email = _email || ST.memberEmail || memberSafeEmail();
if(email){
await safeLoadMemberPage(email,{navOptions:{noPush:true}});
}else{
navTo('member',{noPush:true});
memberShowLogin();
const el=$('myRegsList');
if(el) el.innerHTML='找不到這筆報名對應的 Email。
請輸入報名 Email 查詢,或截圖聯繫官方 LINE。
';
}
const ml=$('memberLoginArea');
if(ml && ml.style.display!=='none') ml.scrollIntoView({behavior:'smooth',block:'start'});
}, 800);
}
})();
// ── 電腦版滑鼠拖曳橫向滑動 ──
function enableDragScroll(){
const el = document.querySelector('.events-scroll');
if(!el || el.__dragBound) return;
el.__dragBound = true;
let isDown=false, startX, scrollLeft;
el.addEventListener('mousedown', e=>{ isDown=true; startX=e.pageX-el.offsetLeft; scrollLeft=el.scrollLeft; });
el.addEventListener('mouseleave', ()=>{ isDown=false; });
el.addEventListener('mouseup', ()=>{ isDown=false; });
el.addEventListener('mousemove', e=>{
if(!isDown) return;
e.preventDefault();
const x=e.pageX-el.offsetLeft;
el.scrollLeft = scrollLeft - (x-startX)*1.5;
});
}
// ── 前台啟動:一定要自動載入首頁資料 ──
function initFrontApp(){
if(window.__tbFrontInited) return;
window.__tbFrontInited = true;
try{ history.replaceState({page:'home'}, '', location.href); }catch(e){}
clearFrontPageStateForDeepLink();
try{
const saved=null; // V5: 只從 DB 回抓,不從 localStorage 讀取 member 資料
if(saved){
const d=JSON.parse(saved);
if(d&&d.email){
ST.memberEmail=d.email;
ST.member=null;
api({action:'getMember',email:d.email,phone:getMemberPhone(),_t:Date.now()}).then(m=>{ if(m&&!m.error) ST.member=m; }).catch(()=>{});
}
}
}catch(e){}
try{ applySiteConfig(); }catch(e){}
// 深層分享與頁面還原統一交給 bootDbFront,避免同一連結被開兩次或被上次報名頁覆蓋
if(!window.__tbBootDbFrontAutoScheduled && typeof bootDbFront === 'function') {
bootDbFront().catch(e=>{ console.error('bootDbFront error:',e); loadHome(); });
}
}
if(document.readyState==='loading'){
document.addEventListener('DOMContentLoaded', initFrontApp);
}else{
initFrontApp();
}
// ────────────────────────────────────────────────────────────────
// DB-DRIVEN CORE PATCH 20260618:資料庫主導前台,不再靠零散前台狀態
// ────────────────────────────────────────────────────────────────
(function(){
const portalTypeMap = {'市集報名':['市集場次'],'體驗活動':['活動報名','體驗活動'],'通路寄賣':['通路寄賣'],'合作洽詢':['合作申請']};
// 開放狀態判斷唯一來源(與上方 isOpenSession 同一份規則:未設定或報名中/開放中皆為開放)。
// 注意:這裡不可呼叫 isOpenSession,因為下方 window.isOpenSession = openSess 會覆蓋它,
// 互相呼叫會造成無限遞迴、前台卡在載入中。
function openSess(s){ const st=String(s?.status||'').trim(); return !st || st==='報名中' || st==='開放中'; }
function asArr(v){ return Array.isArray(v)?v:[]; }
function uniqueSessions(list){ return [...new Map(asArr(list).filter(Boolean).map(s=>[String(s.id),s])).values()]; }
function saveEmail(email){ try{ localStorage.setItem('doing_market_member_email', email||''); localStorage.removeItem('tb_member'); }catch(e){} }
function readEmail(){ try{ return localStorage.getItem('doing_market_member_email')||''; }catch(e){ return ''; } }
window.isOpenSession = openSess;
window.loadHome = async function(){
const evEl=$('eventsList'); if(evEl) evEl.innerHTML='載入中…
';
try{ const boot = await api({action:'frontBootstrap', tenant:TENANT_ID, _t:Date.now()});
ST.events=asArr(boot.events); ST.sessions=uniqueSessions(boot.sessions).filter(openSess); ST.allSessionsData=ST.sessions;
try{
const publicBundles=await api({action:'getBundlesPublic', tenant:TENANT_ID, _t:Date.now()});
ST.bundles=Array.isArray(publicBundles)?publicBundles:[];
}catch(_bundleErr){
ST.bundles=[];
}
if(boot.tenant){
ST.tenant = boot.tenant;
// 動態更新 LINE_URL
if(boot.tenant.name) document.title = boot.tenant.name + '|報名系統';
if(boot.tenant.lineUrl) LINE_URL = boot.tenant.lineUrl;
// 動態更新銀行帳號與線上付款連結顯示(缺設定不 fallback,顯示未設定)
const bankEl = document.getElementById('bankInfoDisplay');
if(boot.tenant.bankInfo){
const bi = boot.tenant.bankInfo;
if(bankEl){ bankEl.innerHTML = bi.replace(/\n/g,'
'); }
} else if(bankEl){
bankEl.innerHTML = '此主辦尚未設定付款資訊,請聯繫主辦';
}
const payCfg = boot.tenant.paymentConfig || boot.tenant.paymentLinks || {};
const tenantLinePay = payCfg.linePayUrl || payCfg.linePay || payCfg.linepay || payCfg.line_pay_url || '';
const tenantCardPay = payCfg.cardPayUrl || payCfg.creditCardUrl || payCfg.ecpayUrl || payCfg.card || payCfg.card_pay_url || '';
if(tenantLinePay) LINEPAY_LINK = tenantLinePay;
if(tenantCardPay) CARDPAY_LINK = tenantCardPay;
PAY_METHODS = Array.isArray(payCfg.payMethods) ? payCfg.payMethods.filter(m=>m && m.name) : [];
// 動態更新品牌顯示
const logoText = document.querySelector('.logo-text');
if(logoText && boot.tenant.name) logoText.childNodes[0].textContent = boot.tenant.name;
const logoImg = document.querySelector('.logo-img');
if(logoImg && boot.tenant.logoUrl) logoImg.src = boot.tenant.logoUrl;
// infoText / heroImg
const band=$('siteInfoBand'); const strong=band&&band.querySelector('strong');
if(strong && boot.tenant.infoText) strong.textContent=boot.tenant.infoText;
const hero=$('siteHero');
const tenantCover = boot.tenant.coverUrl || boot.tenant.heroImg || '';
if(hero && tenantCover){ hero.style.backgroundImage='url('+tenantCover+')'; hero.style.backgroundSize='cover'; hero.style.backgroundPosition='center'; }
}
renderAnnouncements(asArr(boot.announcements)); renderEvents(); return boot;
}catch(e){ console.error('[frontBootstrap failed]', e); const sessions=await api({action:'getSessions', _t:Date.now()}); ST.allSessionsData=uniqueSessions(sessions).filter(openSess); ST.sessions=ST.allSessionsData; ST.events=[]; ST.bundles=[]; renderEvents(); }
};
window.renderEvents = function(){
// ── 精選主題活動(ST.events 活動層)──
const fl=$('featuredEventsList');
if(fl){
const events=asArr(ST.events);
if(events.length){
fl.innerHTML=events.map(e=>{
const coverHtml=e.cover
?`
`
:``;
const placeholderHtml=``;
return `
${coverHtml}${placeholderHtml}
${esc(e.title||'')}
${e.location?`
📍 ${esc(e.location)}
`:''}
${e.desc?`
${esc(e.desc)}
`:''}
🎪 ${eventSessionCount(e.id)} 個場次
`;
}).join('');
} else {
fl.innerHTML='目前無主題活動
';
}
}
// ── 近期場次(ST.allSessionsData 場次層)──
const el=$('eventsList');
if(el){
const sessions=sortSessionsByUpcoming(uniqueSessions(ST.allSessionsData).filter(openSess));
setActivitySessions('home',sessions);
}
setTimeout(enableDragScroll,100);
};
window.openSessionFromCard=function(ev,sessionId){ if(ev){ev.preventDefault();ev.stopPropagation();} openSession(sessionId); }; window.handleSessionTap=window.openSessionFromCard;
window.openEvent=async function(eventId){ const evt=asArr(ST.events).find(e=>String(e.id)===String(eventId)); const list=$('portalSessionList'); const title=$('portalTitle'); if(title) title.textContent=evt?evt.title:'場次列表'; navTo('portal'); if(list) list.innerHTML='載入中…
'; let data=uniqueSessions(ST.allSessionsData).filter(s=>String(s.eventId||'')===String(eventId)&&openSess(s)); if(!data.length){ data=uniqueSessions(await api({action:'getSessions',eventId,_t:Date.now()})).filter(openSess); ST.allSessionsData=uniqueSessions([...(ST.allSessionsData||[]),...data]); } setActivitySessions('portal',data); };
window.filterCat=async function(type,el){ ST.currentCatFilter=type; document.querySelectorAll('.cat-btn').forEach(b=>b.classList.remove('active')); if(el) el.classList.add('active'); const title=$('portalTitle'); if(title) title.textContent=type; const list=$('portalSessionList'); if(list) list.innerHTML='載入中…
'; navTo('portal'); const allow=portalTypeMap[type]||[]; let data=uniqueSessions(ST.allSessionsData).filter(s=>openSess(s)&&((asArr(s.portals).includes(type))||allow.includes(s.type))); if(!data.length){ data=uniqueSessions(await api({action:'getSessions',portal:type,_t:Date.now()})).filter(openSess); ST.allSessionsData=uniqueSessions([...(ST.allSessionsData||[]),...data]); } setActivitySessions('portal',data); };
// 會員登入與我的紀錄統一由 safeLoadMemberPage 管理;此處不再覆蓋 memberLogin/goMemberPage。
async function bootDbFront(){
// 前台使用固定 30 天的安全登入憑證;憑證不保存手機,頁面載入後向 Worker 重新核對會員。
const savedEmail = readEmail();
const savedMemberToken = memberSafeToken();
if (savedEmail) ST.memberEmail = savedEmail;
if (savedMemberToken) ST.memberToken = savedMemberToken;
// 讀取分享連結;若網址有指定場次/主題,必須優先於上次停留頁面
const deepLink = readFrontDeepLink();
if(deepLink.hasDeepLink) clearFrontPageStateForDeepLink();
// 讀取上次儲存的完整狀態
let savedState = null;
try { savedState = deepLink.hasDeepLink ? null : JSON.parse(localStorage.getItem('doing_market_page_state')||'null'); } catch(e){}
const rawTab = (savedState && savedState.tab) || localStorage.getItem('doing_market_current_tab') || 'home';
const lastTab = deepLink.hasDeepLink ? 'home' : ((rawTab === 'myregs') ? 'member' : rawTab);
// 分享連結:先蓋一層載入畫面,避免使用者看到首頁閃一下才跳到場次
if(deepLink.hasDeepLink) showDeepLinkLoading();
try{ await loadPhotoFrames(); }catch(e){}
try{ await openPhotoFrameByLink(); }catch(e){}
await loadHome();
if(deepLink.sessionId){ await openSession(deepLink.sessionId); hideDeepLinkLoading(); return; }
if(deepLink.eventId){ await openEvent(deepLink.eventId); hideDeepLinkLoading(); return; }
if(deepLink.isMember){ await navToTab('member'); hideDeepLinkLoading(); return; }
hideDeepLinkLoading();
// 還原頁面:非分享連結一律回到單一首頁;避免上次停留的報名表或舊場次列表蓋回首頁。
if(lastTab === 'member' && (savedEmail||savedMemberToken)){
setTimeout(()=>{ navToTab('member'); }, 200);
} else if(lastTab === 'support'){
setTimeout(()=>{ navToTab('support'); }, 200);
} else {
navToTab('home');
}
}
handleDoingLineCallback();
window.__tbBootDbFrontAutoScheduled = true;
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',bootDbFront,{once:true}); else setTimeout(bootDbFront,0);
})();