PageSourceSearch

https://k-chicken-sommelier.vercel.app/app.js

js k-chicken-sommelier.vercel.app collected 2026-10-03 07:10:01 UTC 66,943 bytes, 1,806 lines download raw bytes

1// K-Chicken Sommelier - App Logic (Updated)
2console.log("Start of app.js execution");
3
4// ========== STATE MANAGEMENT ==========
5const state = {
6    currentPage: 'home',
7    currentQuestion: 0,
8    answers: {},
9    userProfile: null,
10    matches: [],
11    isPremium: false,
12    history: [],
13    brandDirectory: [],
14    brandSheetMeta: null,
15    homeStats: null,
16    nearbyStores: [],
17    nearbyLocation: null,
18    nearbyMap: null,
19    nearbyMarkers: []
20};
21
22const BRAND_SHEET_ID = '1CGhr6ETMKV3VTC_62Y-8hRgqt9KQQ-VXSnBQsdTkTnM';
23const BRAND_SHEET_JSON_URL = `https://opensheet.elk.sh/${BRAND_SHEET_ID}/1`;
24const BRAND_SHEET_CSV_URL = `https://docs.google.com/spreadsheets/d/${BRAND_SHEET_ID}/gviz/tq?tqx=out:csv`;
25const BRAND_SHEET_CACHE_KEY = 'kcs-brand-sheet-cache-v1';
26const NEARBY_RADIUS_METERS = 10000;
27const CHICKEN_NAME_REGEX = '치킨|Chicken|Kyochon|BHC|BBQ|교촌|굽네|네네|푸라닭|처갓집|페리카나|멕시카나|지코바|호식이|60계|노랑통닭|자담|또래오래|KFC';
28
29const BRAND_MATCH_ALIASES = {
30    BHC: ['비에이치씨(BHC)', '비에이치씨'],
31    BBQ: ['BBQ치킨', 'BBQ 치킨앤비어'],
32    '60계치킨': ['60계'],
33    푸라닭: ['푸라닭치킨'],
34    '호식이두마리치킨': ['호식이 두마리치킨']
35};
36
37// ========== INITIALIZATION ==========
38document.addEventListener('DOMContentLoaded', () => {
39    loadState();
40    initNavigation();
41    initQuiz();
42    initPremium();
43    initSettings();
44    initFAQ();
45    initLanguages();
46    initShare();
47    initNearbyStores();
48    initStats();      // New
49    initBrandDirectory(); // New
50    initGoogleLogin(); // New
51    showPage('home');
52});
53
54// ========== NEW FEATURES ==========
55function initStats() {
56    const fallbackMenus = MENU_ITEMS.length;
57    const fallbackBrands = new Set(MENU_ITEMS.map(item => item.brand)).size;
58    const totalMenus = Number.isFinite(state.homeStats?.totalMenus) ? state.homeStats.totalMenus : fallbackMenus;
59    const uniqueBrands = Number.isFinite(state.homeStats?.brandCount) ? state.homeStats.brandCount : fallbackBrands;
60    const sourceDate = state.brandSheetMeta?.fetchedAt
61        ? new Date(state.brandSheetMeta.fetchedAt).toISOString().split('T')[0]
62        : new Date().toISOString().split('T')[0];
63    const sourceLabel = state.homeStats?.fromSheet ? {
64        ko: '구글 시트',
65        en: 'Google Sheet',
66        zh: 'Google Sheets',
67        ja: 'Google Sheets'
68    } : {
69        ko: '로컬 데이터',
70        en: 'Local data',
71        zh: '本地数据',
72        ja: 'ローカルデータ'
73    };
74    const statsEl = document.getElementById('data-stats');
75    if (statsEl) {
76        const lang = state.language || 'en';
77
78        const stats = {
79            ko: `현재 <strong>${uniqueBrands}개 브랜드</strong>, <strong>${totalMenus}개 메뉴</strong> 분석 완료 📊 <span style="font-size: 11px; color: #999; margin-left: 5px;">(${sourceLabel.ko}: ${sourceDate})</span>`,
80            en: `Analyzing <strong>${uniqueBrands} Brands</strong> & <strong>${totalMenus} Menus</strong> 📊 <span style="font-size: 11px; color: #999; margin-left: 5px;">(${sourceLabel.en}: ${sourceDate})</span>`,
81            zh: `正在分析 <strong>${uniqueBrands} 个品牌</strong> 和 <strong>${totalMenus} 个菜单</strong> 📊 <span style="font-size: 11px; color: #999; margin-left: 5px;">(${sourceLabel.zh}: ${sourceDate})</span>`,
82            ja: `現在 <strong>${uniqueBrands}つのブランド</strong>、<strong>${totalMenus}つのメニュー</strong>を分析中 📊 <span style="font-size: 11px; color: #999; margin-left: 5px;">(${sourceLabel.ja}: ${sourceDate})</span>`
83        };
84
85        statsEl.innerHTML = stats[lang] || stats.en;
86    }
87}
88
89function normalizeBrandKey(value) {
90    return String(value || '')
91        .toLowerCase()
92        .replace(/[()]/g, '')
93        .replace(/\s+/g, '')
94        .replace(/[^a-z0-9가-힣]/g, '');
95}
96
97function parseStoreCount(value) {
98    const digits = String(value || '').replace(/[^0-9]/g, '');
99    if (!digits) return null;
100    const parsed = parseInt(digits, 10);
101    return Number.isFinite(parsed) ? parsed : null;
102}
103
104function parseMenuCount(value) {
105    const digits = String(value || '').replace(/[^0-9]/g, '');
106    if (!digits) return null;
107    const parsed = parseInt(digits, 10);
108    return Number.isFinite(parsed) ? parsed : null;
109}
110
111function parseCsvRows(text) {
112    const rows = [];
113    let row = [];
114    let current = '';
115    let inQuotes = false;
116
117    for (let i = 0; i < text.length; i++) {
118        const char = text[i];
119
120        if (char === '"') {
121            if (inQuotes && text[i + 1] === '"') {
122                current += '"';
123                i++;
124            } else {
125                inQuotes = !inQuotes;
126            }
127            continue;
128        }
129
130        if (char === ',' && !inQuotes) {
131            row.push(current);
132            current = '';
133            continue;
134        }
135
136        if ((char === '\n' || char === '\r') && !inQuotes) {
137            if (char === '\r' && text[i + 1] === '\n') i++;
138            row.push(current);
139            rows.push(row);
140            row = [];
141            current = '';
142            continue;
143        }
144
145        current += char;
146    }
147
148    if (current.length || row.length) {
149        row.push(current);
150        rows.push(row);
151    }
152
153    return rows.filter(r => r.some(cell => String(cell).trim().length > 0));
154}
155
156function mapSheetRows(rawRows) {
157    return rawRows
158        .map(row => {
159            const brandName = String(row.brandName || '').trim();
160            const category = String(row.category || '').trim();
161            return {
162                brandName,
163                category,
164                storeCount: parseStoreCount(row.storeCount),
165                menuCount: parseMenuCount(row.menuCount),
166                normalized: normalizeBrandKey(brandName)
167            };
168        })
169        .filter(row => row.brandName);
170}
171
172async function fetchBrandSheetFromJson() {
173    const response = await fetch(`${BRAND_SHEET_JSON_URL}?_ts=${Date.now()}`, {
174        cache: 'no-store'
175    });
176    if (!response.ok) throw new Error(`Brand sheet JSON fetch failed: ${response.status}`);
177
178    const jsonRows = await response.json();
179    const mappedRows = mapSheetRows(
180        jsonRows.map(row => ({
181            brandName: row['브랜드명'],
182            category: row['분류'],
183            storeCount: row['매장 수 (추정)'],
184            menuCount: row['메뉴 수'] || row['메뉴수'] || row['총 메뉴 수'] || row['총 메뉴수']
185        }))
186    );
187
188    return { rows: mappedRows, source: 'json' };
189}
190
191async function fetchBrandSheetFromCsv() {
192    const response = await fetch(`${BRAND_SHEET_CSV_URL}&_ts=${Date.now()}`, {
193        cache: 'no-store'
194    });
195    if (!response.ok) throw new Error(`Brand sheet CSV fetch failed: ${response.status}`);
196
197    const csvText = await response.text();
198    const rows = parseCsvRows(csvText);
199    if (!rows.length) throw new Error('Brand sheet CSV is empty.');
200
201    const headers = rows[0].map(header => String(header || '').replace(/^\uFEFF/, '').trim());
202    const brandIndex = headers.findIndex(header => header.includes('브랜드명'));
203    const categoryIndex = headers.findIndex(header => header.includes('분류'));
204    const storeIndex = headers.findIndex(header => header.includes('매장'));
205    const menuIndex = headers.findIndex(header => header.includes('메뉴'));
206
207    if (brandIndex === -1 || categoryIndex === -1 || storeIndex === -1) {
208        throw new Error('Brand sheet CSV header mismatch.');
209    }
210
211    const mappedRows = mapSheetRows(rows.slice(1).map(row => ({
212        brandName: row[brandIndex],
213        category: row[categoryIndex],
214        storeCount: row[storeIndex],
215        menuCount: menuIndex === -1 ? null : row[menuIndex]
216    })));
217
218    return { rows: mappedRows, source: 'csv' };
219}
220
221function loadBrandSheetCache() {
222    try {
223        const raw = localStorage.getItem(BRAND_SHEET_CACHE_KEY);
224        if (!raw) return null;
225
226        const parsed = JSON.parse(raw);
227        if (!parsed || !Array.isArray(parsed.rows) || !parsed.fetchedAt) return null;
228
229        return parsed;
230    } catch (error) {
231        console.warn('Failed to parse brand sheet cache:', error);
232        return null;
233    }
234}
235
236function saveBrandSheetCache(rows, source) {
237    try {
238        localStorage.setItem(BRAND_SHEET_CACHE_KEY, JSON.stringify({
239            rows,
240            source,
241            fetchedAt: Date.now()
242        }));
243    } catch (error) {
244        console.warn('Failed to save brand sheet cache:', error);
245    }
246}
247
248async function loadBrandSheetRows() {
249    const cached = loadBrandSheetCache();
250
251    try {
252        const jsonResult = await fetchBrandSheetFromJson();
253        saveBrandSheetCache(jsonResult.rows, jsonResult.source);
254        return { rows: jsonResult.rows, source: jsonResult.source, fetchedAt: Date.now() };
255    } catch (jsonError) {
256        console.warn('Brand sheet JSON load failed, trying CSV:', jsonError);
257    }
258
259    try {
260        const csvResult = await fetchBrandSheetFromCsv();
261        saveBrandSheetCache(csvResult.rows, csvResult.source);
262        return { rows: csvResult.rows, source: csvResult.source, fetchedAt: Date.now() };
263    } catch (csvError) {
264        console.warn('Brand sheet CSV load failed:', csvError);
265    }
266
267    if (cached) {
268        return { rows: cached.rows, source: 'stale-cache', fetchedAt: cached.fetchedAt };
269    }
270
271    throw new Error('Failed to load brand sheet data.');
272}
273
274function collectServiceBrands() {
275    const byBrand = new Map();
276
277    MENU_ITEMS.forEach(item => {
278        if (!byBrand.has(item.brand)) {
279            byBrand.set(item.brand, {
280                brand: item.brand,
281                menuCount: 0,
282                brandImage: item.brandImage || '',
283                website: item.website || '#'
284            });
285        }
286
287        byBrand.get(item.brand).menuCount += 1;
288    });
289
290    return Array.from(byBrand.values());
291}
292
293function findSheetRowForBrand(brandName, sheetRows) {
294    const primaryKey = normalizeBrandKey(brandName);
295    const aliasKeys = (BRAND_MATCH_ALIASES[brandName] || []).map(normalizeBrandKey);
296    const keysToMatch = [primaryKey, ...aliasKeys].filter(Boolean);
297
298    const candidates = sheetRows.filter(row => {
299        const rowKey = row.normalized;
300        if (!rowKey || rowKey.length < 3) return false;
301
302        return keysToMatch.some(key => {
303            if (!key || key.length < 3) return false;
304            return rowKey === key || rowKey.includes(key) || key.includes(rowKey);
305        });
306    });
307
308    if (!candidates.length) return null;
309
310    return candidates.sort((a, b) => (b.storeCount || -1) - (a.storeCount || -1))[0];
311}
312
313function buildBrandDirectoryRecords(sheetRows) {
314    return collectServiceBrands()
315        .map(serviceBrand => {
316            const matched = findSheetRowForBrand(serviceBrand.brand, sheetRows);
317            return {
318                ...serviceBrand,
319                category: matched?.category || null,
320                storeCount: matched?.storeCount ?? null,
321                sourceBrandName: matched?.brandName || null
322            };
323        })
324        .sort((a, b) => (b.storeCount || -1) - (a.storeCount || -1) || a.brand.localeCompare(b.brand, 'ko'));
325}
326
327function computeHomeStatsFromSheet(sheetRows) {
328    const uniqueSheetBrandKeys = new Set(
329        sheetRows.map(row => row.normalized).filter(Boolean)
330    );
331    const serviceBrands = collectServiceBrands();
332    const matchedServiceBrands = serviceBrands.filter(serviceBrand => (
333        Boolean(findSheetRowForBrand(serviceBrand.brand, sheetRows))
334    ));
335
336    const sheetBrandCount = uniqueSheetBrandKeys.size;
337    const menusFromMatchedBrands = matchedServiceBrands.reduce((sum, record) => sum + record.menuCount, 0);
338    const hasExplicitMenuColumn = sheetRows.some(row => Number.isFinite(row.menuCount));
339    const menusFromSheetColumn = sheetRows.reduce((sum, row) => (
340        sum + (Number.isFinite(row.menuCount) ? row.menuCount : 0)
341    ), 0);
342
343    return {
344        brandCount: sheetBrandCount || matchedServiceBrands.length,
345        totalMenus: hasExplicitMenuColumn ? menusFromSheetColumn : menusFromMatchedBrands,
346        fromSheet: true
347    };
348}
349
350function translateSheetCategory(category, lang) {
351    const labels = {
352        대형: { en: 'Major', ko: '대형', zh: '大型', ja: '大手' },
353        중형: { en: 'Mid-size', ko: '중형', zh: '中型', ja: '中規模' },
354        소형: { en: 'Small', ko: '소형', zh: '小型', ja: '小規模' },
355        지역구: { en: 'Regional', ko: '지역구', zh: '地区型', ja: '地域密着' }
356    };
357
358    if (!category) return null;
359    return labels[category]?.[lang] || category;
360}
361
362function formatStoreCount(storeCount, lang) {
363    if (!Number.isFinite(storeCount)) {
364        return (lang === 'ko') ? '정보 없음' :
365            (lang === 'zh') ? '暂无' :
366                (lang === 'ja') ? 'データなし' : 'N/A';
367    }
368
369    const formatted = storeCount.toLocaleString();
370    return (lang === 'ko') ? `${formatted}개` :
371        (lang === 'zh') ? `${formatted}å®¶` :
372            (lang === 'ja') ? `${formatted}店` : formatted;
373}
374
375function renderBrandDirectory() {
376    const container = document.getElementById('brand-directory-list');
377    const sourceEl = document.getElementById('brand-directory-source');
378    if (!container) return;
379
380    const strings = getStrings();
381    const lang = state.language || 'en';
382
383    if (!state.brandDirectory.length) {
384        container.innerHTML = `<div class="brand-directory-placeholder">${strings.brandLoading || 'Loading Google Sheets data...'}</div>`;
385    } else {
386        container.innerHTML = state.brandDirectory.map(record => {
387            const categoryText = translateSheetCategory(record.category, lang) || (strings.brandNotFound || 'No matched data');
388            const storeCountText = formatStoreCount(record.storeCount, lang);
389
390            return `
391        <article class="brand-directory-card">
392          <div class="brand-directory-top">
393            ${record.brandImage ? `<img class="brand-directory-logo" src="${record.brandImage}" alt="${record.brand} logo" loading="lazy" decoding="async" referrerpolicy="no-referrer">` : ''}
394            <div class="brand-directory-name">${record.brand}</div>
395          </div>
396          <div class="brand-directory-meta"><span>${strings.brandCategoryLabel || 'Category'}</span><strong>${categoryText}</strong></div>
397          <div class="brand-directory-meta"><span>${strings.brandStoreLabel || 'Stores'}</span><strong>${storeCountText}</strong></div>
398          <div class="brand-directory-meta"><span>${strings.brandMenuLabel || 'Menus'}</span><strong>${record.menuCount}</strong></div>
399        </article>
400      `;
401        }).join('');
402    }
403
404    if (sourceEl) {
405        if (!state.brandSheetMeta?.fetchedAt) {
406            sourceEl.textContent = '';
407        } else {
408            const dateText = new Date(state.brandSheetMeta.fetchedAt).toLocaleDateString(lang === 'ko' ? 'ko-KR' : 'en-US');
409            sourceEl.textContent = `${strings.brandSourcePrefix || 'Source'}: ${strings.brandSourceGoogleSheet || 'Google Sheets'} · ${dateText}`;
410        }
411    }
412}
413
414function initBrandDirectory() {
415    renderBrandDirectory();
416
417    loadBrandSheetRows()
418        .then(result => {
419            state.brandDirectory = buildBrandDirectoryRecords(result.rows);
420            state.homeStats = computeHomeStatsFromSheet(result.rows);
421            state.brandSheetMeta = {
422                source: result.source,
423                fetchedAt: result.fetchedAt
424            };
425            renderBrandDirectory();
426            initStats();
427        })
428        .catch(error => {
429            console.error('Failed to initialize brand directory:', error);
430            state.brandDirectory = buildBrandDirectoryRecords([]);
431            state.brandSheetMeta = null;
432            state.homeStats = null;
433            renderBrandDirectory();
434            initStats();
435        });
436}
437
438function initGoogleLogin() {
439    const btn = document.getElementById('google-login-btn');
440    if (btn) {
441        btn.addEventListener('click', () => {
442            // Mock login
443            const isKo = state.language === 'ko';
444            alert(isKo
445                ? "구글 로그인 성공! 더 많은 정보를 무료로 확인하세요."
446                : "Logged in with Google! Enjoy free access to extended insights."
447            );
448            btn.innerHTML = isKo ? "로그인됨 (Google)" : "Logged In (Google)";
449            btn.disabled = true;
450            btn.style.opacity = "0.7";
451        });
452    }
453}
454
455// ========== NAVIGATION ==========
456function showPage(pageName) {
457    // Hide all pages
458    document.querySelectorAll('.page').forEach(page => {
459        page.classList.remove('active');
460    });
461
462    // Show target page
463    const targetPage = document.getElementById(`page-${pageName}`);
464    if (targetPage) {
465        targetPage.classList.add('active');
466        state.currentPage = pageName;
467
468        // Scroll to top
469        window.scrollTo(0, 0);
470
471        // Page-specific initialization
472        if (pageName === 'quiz') {
473            renderQuizQuestion();
474        } else if (pageName === 'results') {
475            renderResults();
476        } else if (pageName === 'settings') {
477            renderSettings();
478        }
479    }
480}
481
482function initNavigation() {
483    // Navigation buttons
484    document.querySelectorAll('[data-navigate]').forEach(btn => {
485        btn.addEventListener('click', () => {
486            const target = btn.dataset.navigate;
487            showPage(target);
488        });
489    });
490
491    // Start quiz button
492    document.querySelectorAll('[data-action="start-quiz"]').forEach(btn => {
493        btn.addEventListener('click', () => {
494            state.currentQuestion = 0;
495            state.answers = {};
496            showPage('quiz');
497        });
498    });
499}
500
501// ========== QUIZ LOGIC ==========
502function initQuiz() {
503    // Next button
504    const nextBtn = document.getElementById('quiz-next');
505    if (nextBtn) {
506        nextBtn.addEventListener('click', handleQuizNext);
507    }
508
509    // Skip button
510    const skipBtn = document.getElementById('quiz-skip');
511    if (skipBtn) {
512        skipBtn.addEventListener('click', handleQuizSkip);
513    }
514
515    // Back button
516    const backBtn = document.getElementById('quiz-back');
517    if (backBtn) {
518        backBtn.addEventListener('click', handleQuizBack);
519    }
520}
521
522function getAnsweredQuestionIds(answers) {
523    return QUIZ_QUESTIONS
524        .map(question => question.id)
525        .filter(id => answers[id] !== undefined && answers[id] !== null && answers[id] !== '');
526}
527
528function getProgressText(questionIndex, totalQuestions, lang) {
529    const current = questionIndex + 1;
530    if (lang === 'ko') return `${current} / ${totalQuestions} 문항`;
531    if (lang === 'zh') return `第 ${current} / ${totalQuestions} 题`;
532    if (lang === 'ja') return `${current} / ${totalQuestions} 問`;
533    return `Question ${current} of ${totalQuestions}`;
534}
535
536function renderQuizQuestion() {
537    const question = QUIZ_QUESTIONS[state.currentQuestion];
538    if (!question) return;
539
540    const progressFill = document.querySelector('.progress-fill');
541    const progressText = document.querySelector('.progress-text');
542    const lang = state.language || 'en';
543
544    // Update progress
545    if (progressText) {
546        progressText.textContent = getProgressText(state.currentQuestion, QUIZ_QUESTIONS.length, lang);
547    }
548    if (progressFill) {
549        const progress = ((state.currentQuestion + 1) / QUIZ_QUESTIONS.length) * 100;
550        progressFill.style.width = `${progress}%`;
551    }
552
553    // Get translations
554    const qTrans = QUIZ_TRANSLATIONS[question.id];
555    const titleText = qTrans?.title[lang] || question.title;
556
557    // Update question title
558    const titleEl = document.getElementById('quiz-title');
559    if (titleEl) {
560        titleEl.textContent = titleText;
561    }
562
563    // Render options
564    const optionsContainer = document.getElementById('quiz-options');
565    if (optionsContainer) {
566        // Different rendering based on type
567        if (question.type === 'scale' || question.type === 'filter') {
568            renderSingleSelectOptions(optionsContainer, question);
569        } else if (question.type === 'tags') {
570            renderMultiSelectOptions(optionsContainer, question);
571        }
572    }
573
574    // Update button states
575    updateQuizButtons();
576    renderLiveRecommendation();
577}
578
579function renderSingleSelectOptions(container, question) {
580    const lang = state.language || 'en';
581    const qTrans = QUIZ_TRANSLATIONS[question.id];
582
583    container.innerHTML = question.options.map((option, index) => {
584        const isSelected = state.answers[question.id] === option.value;
585        // Try to get translation, fallback to option.label
586        const translatedLabel = qTrans?.options?.[option.value]?.[lang] || option.label;
587
588        return `
589      <button class="quiz-option ${isSelected ? 'selected' : ''}" 
590              data-type="single"
591              data-question-id="${question.id}"
592              data-value="${option.value}">
593        ${translatedLabel}
594      </button>
595    `;
596    }).join('');
597
598    attachOptionListeners(container);
599}
600
601function renderMultiSelectOptions(container, question) {
602    const lang = state.language || 'en';
603    const qTrans = QUIZ_TRANSLATIONS[question.id];
604
605    container.innerHTML = question.options.map((option, index) => {
606        const currentAnswers = state.answers[question.id] || [];
607        const isSelected = currentAnswers.includes(option.value);
608        // Try to get translation, fallback to option.label
609        const translatedLabel = qTrans?.options?.[option.value]?.[lang] || option.label;
610
611        return `
612      <button class="quiz-option ${isSelected ? 'selected' : ''}" 
613              data-type="multi"
614              data-question-id="${question.id}"
615              data-value="${option.value}">
616        ${translatedLabel}
617      </button>
618    `;
619    }).join('');
620
621    attachOptionListeners(container);
622}
623
624function attachOptionListeners(container) {
625    container.querySelectorAll('.quiz-option').forEach(btn => {
626        btn.addEventListener('click', () => {
627            const qId = btn.dataset.questionId;
628            const val = btn.dataset.value;
629            const type = btn.dataset.type;
630
631            if (type === 'single') {
632                // Parse int if it looks like a number (for 1-5 scales)
633                const parsedVal = isNaN(val) ? val : parseInt(val, 10);
634                handleOptionSelect(qId, parsedVal);
635            } else {
636                handleMultiSelect(qId, val);
637            }
638        });
639    });
640}
641
642function handleOptionSelect(questionId, value) {
643    state.answers[questionId] = value;
644    // Re-render to update UI selection state
645    renderQuizQuestion();
646}
647
648function handleMultiSelect(questionId, value) {
649    const current = state.answers[questionId] || [];
650    const idx = current.indexOf(value);
651
652    if (idx >= 0) {
653        // Remove
654        current.splice(idx, 1);
655    } else {
656        // Add
657        current.push(value);
658    }
659    state.answers[questionId] = current;
660    renderQuizQuestion();
661}
662
663function updateQuizButtons() {
664    const question = QUIZ_QUESTIONS[state.currentQuestion];
665    const nextBtn = document.getElementById('quiz-next');
666
667    let hasAnswer = false;
668    if (question.type === 'tags') {
669        // For tags, maybe allow empty? Or require at least one? Let's allow empty or require 1.
670        // Let's require at least one for better results.
671        hasAnswer = state.answers[question.id] && state.answers[question.id].length > 0;
672    } else {
673        hasAnswer = state.answers[question.id] !== undefined;
674    }
675
676    if (nextBtn) {
677        nextBtn.disabled = !hasAnswer;
678
679        // Update button text for last question
680        const lang = state.language || 'en';
681        const strings = UI_STRINGS[lang] || UI_STRINGS['en'];
682
683        if (state.currentQuestion === QUIZ_QUESTIONS.length - 1) {
684            nextBtn.textContent = (lang === 'ko' ? '결과 보기' : (lang === 'zh' ? '查看结果' : (lang === 'ja' ? '結果を見る' : 'Get Matches')));
685        } else {
686            nextBtn.textContent = strings.nextBtn;
687        }
688    }
689}
690
691function handleQuizNext() {
692    if (state.currentQuestion < QUIZ_QUESTIONS.length - 1) {
693        state.currentQuestion++;
694        renderQuizQuestion();
695    } else {
696        // Calculate results and show
697        calculateResults();
698        showPage('results');
699    }
700}
701
702function handleQuizSkip() {
703    if (state.currentQuestion < QUIZ_QUESTIONS.length - 1) {
704        state.currentQuestion++;
705        renderQuizQuestion();
706    } else {
707        calculateResults();
708        showPage('results');
709    }
710}
711
712function handleQuizBack() {
713    if (state.currentQuestion > 0) {
714        state.currentQuestion--;
715        renderQuizQuestion();
716    } else {
717        showPage('home');
718    }
719}
720
721// ========== SCORING ALGORITHM ==========
722const THIGH_BONELESS_BRANDS = new Set([
723    '굽네치킨', '푸라닭', 'BBQ', '맘스터치', '60계치킨', '노랑통닭', '자담치킨', '호식이두마리치킨', '또래오래'
724]);
725const BREAST_BONELESS_BRANDS = new Set([
726    '네네치킨', 'KFC'
727]);
728
729function inferBonelessType(item) {
730    const composition = item.composition || [];
731    const name = item.name || '';
732    if (!composition.includes('순살')) return null;
733
734    if (/싸이|다리살|통다리/.test(name)) return 'boneless_thigh';
735    if (/가슴|안심|텐더/.test(name)) return 'boneless_breast';
736    if (THIGH_BONELESS_BRANDS.has(item.brand)) return 'boneless_thigh';
737    if (BREAST_BONELESS_BRANDS.has(item.brand)) return 'boneless_breast';
738    return 'boneless_mix';
739}
740
741function matchesCompositionPreference(item, preference) {
742    if (preference === 'any') return true;
743    const composition = item.composition || [];
744
745    switch (preference) {
746        case 'whole_bone':
747            return composition.includes('전체');
748        case 'wing_combo':
749            return composition.includes('윙봉') || composition.includes('콤보');
750        case 'boneless_thigh':
751            return composition.includes('순살') && inferBonelessType(item) === 'boneless_thigh';
752        case 'boneless_breast':
753            return composition.includes('순살') && inferBonelessType(item) === 'boneless_breast';
754        case 'boneless_mix':
755            return composition.includes('순살') && inferBonelessType(item) === 'boneless_mix';
756        default:
757            return true;
758    }
759}
760
761function classifyTextureMethod(item) {
762    const method = item.cooking_method || '';
763    const name = item.name || '';
764    const crispiness = item.crispiness || 3;
765
766    if (/숯불|조림/.test(method) || /숯불|양념구이/.test(name) || item.brand === '지코바치킨') {
767        return 'stirfry';
768    }
769
770    if (/오븐/.test(method) || /오븐/.test(name)) {
771        if (crispiness >= 3 || /바사삭|크리스피/.test(name)) return 'baked';
772        return 'roast';
773    }
774
775    if (/구이/.test(method) || /구이/.test(name)) return 'roast';
776
777    if (crispiness >= 4 || /크리스피|핫크리스피/.test(name)) return 'crispy';
778
779    return 'embossed';
780}
781
782function classifySauceProfile(item) {
783    const name = item.name || '';
784    const tags = item.flavor_tags || [];
785    const hasTag = (target) => tags.includes(target);
786
787    if (/뿌링|스노윙|시즈닝|콘소메|파우더/.test(name) || hasTag('시즈닝') || hasTag('콘소메')) {
788        return 'powder';
789    }
790    if (/마요|크림|치즈|어니언|화이트/.test(name) || hasTag('치즈') || hasTag('요거트') || hasTag('크리미')) {
791        return 'white';
792    }
793    if (/간장|갈비|맛초킹|소이|블랙/.test(name) || hasTag('간장') || hasTag('마늘간장') || hasTag('짭조름')) {
794        return 'black';
795    }
796    if (/양념|레드|핫|칠리|고추|불닭|땡초|볼케이노/.test(name) || hasTag('매콤') || hasTag('매운') || hasTag('고추')) {
797        return 'red';
798    }
799    return 'none';
800}
801
802function getAdaptiveWeights(answeredIds) {
803    const answeredCount = answeredIds.length;
804    const weights = {
805        spiciness: 1,
806        crispiness_preference: 1,
807        texture_method: 1,
808        composition: 1,
809        sauce_profile: 1
810    };
811
812    if (answeredCount <= 2) {
813        weights.spiciness = 1.45;
814        weights.crispiness_preference = 1.35;
815        weights.texture_method = 1.15;
816        weights.composition = 0.75;
817        weights.sauce_profile = 0.8;
818    } else if (answeredCount <= 4) {
819        weights.spiciness = 1.2;
820        weights.crispiness_preference = 1.2;
821        weights.texture_method = 1.25;
822        weights.composition = 1.15;
823        weights.sauce_profile = 1.2;
824    } else {
825        weights.spiciness = 1.15;
826        weights.crispiness_preference = 1.1;
827        weights.texture_method = 1.25;
828        weights.composition = 1.2;
829        weights.sauce_profile = 1.35;
830    }
831
832    return weights;
833}
834
835function scoreMenuItem(item, answers, weights) {
836    let rawScore = 0;
837    let maxScore = 0;
838    const diagnostics = {};
839
840    const userSpice = Number(answers.spiciness);
841    if (Number.isFinite(userSpice)) {
842        const spiceDiff = Math.abs(userSpice - (item.spiciness || 3));
843        const spiceBaseMax = 45;
844        const spiceComponent = Math.max(0, spiceBaseMax - (spiceDiff * 11));
845        rawScore += spiceComponent * weights.spiciness;
846        maxScore += spiceBaseMax * weights.spiciness;
847        diagnostics.spiceDiff = spiceDiff;
848    }
849
850    const userCrispy = Number(answers.crispiness_preference);
851    if (Number.isFinite(userCrispy)) {
852        const crispyDiff = Math.abs(userCrispy - (item.crispiness || 3));
853        const crispyBaseMax = 32;
854        const crispyComponent = Math.max(0, crispyBaseMax - (crispyDiff * 8));
855        rawScore += crispyComponent * weights.crispiness_preference;
856        maxScore += crispyBaseMax * weights.crispiness_preference;
857        diagnostics.crispyDiff = crispyDiff;
858    }
859
860    const texturePref = answers.texture_method;
861    const textureType = classifyTextureMethod(item);
862    if (texturePref !== undefined) {
863        const textureBaseMax = 30;
864        const isAny = texturePref === 'any';
865        const isMatch = textureType === texturePref;
866        const textureComponent = isAny ? 14 : (isMatch ? textureBaseMax : 0);
867        rawScore += textureComponent * weights.texture_method;
868        maxScore += textureBaseMax * weights.texture_method;
869        diagnostics.textureMatch = isAny ? true : isMatch;
870    }
871
872    const compositionPref = answers.composition;
873    if (compositionPref !== undefined) {
874        const compositionBaseMax = 28;
875        const isAny = compositionPref === 'any';
876        const isMatch = matchesCompositionPreference(item, compositionPref);
877        const compositionComponent = isAny ? 12 : (isMatch ? compositionBaseMax : 0);
878        rawScore += compositionComponent * weights.composition;
879        maxScore += compositionBaseMax * weights.composition;
880        diagnostics.compositionMatch = isAny ? true : isMatch;
881    }
882
883    const saucePref = answers.sauce_profile;
884    const sauceType = classifySauceProfile(item);
885    if (saucePref !== undefined) {
886        const sauceBaseMax = 30;
887        const isAny = saucePref === 'any';
888        const isMatch = sauceType === saucePref;
889        const sauceComponent = isAny ? 14 : (isMatch ? sauceBaseMax : 0);
890        rawScore += sauceComponent * weights.sauce_profile;
891        maxScore += sauceBaseMax * weights.sauce_profile;
892        diagnostics.sauceMatch = isAny ? true : isMatch;
893    }
894
895    const normalizedScore = maxScore > 0
896        ? Math.max(50, Math.min(99, Math.round((rawScore / maxScore) * 49 + 50)))
897        : 50;
898
899    return {
900        ...item,
901        score: normalizedScore,
902        rawScore,
903        diagnostics,
904        textureType,
905        sauceType
906    };
907}
908
909function calculateRankedMatches(answers, options = {}) {
910    const { preview = false, limit = 3 } = options;
911    const answeredIds = getAnsweredQuestionIds(answers);
912    if (!answeredIds.length) return [];
913
914    const weights = getAdaptiveWeights(answeredIds);
915    const normalizedAnswers = {};
916    answeredIds.forEach(id => {
917        normalizedAnswers[id] = answers[id];
918    });
919
920    let candidates = MENU_ITEMS;
921    if (!preview && normalizedAnswers.composition && normalizedAnswers.composition !== 'any') {
922        const filtered = MENU_ITEMS.filter(item => matchesCompositionPreference(item, normalizedAnswers.composition));
923        if (filtered.length) candidates = filtered;
924    }
925
926    const scored = candidates.map(item => scoreMenuItem(item, normalizedAnswers, weights));
927    scored.sort((a, b) => b.rawScore - a.rawScore || b.score - a.score);
928    return scored.slice(0, limit);
929}
930
931function buildLiveReason(item, lang) {
932    const parts = [];
933    const d = item.diagnostics || {};
934
935    if (Number.isFinite(d.spiceDiff)) {
936        parts.push(lang === 'ko' ? `맵기 차이 ±${d.spiceDiff}` : `Spice ±${d.spiceDiff}`);
937    }
938    if (Number.isFinite(d.crispyDiff)) {
939        parts.push(lang === 'ko' ? `바삭함 차이 ±${d.crispyDiff}` : `Crispy ±${d.crispyDiff}`);
940    }
941    if (d.textureMatch !== undefined) {
942        parts.push(lang === 'ko'
943            ? (d.textureMatch ? '조리법 일치' : '조리법 비일치')
944            : (d.textureMatch ? 'Method match' : 'Method mismatch'));
945    }
946    if (d.sauceMatch !== undefined) {
947        parts.push(lang === 'ko'
948            ? (d.sauceMatch ? '소스 성향 일치' : '소스 성향 비일치')
949            : (d.sauceMatch ? 'Sauce match' : 'Sauce mismatch'));
950    }
951    if (d.compositionMatch !== undefined) {
952        parts.push(lang === 'ko'
953            ? (d.compositionMatch ? '부위 선호 일치' : '부위 선호 비일치')
954            : (d.compositionMatch ? 'Cut match' : 'Cut mismatch'));
955    }
956
957    return parts.slice(0, 2).join(' · ');
958}
959
960function renderLiveRecommendation() {
961    const container = document.getElementById('quiz-live-reco');
962    if (!container) return;
963
964    const strings = getStrings();
965    const lang = state.language || 'en';
966    const answeredIds = getAnsweredQuestionIds(state.answers);
967    const answeredCount = answeredIds.length;
968
969    if (!answeredCount) {
970        container.innerHTML = `
971      <div class="quiz-live-header">
972        <h3>${strings.liveRecoTitle || 'Live Guess While You Answer'}</h3>
973        <span>0 / ${QUIZ_QUESTIONS.length}</span>
974      </div>
975      <p class="quiz-live-subtitle">${strings.liveRecoHint || 'Select an option to see an early recommendation.'}</p>
976    `;
977        return;
978    }
979
980    const picks = calculateRankedMatches(state.answers, { preview: true, limit: 2 });
981    if (!picks.length) {
982        container.innerHTML = `
983      <div class="quiz-live-header">
984        <h3>${strings.liveRecoTitle || 'Live Guess While You Answer'}</h3>
985        <span>${answeredCount} / ${QUIZ_QUESTIONS.length}</span>
986      </div>
987      <p class="quiz-live-subtitle">${strings.liveRecoHint || 'Select an option to see an early recommendation.'}</p>
988    `;
989        return;
990    }
991
992    container.innerHTML = `
993    <div class="quiz-live-header">
994      <h3>${strings.liveRecoTitle || 'Live Guess While You Answer'}</h3>
995      <span>${answeredCount} / ${QUIZ_QUESTIONS.length}</span>
996    </div>
997    <p class="quiz-live-subtitle">${strings.liveRecoSubtitle || 'Weighted recommendation updates after each step.'}</p>
998    <div class="quiz-live-cards">
999      ${picks.map(item => {
1000        const displayName = getTranslatedName(item, lang);
1001        const reason = buildLiveReason(item, lang);
1002        return `
1003          <article class="quiz-live-card">
1004            <img src="${item.image}" data-fallback="${item.fallbackImage || ''}" alt="${displayName}" class="quiz-live-image" loading="lazy" decoding="async" referrerpolicy="no-referrer" onerror="if(this.dataset.fallback && this.src !== this.dataset.fallback){this.src=this.dataset.fallback;}">
1005            <div class="quiz-live-meta">
1006              <strong>${displayName}</strong>
1007              <span>${item.brand}</span>
1008              <div class="quiz-live-score">${strings.liveRecoConfidence || 'Confidence'} ${item.score}%</div>
1009              <p>${reason}</p>
1010            </div>
1011          </article>
1012        `;
1013    }).join('')}
1014    </div>
1015  `;
1016}
1017
1018function calculateResults() {
1019    state.matches = calculateRankedMatches(state.answers, { preview: false, limit: 3 });
1020
1021    state.userProfile = {
1022        spiciness: Number(state.answers.spiciness) || 3,
1023        crispiness_preference: Number(state.answers.crispiness_preference) || 3,
1024        texture_method: state.answers.texture_method || 'any',
1025        composition: state.answers.composition || 'any',
1026        sauce_profile: state.answers.sauce_profile || 'any'
1027    };
1028
1029    addToHistory();
1030    saveState();
1031}
1032
1033// ========== RESULTS PAGE ==========
1034function renderResults() {
1035    renderMatchCards();
1036    renderNearbyStoresSection();
1037    updatePremiumSection();
1038    // Profile chips could be rendered if needed, but simplified for now
1039}
1040
1041function initNearbyStores() {
1042    const findBtn = document.getElementById('nearby-find-btn');
1043    if (!findBtn) return;
1044    findBtn.addEventListener('click', handleFindNearbyStores);
1045    renderNearbyStoresSection();
1046}
1047
1048function setNearbyStatus(message, type = '') {
1049    const statusEl = document.getElementById('nearby-status');
1050    if (!statusEl) return;
1051    statusEl.className = `nearby-status ${type}`.trim();
1052    statusEl.textContent = message || '';
1053}
1054
1055function haversineDistanceKm(lat1, lon1, lat2, lon2) {
1056    const toRad = (value) => (value * Math.PI) / 180;
1057    const earthKm = 6371;
1058    const dLat = toRad(lat2 - lat1);
1059    const dLon = toRad(lon2 - lon1);
1060    const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
1061        Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
1062        Math.sin(dLon / 2) * Math.sin(dLon / 2);
1063    const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
1064    return earthKm * c;
1065}
1066
1067function normalizeNearbyStore(rawStore, userLat, userLon) {
1068    const lat = rawStore.lat ?? rawStore.center?.lat;
1069    const lon = rawStore.lon ?? rawStore.center?.lon;
1070    if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
1071    const tags = rawStore.tags || {};
1072    const name = String(tags.name || '').trim();
1073    if (!name) return null;
1074
1075    const distanceKm = haversineDistanceKm(userLat, userLon, lat, lon);
1076    return {
1077        id: `${name}-${lat.toFixed(5)}-${lon.toFixed(5)}`,
1078        name,
1079        lat,
1080        lon,
1081        distanceKm,
1082        cuisine: tags.cuisine || '',
1083        address: tags['addr:full'] || `${tags['addr:street'] || ''} ${tags['addr:housenumber'] || ''}`.trim(),
1084        mapLink: `https://www.google.com/maps/search/?api=1&query=${lat},${lon}`
1085    };
1086}
1087
1088async function fetchNearbyChickenStores(latitude, longitude) {
1089    const overpassQuery = `
1090[out:json][timeout:25];
1091(
1092  node(around:${NEARBY_RADIUS_METERS},${latitude},${longitude})["cuisine"~"chicken|fried_chicken",i];
1093  way(around:${NEARBY_RADIUS_METERS},${latitude},${longitude})["cuisine"~"chicken|fried_chicken",i];
1094  relation(around:${NEARBY_RADIUS_METERS},${latitude},${longitude})["cuisine"~"chicken|fried_chicken",i];
1095  node(around:${NEARBY_RADIUS_METERS},${latitude},${longitude})["name"~"${CHICKEN_NAME_REGEX}",i]["amenity"~"restaurant|fast_food",i];
1096  way(around:${NEARBY_RADIUS_METERS},${latitude},${longitude})["name"~"${CHICKEN_NAME_REGEX}",i]["amenity"~"restaurant|fast_food",i];
1097  relation(around:${NEARBY_RADIUS_METERS},${latitude},${longitude})["name"~"${CHICKEN_NAME_REGEX}",i]["amenity"~"restaurant|fast_food",i];
1098);
1099out center tags;
1100`;
1101
1102    const endpoints = [
1103        'https://overpass-api.de/api/interpreter',
1104        'https://overpass.kumi.systems/api/interpreter'
1105    ];
1106
1107    let payload = null;
1108    for (const endpoint of endpoints) {
1109        try {
1110            const response = await fetch(endpoint, {
1111                method: 'POST',
1112                headers: { 'Content-Type': 'text/plain;charset=UTF-8' },
1113                body: overpassQuery
1114            });
1115            if (!response.ok) continue;
1116            payload = await response.json();
1117            break;
1118        } catch (error) {
1119            console.warn(`Nearby lookup failed on endpoint: ${endpoint}`, error);
1120        }
1121    }
1122    if (!payload) throw new Error('Nearby fetch failed on all endpoints.');
1123
1124    const stores = (payload.elements || [])
1125        .map(entry => normalizeNearbyStore(entry, latitude, longitude))
1126        .filter(Boolean)
1127        .filter(store => store.distanceKm <= (NEARBY_RADIUS_METERS / 1000));
1128
1129    const dedup = new Map();
1130    stores.forEach(store => {
1131        const key = `${store.name.toLowerCase()}-${store.lat.toFixed(4)}-${store.lon.toFixed(4)}`;
1132        if (!dedup.has(key) || dedup.get(key).distanceKm > store.distanceKm) dedup.set(key, store);
1133    });
1134
1135    return Array.from(dedup.values()).sort((a, b) => a.distanceKm - b.distanceKm);
1136}
1137
1138function ensureNearbyMap() {
1139    const mapEl = document.getElementById('nearby-map');
1140    if (!mapEl) return null;
1141    if (state.nearbyMap) return state.nearbyMap;
1142    if (!window.L) return null;
1143
1144    state.nearbyMap = L.map(mapEl, { zoomControl: true });
1145    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
1146        maxZoom: 19,
1147        attribution: '&copy; OpenStreetMap contributors'
1148    }).addTo(state.nearbyMap);
1149
1150    return state.nearbyMap;
1151}
1152
1153function clearNearbyMarkers() {
1154    if (!state.nearbyMap || !state.nearbyMarkers.length) return;
1155    state.nearbyMarkers.forEach(marker => marker.remove());
1156    state.nearbyMarkers = [];
1157}
1158
1159function renderNearbyMap() {
1160    const mapEl = document.getElementById('nearby-map');
1161    if (!mapEl) return;
1162
1163    const map = ensureNearbyMap();
1164    if (!map || !state.nearbyLocation) return;
1165
1166    mapEl.classList.add('ready');
1167    setTimeout(() => map.invalidateSize(), 0);
1168    clearNearbyMarkers();
1169
1170    const userLatLng = [state.nearbyLocation.lat, state.nearbyLocation.lon];
1171    const userMarker = L.circleMarker(userLatLng, {
1172        radius: 8,
1173        color: '#E31837',
1174        fillColor: '#FFC72C',
1175        fillOpacity: 1,
1176        weight: 2
1177    }).addTo(map).bindPopup('You are here');
1178    state.nearbyMarkers.push(userMarker);
1179
1180    const radiusCircle = L.circle(userLatLng, {
1181        radius: NEARBY_RADIUS_METERS,
1182        color: '#E31837',
1183        fillColor: '#E31837',
1184        fillOpacity: 0.08,
1185        weight: 1
1186    }).addTo(map);
1187    state.nearbyMarkers.push(radiusCircle);
1188
1189    const bounds = [userLatLng];
1190    state.nearbyStores.forEach(store => {
1191        const latLng = [store.lat, store.lon];
1192        bounds.push(latLng);
1193        const marker = L.marker(latLng)
1194            .addTo(map)
1195            .bindPopup(`<strong>${store.name}</strong><br>${store.distanceKm.toFixed(2)} km`);
1196        state.nearbyMarkers.push(marker);
1197    });
1198
1199    map.fitBounds(bounds, { padding: [20, 20] });
1200}
1201
1202function renderNearbyStoresSection() {
1203    const listEl = document.getElementById('nearby-list');
1204    const mapEl = document.getElementById('nearby-map');
1205    if (!listEl || !mapEl) return;
1206
1207    const strings = getStrings();
1208    if (!state.nearbyLocation || !state.nearbyStores.length) {
1209        mapEl.classList.remove('ready');
1210        clearNearbyMarkers();
1211        listEl.innerHTML = `
1212      <div class="nearby-empty">
1213        ${strings.nearbySubtitle || 'Use my location and list every chicken store found nearby.'}
1214      </div>
1215    `;
1216        return;
1217    }
1218
1219    listEl.innerHTML = state.nearbyStores.map(store => `
1220    <article class="nearby-item">
1221      <div class="nearby-item-main">
1222        <h4>${store.name}</h4>
1223        <p>${store.address || '-'}</p>
1224      </div>
1225      <div class="nearby-item-side">
1226        <strong>${store.distanceKm.toFixed(2)} km</strong>
1227        <a href="${store.mapLink}" target="_blank" rel="noopener">${strings.nearbyOpenMap || 'Open in map'}</a>
1228      </div>
1229    </article>
1230  `).join('');
1231
1232    renderNearbyMap();
1233}
1234
1235function handleFindNearbyStores() {
1236    const strings = getStrings();
1237    if (!navigator.geolocation) {
1238        setNearbyStatus(strings.nearbyUnsupported || 'Geolocation is not supported in this browser.', 'error');
1239        return;
1240    }
1241
1242    setNearbyStatus(strings.nearbyLoading || 'Finding nearby stores...', 'loading');
1243    navigator.geolocation.getCurrentPosition(async (position) => {
1244        try {
1245            const lat = position.coords.latitude;
1246            const lon = position.coords.longitude;
1247            const stores = await fetchNearbyChickenStores(lat, lon);
1248
1249            state.nearbyLocation = { lat, lon };
1250            state.nearbyStores = stores;
1251
1252            if (!stores.length) {
1253                setNearbyStatus(strings.nearbyNoStores || 'No chicken stores found within 10km.', 'warning');
1254            } else {
1255                const lang = state.language || 'en';
1256                const suffix = strings.nearbyFoundSuffix || 'stores found within 10km';
1257                const countMessage = (lang === 'ko' || lang === 'zh' || lang === 'ja')
1258                    ? `${stores.length}${suffix}`
1259                    : `${stores.length} ${suffix}`;
1260                setNearbyStatus(countMessage, 'success');
1261            }
1262
1263            renderNearbyStoresSection();
1264        } catch (error) {
1265            console.error('Failed to find nearby stores:', error);
1266            const lang = state.language || 'en';
1267            const message = lang === 'ko'
1268                ? '주변 매장 조회에 실패했습니다. 잠시 후 다시 시도해주세요.'
1269                : lang === 'zh'
1270                    ? '附近门店查询失败,请稍后重试。'
1271                    : lang === 'ja'
1272                        ? '近くの店舗検索に失敗しました。時間をおいて再試行してください。'
1273                        : 'Nearby store lookup failed. Please try again.';
1274            setNearbyStatus(message, 'error');
1275        }
1276    }, () => {
1277        setNearbyStatus(strings.nearbyNeedPermission || 'Location permission is required to find nearby stores.', 'error');
1278    }, {
1279        enableHighAccuracy: true,
1280        timeout: 15000,
1281        maximumAge: 0
1282    });
1283}
1284
1285// ========== Translation Helpers ==========
1286function getTranslatedName(item, lang) {
1287    const koName = item.name; // always Korean from raw data
1288    if (lang === 'ko') return koName;
1289    const t = MENU_NAME_TRANSLATIONS[koName];
1290    if (t && t[lang]) return t[lang];
1291    return koName; // fallback to Korean
1292}
1293
1294function getTranslatedTags(item, lang) {
1295    if (lang === 'ko') return item.flavor_tags;
1296    return item.flavor_tags.map(tag => {
1297        const t = FLAVOR_TAG_TRANSLATIONS[tag];
1298        return (t && t[lang]) ? t[lang] : tag;
1299    });
1300}
1301
1302function getTranslatedDescription(item, lang) {
1303    // Description is always in Korean from raw data
1304    // For non-Korean, we keep Korean but show it in a helpful way
1305    if (lang === 'ko') return item.description || '';
1306    // For other languages, we won't machine-translate inline, just show Korean with context
1307    return item.description || '';
1308}
1309
1310function getTranslatedBadge(badge, lang) {
1311    const t = BADGE_TRANSLATIONS[badge];
1312    if (t && t[lang]) return t[lang];
1313    return badge;
1314}
1315
1316function renderMatchCards() {
1317    const container = document.getElementById('match-cards');
1318    if (!container) return;
1319
1320    const lang = state.language || 'en';
1321    const strings = getStrings();
1322
1323    if (state.matches.length === 0) {
1324        const noMatchMsg = lang === 'ko' ? '이 조건에 맞는 결과가 없습니다. 조건을 변경해보세요!' :
1325            lang === 'zh' ? '没有找到匹配的结果,请调整条件!' :
1326                lang === 'ja' ? '条件に合う結果がありません。条件を変更してみてください!' :
1327                    'No matches found with these exact filters. Try loosening your criteria!';
1328        container.innerHTML = `<div style="text-align:center; padding: 2rem;">${noMatchMsg}</div>`;
1329        return;
1330    }
1331
1332    const spicyLabel = strings.spiciness || 'Spiciness';
1333    const crispyLabel = strings.crispiness || 'Crispiness';
1334    const featuresLabel = lang === 'ko' ? '특징' : lang === 'zh' ? '特色' : lang === 'ja' ? '特徴' : 'Features';
1335
1336    container.innerHTML = state.matches.map(item => {
1337        const displayName = getTranslatedName(item, lang);
1338        const koName = item.name;
1339        // Show both Korean original + translated name for non-Korean users
1340        const nameDisplay = lang === 'ko' ? koName : `${displayName} <span style="font-size: 13px; color: #888; font-weight: 400;">(${koName})</span>`;
1341        const tags = getTranslatedTags(item, lang);
1342        const badge = getTranslatedBadge(item.badge, lang);
1343        const desc = item.description || '';
1344
1345        return `
1346    <div class="match-card">
1347      <div class="match-card-image" onclick="window.open('${item.menuPage}', '_blank')" style="cursor: pointer; overflow: hidden;">
1348        <img class="match-card-photo" src="${item.image}" data-fallback="${item.fallbackImage || ''}" alt="${displayName}" loading="lazy" decoding="async" referrerpolicy="no-referrer" onerror="if(this.dataset.fallback && this.src !== this.dataset.fallback) { this.src = this.dataset.fallback; }">
1349        <div class="match-card-badge">
1350          <span class="badge ${item.badge === 'Spicy' ? 'badge-spicy' : ''}">${badge}</span>
1351        </div>
1352      </div>
1353      <div class="match-card-content">
1354        <div class="match-card-header">
1355          <div class="match-card-meta" onclick="window.open('${item.website}', '_blank')">
1356            <div class="match-card-name">${nameDisplay} <span style="font-size: 12px; vertical-align: middle;">🔗</span></div>
1357            <div class="match-card-brand-row">
1358              ${item.brandImage ? `<img class="match-card-brand-logo" src="${item.brandImage}" alt="${item.brand} logo" loading="lazy" decoding="async" referrerpolicy="no-referrer">` : ''}
1359              <div class="match-card-brand">${item.brand}</div>
1360            </div>
1361          </div>
1362          <div class="match-card-score">
1363            <div class="match-card-score-value">${item.score}</div>
1364            <div class="match-card-score-label">${strings.matchScore || 'Match'}</div>
1365          </div>
1366        </div>
1367        
1368        <div class="mini-meters">
1369            <div class="mini-meter">
1370                <div class="mini-meter-label">${spicyLabel}</div>
1371                <div class="mini-meter-bar"><div class="mini-meter-fill" style="width: ${item.spiciness * 20}%"></div></div>
1372            </div>
1373            <div class="mini-meter">
1374                <div class="mini-meter-label">${crispyLabel}</div>
1375                <div class="mini-meter-bar"><div class="mini-meter-fill" style="width: ${item.crispiness * 20}%"></div></div>
1376            </div>
1377        </div>
1378        
1379        <div style="margin-top: 8px; font-size: 12px; color: #666;">
1380            <strong>${featuresLabel}:</strong> ${tags.join(', ')}
1381        </div>
1382
1383        <div class="match-card-reason">${desc}</div>
1384      </div>
1385    </div>
1386  `;
1387    }).join('');
1388
1389    attachMatchCardImageFallbacks(container);
1390}
1391
1392function attachMatchCardImageFallbacks(container) {
1393    container.querySelectorAll('.match-card-photo').forEach(img => {
1394        img.addEventListener('error', () => {
1395            img.style.display = 'none';
1396        });
1397    });
1398    container.querySelectorAll('.match-card-brand-logo').forEach(img => {
1399        img.addEventListener('error', () => {
1400            img.style.display = 'none';
1401        });
1402    });
1403}
1404
1405function updatePremiumSection() {
1406    const lockedSection = document.getElementById('locked-premium-section');
1407    if (lockedSection) {
1408        lockedSection.style.display = state.isPremium ? 'none' : 'block';
1409    }
1410}
1411
1412// ========== PREMIUM ==========
1413function initPremium() {
1414    document.querySelectorAll('[data-action="start-premium"]').forEach(btn => {
1415        btn.addEventListener('click', handleStartPremium);
1416    });
1417    document.querySelectorAll('[data-action="restore-purchase"]').forEach(btn => {
1418        btn.addEventListener('click', handleRestorePurchase);
1419    });
1420}
1421
1422function handleStartPremium() {
1423    state.isPremium = true;
1424    saveState();
1425    alert('🎉 Welcome to Premium! Your free month has started.');
1426    showPage('results');
1427}
1428
1429function handleRestorePurchase() {
1430    if (state.isPremium) {
1431        alert('✓ Your Premium subscription has been restored!');
1432    } else {
1433        alert('No previous subscription found.');
1434    }
1435}
1436
1437// ========== SETTINGS ==========
1438function initSettings() {
1439    const langToggle = document.getElementById('lang-toggle');
1440    if (langToggle) {
1441        langToggle.addEventListener('click', () => {
1442            langToggle.classList.toggle('active');
1443        });
1444    }
1445
1446    document.querySelectorAll('[data-action="edit-quiz"]').forEach(btn => {
1447        btn.addEventListener('click', () => {
1448            state.currentQuestion = 0;
1449            showPage('quiz');
1450        });
1451    });
1452}
1453
1454function renderSettings() {
1455    // Simplified settings render
1456    const historyList = document.getElementById('history-list');
1457    if (historyList) {
1458        if (state.history.length === 0) {
1459            historyList.innerHTML = '<p class="text-muted">No picks yet.</p>';
1460        } else {
1461            historyList.innerHTML = state.history.slice(0, 5).map(item => `
1462        <div class="history-item">
1463          <div>
1464            <div class="history-name">${item.name}</div>
1465            <div class="history-date">${item.date}</div>
1466          </div>
1467        </div>
1468      `).join('');
1469        }
1470    }
1471}
1472
1473// ========== FAQ ==========
1474function initFAQ() {
1475    document.querySelectorAll('.faq-question').forEach(btn => {
1476        btn.addEventListener('click', () => {
1477            const item = btn.closest('.faq-item');
1478            item.classList.toggle('open');
1479        });
1480    });
1481}
1482
1483// ========== HISTORY ==========
1484function addToHistory() {
1485    if (state.matches.length > 0) {
1486        const topMatch = state.matches[0];
1487        state.history.unshift({
1488            name: topMatch.name,
1489            score: topMatch.score,
1490            date: new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
1491        });
1492        state.history = state.history.slice(0, 10);
1493    }
1494}
1495
1496// ========== PERSISTENCE ==========
1497function saveState() {
1498    localStorage.setItem('kcs-state', JSON.stringify({
1499        userProfile: state.userProfile,
1500        history: state.history,
1501        isPremium: state.isPremium,
1502        answers: state.answers
1503    }));
1504}
1505
1506function loadState() {
1507    try {
1508        const saved = localStorage.getItem('kcs-state');
1509        if (saved) {
1510            const data = JSON.parse(saved);
1511            state.userProfile = data.userProfile || null;
1512            state.history = data.history || [];
1513            state.isPremium = data.isPremium || false;
1514        }
1515    } catch (e) {
1516        console.error('Failed to load state:', e);
1517    }
1518}
1519
1520// ========== UTILITIES ==========
1521function getStrings() {
1522    return UI_STRINGS[state.language || 'en'] || UI_STRINGS.en;
1523}
1524
1525// ========== SHARE ==========
1526function buildShareText() {
1527    const lang = state.language || 'en';
1528    const url = 'https://k-chicken-sommelier.vercel.app';
1529
1530    if (state.matches.length === 0) {
1531        const defaultMsg = lang === 'ko' ? '🍗 K-Chicken Sommelier에서 나의 치킨 소울메이트를 찾았어요!' :
1532            '🍗 Found my Korean chicken soulmate with K-Chicken Sommelier!';
1533        const hashtags = lang === 'ko'
1534            ? '\n\n#KChickenSommelier #치킨추천 #한국치킨 #치킨소울메이트'
1535            : '\n\n#KChickenSommelier #KoreanChicken #ChickenSoulmate';
1536        return `${defaultMsg}${hashtags}\n${url}`;
1537    }
1538
1539    const top = state.matches[0];
1540    const koName = top.name;
1541    const displayName = getTranslatedName(top, lang);
1542    const tags = getTranslatedTags(top, lang).slice(0, 3).join(', ');
1543    const badge = getTranslatedBadge(top.badge, lang);
1544
1545    let text, hashtags;
1546    if (lang === 'ko') {
1547        text = `🍗 나의 치킨 소울메이트를 찾았어요!\n\n` +
1548            `✅ ${koName} — ${top.brand}\n` +
1549            `🏷️ ${badge} | 매치 점수 ${top.score}점\n` +
1550            `🔥 ${tags}\n\n` +
1551            `30초 퀴즈로 나만의 치킨을 찾아보세요!`;
1552        hashtags = '\n\n#KChickenSommelier #치킨추천 #한국치킨 #치킨소울메이트 #' + koName.replace(/\s/g, '');
1553    } else if (lang === 'zh') {
1554        text = `🍗 找到了我的炸鸡灵魂伴侣!\n\n` +
1555            `✅ ${displayName} — ${top.brand}\n` +
1556            `🏷️ ${badge} | 匹配度 ${top.score}\n` +
1557            `🔥 ${tags}\n\n` +
1558            `30秒测试找到你的专属炸鸡!`;
1559        hashtags = '\n\n#KChickenSommelier #韩国炸鸡 #炸鸡推荐';
1560    } else if (lang === 'ja') {
1561        text = `🍗 チキンソウルメイトを見つけました!\n\n` +
1562            `✅ ${displayName} — ${top.brand}\n` +
1563            `🏷️ ${badge} | マッチ度 ${top.score}\n` +
1564            `🔥 ${tags}\n\n` +
1565            `30秒クイズであなたのチキンを見つけよう!`;
1566        hashtags = '\n\n#KChickenSommelier #韓国チキン #チキンおすすめ';
1567    } else {
1568        text = `🍗 Found my Korean chicken soulmate!\n\n` +
1569            `✅ ${displayName} — ${top.brand}\n` +
1570            `🏷️ ${badge} | Match Score: ${top.score}\n` +
1571            `🔥 ${tags}\n\n` +
1572            `Take the 30-sec quiz to find yours!`;
1573        hashtags = '\n\n#KChickenSommelier #KoreanChicken #ChickenSoulmate #KFood';
1574    }
1575
1576    return `${text}${hashtags}\n${url}`;
1577}
1578
1579function buildShareHashtags() {
1580    const lang = state.language || 'en';
1581    if (lang === 'ko') return 'KChickenSommelier,치킨추천,한국치킨,치킨소울메이트';
1582    if (lang === 'zh') return 'KChickenSommelier,韩国炸鸡,炸鸡推荐';
1583    if (lang === 'ja') return 'KChickenSommelier,韓国チキン,チキンおすすめ';
1584    return 'KChickenSommelier,KoreanChicken,ChickenSoulmate,KFood';
1585}
1586
1587function showToast(message) {
1588    // Remove existing toast
1589    document.querySelector('.share-toast')?.remove();
1590    const toast = document.createElement('div');
1591    toast.className = 'share-toast';
1592    toast.textContent = message;
1593    toast.style.cssText = `
1594        position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%);
1595        background: #333; color: white; padding: 12px 24px; border-radius: 25px;
1596        font-size: 14px; z-index: 10000; animation: toastFade 2.5s ease forwards;
1597        box-shadow: 0 4px 12px rgba(0,0,0,0.3);
1598    `;
1599    document.body.appendChild(toast);
1600    setTimeout(() => toast.remove(), 2600);
1601}
1602
1603function initShare() {
1604    // Results page share button — opens share modal
1605    const resShare = document.getElementById('btn-results-share');
1606    if (resShare) {
1607        resShare.addEventListener('click', () => {
1608            openShareModal();
1609        });
1610    }
1611
1612    // Close handlers
1613    document.querySelectorAll('[data-share-close]').forEach(el => {
1614        el.addEventListener('click', closeShareModal);
1615    });
1616    const closeBtn = document.getElementById('share-close-btn');
1617    if (closeBtn) {
1618        closeBtn.addEventListener('click', closeShareModal);
1619    }
1620
1621    // Platform buttons in share modal
1622    document.querySelectorAll('[data-share-platform]').forEach(btn => {
1623        btn.addEventListener('click', () => {
1624            handleSharePlatform(btn.dataset.sharePlatform);
1625        });
1626    });
1627}
1628
1629function openShareModal() {
1630    const modal = document.getElementById('share-modal');
1631    if (!modal) return;
1632
1633    // Populate preview text
1634    const preview = document.getElementById('share-preview-text');
1635    if (preview) {
1636        preview.textContent = buildShareText();
1637    }
1638
1639    // Set title/subtitle based on language
1640    const lang = state.language || 'en';
1641    const title = document.getElementById('share-sheet-title');
1642    const subtitle = document.getElementById('share-sheet-subtitle');
1643    if (title) {
1644        title.textContent = lang === 'ko' ? '결과 공유하기' :
1645            lang === 'zh' ? '分享结果' :
1646                lang === 'ja' ? '結果をシェア' :
1647                    'Share your result';
1648    }
1649    if (subtitle) {
1650        subtitle.textContent = lang === 'ko' ? 'SNS에 자랑해보세요! 멘트와 해시태그가 자동 입력됩니다.' :
1651            lang === 'zh' ? '在社交媒体上分享!文字和标签已自动生成。' :
1652                lang === 'ja' ? 'SNSでシェアしよう!テキストとハッシュタグは自動入力されます。' :
1653                    'Share on social media! Text and hashtags are auto-generated.';
1654    }
1655
1656    modal.classList.add('open');
1657    modal.setAttribute('aria-hidden', 'false');
1658}
1659
1660function closeShareModal() {
1661    const modal = document.getElementById('share-modal');
1662    if (modal) {
1663        modal.classList.remove('open');
1664        modal.setAttribute('aria-hidden', 'true');
1665    }
1666}
1667
1668async function copyToClipboard(text) {
1669    try {
1670        await navigator.clipboard.writeText(text);
1671    } catch (e) {
1672        const ta = document.createElement('textarea');
1673        ta.value = text;
1674        ta.style.cssText = 'position:fixed;opacity:0';
1675        document.body.appendChild(ta);
1676        ta.select();
1677        document.execCommand('copy');
1678        ta.remove();
1679    }
1680}
1681
1682async function handleSharePlatform(platform) {
1683    const rawText = buildShareText();
1684    const shareUrl = 'https://k-chicken-sommelier.vercel.app';
1685    const hashtags = buildShareHashtags();
1686    const lang = state.language || 'en';
1687
1688    // Build a short text for Twitter (character limit)
1689    const top = state.matches.length > 0 ? state.matches[0] : null;
1690    const tweetText = top
1691        ? (lang === 'ko'
1692            ? `🍗 나의 치킨 소울메이트: ${top.name} (${top.brand}) — 매치 ${top.score}점!\n30초 퀴즈로 나만의 치킨을 찾아보세요!`
1693            : `🍗 My chicken soulmate: ${getTranslatedName(top, lang)} (${top.brand}) — Match ${top.score}!\nFind yours with the 30-sec quiz!`)
1694        : (lang === 'ko' ? '🍗 K-Chicken Sommelier에서 치킨 소울메이트를 찾아보세요!' : '🍗 Find your Korean chicken soulmate!');
1695
1696    switch (platform) {
1697        case 'x':
1698            window.open(
1699                `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweetText)}&url=${encodeURIComponent(shareUrl)}&hashtags=${hashtags}`,
1700                '_blank'
1701            );
1702            break;
1703        case 'facebook':
1704            window.open(
1705                `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}&quote=${encodeURIComponent(rawText)}`,
1706                '_blank'
1707            );
1708            break;
1709        case 'instagram':
1710            // Instagram has no web share URL — copy text to clipboard
1711            await copyToClipboard(rawText);
1712            showToast(lang === 'ko'
1713                ? '📋 텍스트가 복사되었습니다! 인스타그램에 붙여넣기 하세요.'
1714                : lang === 'zh' ? '📋 文字已复制!请粘贴到Instagram。'
1715                    : lang === 'ja' ? '📋 テキストをコピーしました!Instagramに貼り付けてください。'
1716                        : '📋 Text copied! Paste it on Instagram.');
1717            break;
1718        case 'copy':
1719            await copyToClipboard(rawText);
1720            showToast(lang === 'ko' ? '📋 링크와 텍스트가 복사되었습니다!' :
1721                lang === 'zh' ? '📋 链接和文字已复制!' :
1722                    lang === 'ja' ? '📋 リンクとテキストがコピーされました!' :
1723                        '📋 Link and text copied!');
1724            break;
1725    }
1726    closeShareModal();
1727}
1728
1729function applyTranslations(lang) {
1730    const strings = UI_STRINGS[lang] || UI_STRINGS['en'];
1731
1732    // safe update helper
1733    const update = (id, text) => {
1734        const el = document.getElementById(id);
1735        if (el) el.innerHTML = text;
1736    };
1737
1738    update('hero-headline', strings.heroHeadline);
1739    update('hero-sub', strings.heroSub);
1740    update('start-quiz-btn', strings.startBtn);
1741    update('btn-hero-secondary', strings.seeTopPicks);
1742    update('brand-directory-title', strings.brandDirectoryTitle);
1743    update('brand-directory-subtitle', strings.brandDirectorySubtitle);
1744    update('header-premium-btn', lang === 'ko' ? '프리미엄' : (lang === 'zh' ? '高级版' : (lang === 'ja' ? 'プレミアム' : 'Premium')));
1745
1746    // Quiz Buttons
1747    update('quiz-next', strings.nextBtn);
1747 // Default state, updateQuizButtons will override if needed
1748    update('quiz-skip', strings.skipBtn);
1749    update('back-btn-text', strings.backBtn);
1750
1751    // Results Page Buttons
1752    update('results-title', strings.resultTitle);
1753    update('share-btn-text', strings.shareBtn);
1754    update('btn-results-unlock', strings.unlockPremium);
1755    update('btn-results-retry', strings.tryAgain);
1756    update('locked-section-title', strings.premiumUnlocks);
1757    update('nearby-title', strings.nearbyTitle);
1758    update('nearby-subtitle', strings.nearbySubtitle);
1759    update('nearby-find-btn', strings.nearbyFindBtn);
1760
1761    if (state.nearbyStores.length) {
1762        const suffix = strings.nearbyFoundSuffix || 'stores found within 10km';
1763        const countMessage = (lang === 'ko' || lang === 'zh' || lang === 'ja')
1764            ? `${state.nearbyStores.length}${suffix}`
1765            : `${state.nearbyStores.length} ${suffix}`;
1766        setNearbyStatus(countMessage, 'success');
1767    }
1768
1769    // Recalculate stats text in new language
1770    initStats();
1771    renderBrandDirectory();
1772
1773    // Re-render quiz if active
1774    if (state.currentPage === 'quiz') {
1775        renderQuizQuestion();
1776    }
1777
1778    // Re-render results/cards if active 
1779    if (state.currentPage === 'results') {
1780        renderResults();
1781    } else {
1782        renderNearbyStoresSection();
1783    }
1784}
1785
1786// ========== LANGUAGES ==========
1787function initLanguages() {
1788    document.querySelectorAll('.lang-btn').forEach(btn => {
1789        btn.addEventListener('click', () => {
1790            const lang = btn.dataset.lang;
1791            setLanguage(lang);
1792        });
1793    });
1794}
1795
1796function setLanguage(lang) {
1797    state.language = lang;
1798
1799    // Update active button
1800    document.querySelectorAll('.lang-btn').forEach(btn => {
1801        btn.classList.toggle('active', btn.dataset.lang === lang);
1802    });
1803
1804    applyTranslations(lang);
1805    saveState();
1806}

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.