1import { createHotkerAuthClient } from './my-hotker-auth.js'; 2import { renderCommunityPost } from './community-post-view.js?v=20260908-discovery1'; 3 4const EXPECTED_SITE_ORIGIN = 'https://hotker.ai'; 5const EXPECTED_SUPABASE_ORIGIN = 'https://ihckfmvzkylzuwaixsps.supabase.co'; 6 7const state = { 8 posts: [], 9 editorial: [], 10 nextCursor: null, 11 full: Boolean(new URLSearchParams(window.location.search).get('q') || new URLSearchParams(window.location.search).get('type') || new URLSearchParams(window.location.search).get('product') || new URLSearchParams(window.location.search).get('view') === 'all'), 12 request: 0, 13 errors: new Set(), 14 query: new URLSearchParams(window.location.search).get('q') || '', 15 productId: new URLSearchParams(window.location.search).get('product') || '', 16 postType: new URLSearchParams(window.location.search).get('type') || '' 17}; 18 19function text(value) { 20 return String(value ?? ''); 21} 22 23function escapeHtml(value) { 24 return text(value).replace(/[&<>"']/g, char => ({ 25 '&': '&', 26 '<': '<', 27 '>': '>', 28 '"': '"', 29 "'": ''' 30 }[char])); 31} 32 33function typeLabel(value) { 34 switch (value) { 35 case 'review': return '구매í기'; 36 case 'question': return 'ì§ë¬¸ëµë³'; 37 case 'tip': return '구매í'; 38 case 'discussion': return 'ê°ê²©ì ë³´'; 39 default: return '커뮤ëí°'; 40 } 41} 42 43function writeType(value) { 44 switch (value) { 45 case 'activation': 46 case 'review': 47 return 'review'; 48 case 'price': 49 case 'discussion':
50 return 'discussion'; 51 case 'question': 52 case 'tip': 53 return value; 54 default: 55 return 'review'; 56 } 57} 58 59function matchesPostType(post) { 60 if (!state.postType || state.postType === 'all') return true; 61 if (state.postType === 'activation') { 62 return post.postType === 'review' && post.review?.productType === 'smartphone'; 63 } 64 if (state.postType === 'price') return post.postType === 'discussion'; 65 return post.postType === state.postType; 66} 67 68function setStatus(message, tone = '') { 69 const node = document.getElementById('communityStatus'); 70 if (!node) return; 71 node.className = `community-status${tone ? ` ${tone}` : ''}`; 72 node.textContent = message; 73} 74 75function authClient() { 76 const config = window.HOTKER_CONFIG; 77 const locationUrl = new URL(window.location.href); 78 let supabaseOrigin; 79 try { supabaseOrigin = new URL(config?.SUPABASE_URL).origin; } 80 catch { return null; } 81 const publishableKey = text(config?.SUPABASE_PUBLISHABLE_KEY); 82 const siteOrigin = text(config?.SITE_ORIGIN || EXPECTED_SITE_ORIGIN); 83 if (siteOrigin !== EXPECTED_SITE_ORIGIN || supabaseOrigin !== EXPECTED_SUPABASE_ORIGIN 84 || !/^sb_publishable_[A-Za-z0-9_-]{20,}$/.test(publishableKey)) return null; 85 return createHotkerAuthClient({ 86 supabaseUrl: supabaseOrigin, 87 publishableKey, 88 authRedirectUrl: new URL('/my', locationUrl.origin).href 89 }); 90} 91 92function postMatches(post, query) { 93 if (!matchesPostType(post)) return false; 94 if (state.productId && text(post.productId) !== state.productId) return false; 95 const needle = query.trim().toLowerCase(); 96 if (!needle) return true; 97 return [post.title, post.body, post.productId, post.review?.seller, post.review?.carrier, post.review?.planCondition] 98 .some(value => text(value).toLowerCase().includes(needle)); 99} 100 101function renderPosts(posts) { 102 const list = document.getElementById('communityPostList'); 103 const count = document.getElementById('communityCount'); 104 if (!list) return; 105 const combined = [...new Map([...posts, ...state.editorial].map(post => [post.id, post])).values()] 106 .sort((left, right) => text(right.createdAt).localeCompare(text(left.createdAt))); 107 const visible = combined.filter(post => postMatches(post, state.query)); 108 if (count) count.textContent = `ë¶ë¬ì¨ ê¸ ${visible.length.toLocaleString('k
108o-KR')}ê±´`; 109 const heading = document.getElementById('community-list-title'); 110 if (heading) heading.textContent = (!state.postType || state.postType === 'all') ? (state.full ? 'ì ì²´ ê²ìê¸' : 'ìµê·¼ ê²ìê¸') : `${state.postType === 'activation' ? 'ê°íµí기' : typeLabel(state.postType === 'price' ? 'discussion' : state.postType)} ìµì ê¸`; 111 if (!visible.length) { 112 if (state.errors.size) { 113 if (count) count.textContent = 'ì¡°í íì¸ íì'; 114 list.innerHTML = '<article class="post-card"><h3>ê¸ì 모ë íì¸íì§ ëª»íìµëë¤</h3><p>ì¡°íì ì¤í¨í ê²ìíì´ ììµëë¤. ìë¡ê³ 침 í ë¤ì íì¸í´ 주ì¸ì.</p></article>'; 115 return; 116 } 117 list.innerHTML = '<article class="post-card"><span>ê²ì ê²°ê³¼ ìì</span><h3>ì¡°ê±´ì ë§ë ê¸ì´ ììµëë¤</h3><p>ë¤ë¥¸ 모ë¸ëª , í매ì², ìê¸ì í¤ìëë¡ ê²ìí´ ë³´ì¸ì.</p></article>'; 118 return; 119 } 120 list.innerHTML = visible.map(renderCommunityPost).join(''); 121 const targetId = text(window.location.hash).slice(1); 122 if (/^post_[a-f0-9]{32}$/.test(targetId)) { 123 const target = document.getElementById(targetId); 124 if (target?.tagName === 'DETAILS') target.open = true; 125 } 126} 127 128function updateLoadStatus() { 129 setStatus(state.errors.size ? 'ê¸ì ë¶ë¬ì¤ì§ 못íìµëë¤. ë¤ì ìëí´ ì£¼ì¸ì.' : 130 'ê³µê° êµ¬ë§¤í기ì ì´ìí ìë´ì ëë¤. ê²ìì ê³µê° ê¸ ì ì²´ì ì ì©ë©ëë¤. ì´ìí ìë´ë ê³ ê° íê¸°ê° ìëëë¤.', 131 state.errors.size ? 'error' : ''); 132} 133 134async function loadPosts(more = false) { 135 const request = ++state.request; 136 const button = document.getElementById('communityLoadMore'); 137 if (button) button.disabled = true; 138 const params = new URLSearchParams({ limit: state.full ? '20' : '4' }); 139 const type = state.postType === 'price' ? 'discussion' : state.postType === 'all' ? '' : state.postType; 140 if (type) params.set('type', type); 141 if (state.query) params.set('q', state.query); 142 if (state.productId) params.set('product', state.productId); 143 if (more && state.nextCursor) params.set('cursor', state.nextCursor); 144 try { 145 const response = await fetch('/api/community/feed?' + params, { headers: { accept: 'application/json' } }); 146 if (!response.ok) throw new Error('feed_unavailable'); 147 const payload = await response.json(); 148 if (!Array.isArray(payload.items) || payload.items.length > Number(params.get('limit')) 149 || (payload.nextCursor != null && typeof payload.nextCursor !== 'string')) throw new Error('invalid_feed'); 150 if (request !== state.request) return; 151 state.posts = more ? [...state.posts, ...payload.items] : payload.items; 152 state.nextCursor = payload.nextCursor || null; 153 state.errors.clear(); 154 renderPosts(state.posts); 155 if (button) { 156 button.hidden = state.full && !state.nextCursor; 157 button.textContent = state.full ? 'ì´ì ê¸ 20ê° ë 보기' : 'ì ì²´ ê²ìê¸ ë³´ê¸° â'; 158 } 159 } catch { 160 if (request !== state.request) return; 161 state.errors.add('feed'); 162 if (!more) { state.posts = []; renderPosts([]); } 163 if (button) { button.hidden = false; button.textContent = 'ë¤ì ë¶ë¬ì¤ê¸°'; } 164 } finally { 165 if (request === state.request) { 166 if (button) button.disabled = false; 167 updateLoadStatus(); 168 } 169 } 170} 171 172async function loadLinkedPost() { 173 const id = text(window.location.hash).slice(1); 174 if (!/^post_[a-f0-9]{32}$/.test(id)) return; 175 const node = document.getElementById('communityLinkedPost'); 176 if (!node) return; 177 try { 178 const response = await fetch('/api/community/feed?' + new URLSearchParams({ id, limit: '4' })); 179 if (!response.ok) throw new Error(); 180 const payload = await response.json(); 181 const post = payload.items?.find(item => item.id === id); 182 node.hidden = false; 183 node.innerHTML = post ? '<h2>ì íí ê²ìê¸</h2>' + renderCommunityPost(post).replace('id="' + id + '"', 'id="linked-' + id + '"') 184 : '<p>ê³µê°ëì§ ììê±°ë ìì ë ê¸ì ëë¤.</p>'; 185 const detail = node.querySelector('details'); 186 if (detail) { detail.open = true; node.scrollIntoView({ block: 'start' }); } 187 } catch { node.hidden = false; node.textContent = 'ì íí ê¸ì ë¶ë¬ì¤ì§ 못íìµëë¤. ìë¡ê³ ì¹¨í´ ì£¼ì¸ì.'; } 188} 189 190// Preserve authenticated member-only discovery, without mixing it into public pagination. 191async function loadMemberPosts() { 192 const panel = document.getElementById('communityMemberPosts'); 193 const auth = authClient(); 194 if (!auth || !panel) return; 195 try { 196 const token = await auth.accessToken(); 197 if (!token) return; 198 const response = await fetch('/api/community/posts?limit=50', { 199 headers: { accept: 'application/json', authorization: 'Bearer ' + token } 200 }); 201 if (!response.ok) throw new Error(); 202 const payload = await response.json(); 203 const posts = (payload.items || []).filter(post => postMatches(post, state.query) 204 && post.postType !== 'review' && !post.editorial); 205 panel.hidden = !posts.length; 206 panel.innerHTML = '<h2>íì ê²ìê¸</h2><p>ë¡ê·¸ì¸í íììê² ì ê³µëë ìµê·¼ ê¸ì ëë¤.</p>' + posts.map(renderCommunityPost).join(''); 207 } catch { panel.hidden = false; panel.textContent = 'íì ê¸ ì¡°íì ì¤í¨íìµëë¤. ê³µê° ê¸ì ê³ì ì½ì ì ììµëë¤.'; } 208} 209function writeHref(type = state.postType) { 210 if (state.productId) return `/my#product=${encodeURIComponent(state.productId)}`; 211 const params = new URLSearchParams({ community: '1', type: writeType(type) }); 212 return `/my#${params.toString()}`; 213} 214 215function writeLabel(type = state.postType) { 216 switch (writeType(type)) { 217 case 'question': return 'ì§ë¬¸ ìì±í기'; 218 case 'tip': return '구매í ê³µì í기'; 219 case 'discussion': return 'ê°ê²©ì ë³´ ì ë³´í기'; 220 default: return state.productId ? 'ì´ ìí í기 ìì±í기' : '구매í기 ìì±í기'; 221 } 222} 223 224function bindSearch() { 225 const input = document.getElementById('communityQuery'); 226 const form = document.getElementById('communitySearch'); 227 if (input) input.value = state.query; 228 form?.addEventListener('submit', async event => { 229 event.preventDefault(); 230 state.query = input?.value || ''; 231 state.full = true; 232 state.nextCursor = null; 233 const url = new URL(window.location.href); 234 if (state.query) url.searchParams.set('q', state.query); 235 else url.searchParams.delete('q'); 236 url.searchParams.set('view', 'all'); 237 window.history.replaceState(null, '', url); 238 await loadPosts(); 239 await loadMemberPosts(); 240 }); 241} 242 243function bindWriteLinks() { 244 const links = [ 245 document.getElementById('communityWriteLink'), 246 ...document.querySelectorAll('[data-community-write-link]')
247 ].filter(Boolean); 248 for (const link of links) { 249 const type = link.id === 'communityWriteLink' 250 ? (state.productId ? 'review' : !state.postType || state.postType === 'all' ? 'question' : state.postType) 251 : link.dataset?.communityType || state.postType; 252 link.href = writeHref(type); 253 if (link.id === 'communityWriteLink') link.textContent = writeLabel(type); 254 } 255} 256function bindTabs() { 257 const current = state.postType === 'all' ? '' : state.postType === 'price' ? 'discussion' : state.postType; 258 for (const tab of document.querySelectorAll('.community-tabs a')) { 259 if (new URL(tab.href).pathname === '/community/hotdeals') continue; 260 const type = new URL(tab.href).searchParams.get('type') || ''; 261 tab.classList.toggle('is-active', type === current); 262 if (type === current) tab.setAttribute?.('aria-current', 'page'); 263 } 264} 265bindSearch(); 266bindWriteLinks(); 267bindTabs(); 268document.getElementById('communityLoadMore')?.addEventListener('click', async () => { 269 const more = state.full && Boolean(state.nextCursor); 270 state.full = true; 271 const url = new URL(window.location.href); 272 url.searchParams.set('view', 'all'); 273 window.history.replaceState(null, '', url); 274 await loadPosts(more); 275}); 276loadPosts(); 277loadLinkedPost(); 278loadMemberPosts();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.