PageSourceSearch

https://hotker.ai/community.js?v=20260908-discovery1

js hotker.ai collected 2026-10-05 14:20:51 UTC 11,864 bytes, 278 lines download raw bytes

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    '&': '&amp;',
26    '<': '&lt;',
27    '>': '&gt;',
28    '"': '&quot;',
29    "'": '&#39;'
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.