PageSourceSearch

https://habitschool.web.app/js/i18n.js?v=485

js habitschool.web.app collected 2026-10-03 12:25:18 UTC 39,063 bytes, 667 lines download raw bytes

1import { buildLocalizedUrl, getLocale as getRouteLocale } from './app-mode.js?v=485';
2
3const DEFAULT_LOCALE = 'ko';
4const ENGLISH_LOCALE = 'en';
5
6const MESSAGES = {
7    ko: {
8        'common.save': '저장하기',
9        'common.logout': '로그아웃',
10        'common.loading': '확인 중...',
11        'auth.loginRequired': '로그인이 필요합니다.',
12        'auth.startWithGoogle': 'Google로 시작하기',
13        'profile.languageKorean': '한국어',
14        'profile.languageEnglish': 'English',
15        'notification.enable': '알림 켜기',
16        'toast.aiFailed': 'AI 분석에 실패했습니다. 다시 시도해 주세요.',
17        'toast.analysisFailed': '분석 중 오류가 발생했습니다.',
18        'toast.notHealthScreenshot': '건강/만보기 앱 캡처가 아닌 것 같습니다.',
19        'analysis.signedOut': '로그인이 풀렸어요. 다시 로그인한 뒤 시도해 주세요.',
20        'analysis.tooSlow': '분석이 오래 걸리고 있어요. 잠시 후 다시 시도해 주세요.',
21        'analysis.busy': '요청이 몰려 있어요. 잠시 후 다시 시도해 주세요.',
22        'analysis.offline': '연결이 끊겼어요. 네트워크를 확인한 뒤 다시 시도해 주세요.',
23        'kst.badge': 'KST',
24        'kst.badgeAria': '날짜 기준 안내 (한국시간)',
25        'kst.title': '날짜 기준: 한국시간(KST)',
26        'kst.body': '해빛스쿨의 하루는 한국시간(KST) 기준이에요.\n지금 한국은 {now}입니다.\n\n보상 한도는 한국시간 오전 9시에 초기화돼요.\n사진의 촬영 시각도 한국시간으로 환산해 인정하니, 현지 날짜와 달라 보여도 그대로 올리시면 됩니다.',
27        // 첫 가입 동의 화면에서 나가는 버튼 — "로그아웃"과 다른 뉘앙스
28        'reconsent.signup.later': '그만두기'
29    },
30    en: {
31        'common.save': 'Save',
32        'common.logout': 'Log out',
33        'common.loading': 'Checking...',
34        'auth.loginRequired': 'Please sign in first.',
35        'auth.startWithGoogle': 'Start with Google',
36        'profile.languageKorean': '한국어',
37        'profile.languageEnglish': 'English',
38        'notification.enable': 'Turn on notifications',
39        'toast.aiFailed': 'AI analysis failed. Please try again.',
40        'toast.analysisFailed': 'Something went wrong during analysis.',
41        'toast.notHealthScreenshot': 'This does not look like a health or step-count screenshot.',
42        'analysis.signedOut': 'Your session expired. Sign in again and retry.',
43        'analysis.tooSlow': 'The analysis is taking too long. Please try again shortly.',
44        'analysis.busy': 'Too many requests right now. Please try again shortly.',
45        'analysis.offline': 'Connection lost. Check your network and try again.',
46        'kst.badge': 'KST',
47        'kst.badgeAria': 'About the date basis (Korea time)',
48        'kst.title': 'Dates follow Korea time (KST)',
49        'kst.body': 'A day in Habit School runs on Korea Standard Time (KST, UTC+9).\nIt is now {now} in Korea.\n\nDaily reward limits reset at 9:00 AM KST.\nPhoto capture times are converted to Korea time as well, so go ahead and upload even if the date looks different from your local one.',
50        'diet.grade.A': 'Excellent',
51        'diet.grade.B': 'Good',
52        'diet.grade.C': 'Average',
53        'diet.grade.D': 'Needs work',
54        'diet.grade.F': 'High risk',
55        'diet.category.natural': 'Whole food',
56        'diet.category.processed': 'Minimally processed',
57        'diet.category.ultraprocessed': 'Ultra-processed',
58        'diet.category.other': 'Other',
59        'diet.score.vitamins': 'Vitamins',
60        'diet.score.minerals': 'Minerals',
61        'diet.score.fiber': 'Fiber',
62        'diet.score.antioxidants': 'Antioxidants',
63        'diet.naturalRatio': 'Whole-food ratio',
64        'diet.micronutrientScore': 'Micronutrient score',
65        'diet.insulinImpact': 'Likely insulin impact',
66        'diet.daySummary.wholeFoods': 'Whole foods {value}%',
67        'diet.daySummary.ultra': '{count} ultra-processed item(s)',
68        'diet.daySummary.noUltra': 'No ultra-processed items',
69        'exercise.intensity.low': 'Light',
70        'exercise.intensity.moderate': 'Moderate',
71        'exercise.intensity.high': 'Hard',
72        'exercise.intensity.veryHigh': 'Very hard',
73        'exercise.progress.complete': 'Complete!',
74        'exercise.progress.almost': 'Almost there',
75        'exercise.progress.half': 'Halfway',
76        'exercise.progress.keepGoing': 'Keep going',
77        'exercise.type': 'Detected',
78        'exercise.intensityTitle': 'Exercise intensity analysis',
79        'exercise.dailyProgress': 'Daily recommendation progress',
80        'exercise.coachFeedback': 'AI trainer feedback',
81        'mind.sleepAnalysis': 'Sleep analysis',
82        'mind.mindAnalysis': 'Mind analysis',
83        'mind.sleepDuration': 'Sleep duration',
84        'mind.sleepQuality': 'Sleep quality',
85        'mind.emotionTone': 'Emotional tone',
86        'mind.stressLevel': 'Stress',
87        'mind.coachFeedback': 'AI coach feedback',
88        'voice.unsupported': 'Voice input is not supported in this browser.',
89        'voice.idle': 'Voice input',
90        'voice.starting': 'Listening...',
91        'voice.listening': 'Listening...',
92        'voice.startStatus': 'Listening. Speak your gratitude journal now.',
93        'voice.resultStatus': 'Adding what you said to the journal.',
94        'voice.retry': 'Voice input failed. Please try again.',
95        'voice.permission': 'Allow microphone permission to use voice input.',
96        'voice.noSpeech': 'I could not hear anything. Please try again.',
97        'voice.noMic': 'Could not find a microphone.',
98        'voice.network': 'Please check your network connection.',
99        'voice.done': 'Voice input complete.',
100        'voice.tryAgain': 'I could not hear anything. Please try again.',
101        'voice.startingMic': 'Opening the microphone...',
102        'profile.notificationGranted': 'Notifications are on for this device.',
103        'profile.notificationDefault': 'Turn on reminders for check-ins and progress nudges.',
104        'profile.notificationDenied': 'Notifications are blocked in your browser settings.',
105        'profile.notificationUnsupported': 'This browser does not support push notifications.',
106        'profile.notificationInstallRequired': 'Install Habit School to your home screen first on iPhone/iPad.',
107        'disclaimer.medical': 'Habit School helps you build healthy lifestyle habits and does not provide medical advice, diagnosis, or treatment. Its diet analysis, health scores, and other features are for reference only and are not a substitute for professional medical judgment. Always consult a qualified healthcare professional about your health, symptoms, or any diet or exercise plan.',
108        // 식단 방법 이름·설명. 키에는 내부 ID를 그대로 쓴다(표시명과 분리).
109        // 키가 없으면 t()가 한국어 원문으로 폴백한다.
110        'diet.method.brown_rice_green_veggies.name': 'Brown Rice & Greens',
111        'diet.method.brown_rice_green_veggies.summary': 'Whole grains and vegetables as your base',
112        'diet.method.mediterranean.name': 'Mediterranean',
113        'diet.method.mediterranean.summary': 'Olive oil and fish for heart health',
114        'diet.method.low_carb.name': 'Low-Carb High-Protein',
115        'diet.method.low_carb.summary': 'Less rice and bread, more protein on your plate',
116        'diet.method.intermittent_fasting.name': '16:8 Intermittent Fasting',
117        'diet.method.intermittent_fasting.summary': 'Eat within an 8-hour window and keep the rest of the day clear',
118        'diet.method.switch_on.name': 'Two Weeks Without Ultra-Processed Food',
119        'diet.method.switch_on.summary': 'Cut snacks, processed meat and sweet drinks; fill up on protein and vegetables',
120        'diet.method.none.dashboardTip': 'Your guide and reminders will change.',
121        'diet.method.brown_rice_green_veggies.exerciseSupportTip': 'Start with easy walks.',
122        'diet.method.brown_rice_green_veggies.mindSleepSupportTip': 'Just cutting late-night snacks helps.',
123        'diet.method.mediterranean.exerciseSupportTip': 'Pairs well with short walks.',
124        'diet.method.mediterranean.mindSleepSupportTip': 'Goes well with regular sleep.',
125        'diet.method.low_carb.exerciseSupportTip': 'Keep movement light while you adjust.',
126        'diet.method.low_carb.mindSleepSupportTip': 'Avoid restricting too hard.',
127        'diet.method.intermittent_fasting.exerciseSupportTip': 'Move gently during fasting hours.',
128        'diet.method.intermittent_fasting.mindSleepSupportTip': 'Try to keep a steady sleep schedule.',
129        'diet.method.switch_on.exerciseSupportTip': 'Start light in the first days.',
130        'diet.method.switch_on.mindSleepSupportTip': 'Keep your daily rhythm steady first.',
131        // 식단 탭 안내 카드(js/diet-program.js)
132        'dietGuide.badge': 'Photos {count}/4',
133        'dietGuide.helper.empty': 'You can save with just one meal photo.',
134        'dietGuide.status.empty': 'Add your first meal photo to start today’s food log.',
135        'dietGuide.status.partial': '{count} meal photo(s) ready. Add more for up to 30P.',
136        'dietGuide.helper.partialWithMetrics': '{count} meal photo(s) and your fasting metrics are ready to save.',
137        'dietGuide.helper.partial': '{count} meal photo(s) ready to save now.',
138        'dietGuide.status.metricsOnly': 'Fasting metrics entered. Add meal photos to save them together.',
139        'dietGuide.helper.metricsOnly': 'You can save your fasting metrics now.',
140        'dietGuide.status.full': 'All meal slots are filled. Save to add today’s food points.',
141        'dietGuide.helper.full': 'Food log ready. Save to apply.',
142        'dietGuide.fasting.label': 'Fasting',
143        'dietGuide.fasting.status': 'You are in your fasting hours.',
144        'dietGuide.window': 'Your eating window is {start}–{end}.',
145        'dietGuide.eating.label': 'Eating',
146        'dietGuide.eating.status': 'You are in your eating window.',
147        'dietGuide.finishBy': 'Try to finish by {end}.',
148        'dietGuide.closing.label': 'Closing soon',
149        'dietGuide.closing.status': 'Your eating window is about to close.',
150        'dietGuide.closed.status': 'Today’s eating window is over.',
151        'dietGuide.closed.helper': 'You can keep logging.',
152        'dietGuide.preset.helper': 'Log whenever you like.',
153        'dietGuide.helper.metricsFirst': 'You can save just your fasting metrics first.',
154        'dietProgram.noReminders': 'No reminders',
155        'dietProgram.chooseMethod': 'Choose a food method',
156        'dietProgram.remindersOff': 'Meal-time reminders are off.',
157        'dietProgram.analysisTip': 'Food tip · {guide}',
158        'dietProgram.toggle.needMethod': 'Choose a food method first to turn on reminders.',
159        'dietProgram.toggle.deviceOff': 'Meal-time reminders are on, but notifications are off on this device.',
160        'dietProgram.toggle.on': 'This device gets reminders matched to your food method.',
161        'dietProgram.toggle.off': 'Keep your food method and turn on reminders whenever you need them.',
162        'diet.method.difficulty.기본': 'Basic',
163        'diet.method.difficulty.쉬움': 'Easy',
164        'diet.method.difficulty.보통': 'Moderate',
165        'diet.method.difficulty.도전': 'Challenging',
166        'diet.window.title': 'My eating window',
167        'diet.window.save': 'Save',
168        'diet.window.needMethod': 'Choose a food method first to set your eating window.',
169        'diet.window.helperFasting': 'Eat only within this window. Pick a start time and the end is set 8 hours later. Reminders: {plan}',
170        'diet.window.helperGeneral': 'We send reminders around these times. Reminders: {plan}',
171        'diet.window.saved': '✅ Eating window saved: {start}–{end}.',
172        'diet.window.stepError': 'Please pick times in 30-minute steps.',
173        'diet.window.minError': 'Please set an eating window of at least {hours} hours.',
174        'diet.window.invalid': 'Please check the times again.',
175        'invite.landingTitle': '🎁 A friend invited you',
176        'mind.gratitudePlaceholder': 'Write down three things you felt grateful for after meditating.',
177        'login.filmLabel': 'A 30-second story · The Breakfast Table',
178        'login.filmAria': 'Watch the 30-second story "The Breakfast Table"',
179        'login.filmClose': 'Close video',
180        'login.film2Label': "A 45-second story · Dad's Bike",
181        'login.film2Aria': "Watch the 45-second story \"Dad's Bike\"",
182        'login.film3Label': "A 45-second story · Mom's Blood Sugar",
183        'login.film3Aria': "Watch the 45-second story \"Mom's Blood Sugar\"",
184        'login.tagline': 'Doctors handle the emergencies.<br>Staying well is yours to keep.<br><strong>A gentler way to build good habits.</strong>',
185        'consent.all': 'Agree to all',
186        'consent.required': 'Required',
187        'consent.optional': 'Optional',
188        'consent.terms': '<a href="/en/terms" target="_blank" rel="noopener" onclick="openConsentDoc(event, this)">Terms of Service</a>',
189        'consent.privacy': '<a href="/en/privacy" target="_blank" rel="noopener" onclick="openConsentDoc(event, this)">Collection and use of personal data</a>',
190        'consent.age': 'I am 14 years of age or older',
191        'reconsent.badge': 'Updated terms',
192        'reconsent.title': 'Our Terms and Privacy Policy have changed',
193        'reconsent.copy': 'Please agree to the points below to keep using Habit School.',
194        'reconsent.change1': '<strong>The minimum age is now 14</strong> (previously stated as 18).',
195        'reconsent.change2': '<strong>Redeeming a coupon sends your mobile number</strong> to the delivery agency (KT Alpha).',
196        'reconsent.change3': '<strong>Deleting your account now erases everything</strong>, with nothing kept back.',
197        'reconsent.change4': '<strong>Photos and videos are stored in the United States</strong>; logs and figures stay in Korea.',
198        'reconsent.change5': '<strong>Habit Coach chat</strong> forwards only ! commands — ordinary conversation is never sent.',
199        'reconsent.note': 'You can decline the optional item and carry on. Only the blood test and body composition features are locked.',
200        'reconsent.later': 'Log out',
201        'reconsent.agree': 'Agree and continue',
202        // 첫 가입 동의 화면 전용 — 기존 회원의 약관 개정 화면과 다른 문구를 쓴다.
203        'reconsent.signup.badge': 'Before you start',
204        'reconsent.signup.title': 'One quick step before you begin',
205        'reconsent.signup.copy': 'Agree to the required items below to get started.',
206        'reconsent.signup.later': 'Cancel',
207        'consent.sensitive': 'Health data (blood test, body composition)',
208        'consent.note': 'Optional items can be declined. Only blood test and body composition features are locked.',
209        'invite.landingDesc': 'Start here to connect with your friend and get 200P.',
210        'login.point1': '📸 Upload a meal photo and AI analyzes it',
211        'login.point2': '☕ Record to earn points and get a coffee',
212        'login.point3': '🎁 Start now for a 200P welcome gift',
213        'invite.point1': '📸 Upload a meal photo and AI analyzes it',
214        'invite.point2': '☕ Record to earn points and get a coffee',
215        'invite.point3': '🤝 Cheer each other on with the friend who invited you',
216        'sharePrompt.kicker': 'Today, wrapped up',
217        'sharePrompt.title': 'Your whole day fits on one card',
218        'sharePrompt.kickerFull': 'Full routine done',
219        'sharePrompt.titleFull': 'You covered food, movement and mind today',
220        'sharePrompt.desc': 'The card carries a join QR code. When a friend joins and records for 3 days, 500P comes back to you.',
221        'sharePrompt.later': 'Later',
222        'sharePrompt.share': 'Share',
223        // 토스트/confirm — 영어 모드에서 보이는 주요 메시지
224        'toast.offlineStepsImport': 'You\'re offline — saved as workout photo instead of step count.',
225        'toast.sleepFirstPhotoOnly': 'Only the first photo has been linked to your sleep record.',
226        'toast.sharedDietImported': 'Imported {count} shared photo(s) to diet.',
227        'toast.sharedExerciseImported': 'Shared photo imported as workout.',
228        'toast.sharedBodyImported': 'Body composition values filled in. Check and tap Save.',
229        'toast.sharedSleepImported': 'Shared photo linked to sleep record.',
230        'toast.dietMethodSaveError': 'There was a problem saving the food method. Please try again.',
231        'toast.coachMessageOn': 'Habit Coach messages are on.',
232        'toast.coachMessageOff': 'Habit Coach messages are off.',
233        'toast.coachMessageSaveError': 'Could not save the setting. Please try again.',
234        'toast.loginRequiredShort': 'Please sign in first.',
235        // 사진 날짜 불일치 다이얼로그
236        'photoDate.mismatchAlert': '⚠️ The photo date ({date}) does not match the log date ({logDate}). Upload is not allowed.',
237        'photoDate.mismatchConfirm': '⚠️ The file date ({date}) does not match the log date ({logDate}). Upload anyway?',
238        // 건강정보 동의 철회 confirm
239        'consent.revokeConfirm': 'Revoke health data consent?\n\nBody composition, medication, and blood test features will be disabled.\nRecords already saved can be deleted separately in your profile.',
240        'toast.sensitiveConsentRevoked': 'Health data consent revoked.',
241        'bloodTest.title': '🩸 Blood test results',
242        'bloodTest.desc': 'Upload your checkup or blood test results as a photo, PDF, or Excel file and AI reads the numbers for you.',
243        'bloodTest.pickPhoto': '🖼️ Choose photo',
244        'bloodTest.takePhoto': '📷 Take photo',
245        'bloodTest.pickFile': '📄 Upload PDF · Excel',
246        'bloodTest.tip': '💡 <strong>Tip:</strong> Make sure the numbers are sharp in the photo. For several pages, upload them one at a time.<br>📄 PDFs (up to 15MB) and Excel files (xlsx · xls · csv) from your clinic can be uploaded as they are.<br>🏥 Checkup reports and hospital lab reports both work.',
247        // 탭 버튼 aria-label
248        'tab.diet': 'Diet',
249        'tab.exercise': 'Exercise',
250        'tab.mind': 'Mind',
251        'tab.dashboard': 'My records',
252        'tab.assets': 'Assets',
253        'tab.gallery': 'Gallery',
254        // 기타 UI 라벨
255        'bugReport.ariaLabel': 'Report a bug',
256        'sleepHours.label': '🌙 Last night\'s sleep (hours)',
257        'sleepHours.placeholder': 'e.g. 7.5',
258        // 체성분 체중계 블록 (식단 탭)
259        'bodyComp.scale.title': '🧬 If you use a body composition scale',
260        'bodyComp.scale.share': '📷 Share Fitdays result',
261        'bodyComp.scale.library': '🖼️ Choose a result photo',
262        'bodyComp.scale.camera': '📷 Take a photo',
263        'bodyComp.scale.hint': 'In Fitdays, tap Share → Habit School on the results screen to fill in automatically.',
264        'toast.bodyCompNotDetected': 'This doesn\'t look like a body composition results screen. Please take a photo of the scale screen or Fitdays results.',
265        // 로그인 화면 Terms/Privacy 링크
266        'login.terms': 'Terms of Service',
267        'login.privacy': 'Privacy Policy',
268        // 프로필 탭 Terms/Privacy
269        'profile.terms': 'Terms',
270        'profile.privacy': 'Privacy',
271        'common.cancel': 'Cancel',
272        'common.gotIt': 'Got it',
273        'tab.menu': 'Main menu',
274        'sleepHours.note': 'Used for the sleep part of your healthy habits score. It may al
274so fill in automatically when you upload a screenshot and run AI analysis.',
275        'meditation.methodList': 'Choose a meditation method',
276        // 로그인 화면: 인앱 브라우저 안내
277        'webview.title': 'Google sign-in is limited in in-app browsers',
278        'webview.body': 'Google’s security policy blocks sign-in inside in-app browsers such as KakaoTalk or Instagram.<br>Please open this page in <strong>Chrome or Safari</strong>.',
279        'webview.openExternal': '🌐 Open in browser',
280        'webview.copyLink': '📋 Copy link',
281        // 공유 가져오기(다른 앱에서 공유)
282        'sharedImport.kicker': 'Shared import',
283        'sharedImport.title': 'Where should this photo go?',
284        'sharedImport.diet.title': 'Food',
285        'sharedImport.diet.desc': 'Placed in an empty meal slot',
286        'sharedImport.exercise.title': 'Exercise',
287        'sharedImport.exercise.desc': 'Step screenshot or workout photo',
288        'sharedImport.sleep.title': 'Sleep',
289        'sharedImport.sleep.desc': 'Linked as a sleep screenshot',
290        'sharedImport.body.title': 'Body composition',
291        'sharedImport.body.desc': 'Reads a scale or Fitdays result',
292        // 오류 제보
293        'bugReport.title': '🐞 Report a problem',
294        'bugReport.copy': 'Tell us what happened: which screen you were on, what you tapped, and what went wrong. That helps us fix it fastest.',
295        'bugReport.placeholder': 'e.g. I uploaded a video in the Exercise tab, but it keeps saying the upload is delayed and will not save.',
296        'bugReport.screenshot': '📷 Screenshot (optional)',
297        'bugReport.note': 'Your device, browser, app version and recent error logs are sent along. Your records and photos are not included.',
298        'bugReport.send': 'Send',
299        // 공유 창
300        'share.modalTitle': 'Share ✨',
301        'share.previewTitle': 'Today’s Habit School check-in 🌞',
302        'share.previewText': 'Show your friends<br>your healthy habits!',
303        'share.tip': '<strong>On mobile</strong>, system sharing is easiest. <span>On a computer, save the image and copy the caption, then post them in the platform window.</span>',
304        'share.openPlatform': 'Open a platform',
305        'share.platform.kakao': 'KakaoTalk',
306        'share.platform.instagram': 'Instagram',
307        'share.platform.facebook': 'Facebook',
308        'share.platform.blog': 'Blog',
309        'share.quick': 'Quick actions',
310        'share.action.system': 'System share',
311        'share.action.caption': 'Copy caption',
312        'share.action.link': 'Copy link',
313        'share.action.save': 'Save image',
314        // 첫 로그인: 시작할 습관 고르기
315        'onboarding.kicker': 'Getting started',
316        'onboarding.title': 'Pick one habit to start with',
317        'onboarding.desc': 'Start with one habit today. You can add the others whenever you like.',
318        'onboarding.groupLabel': 'Choose your first habit',
319        'onboarding.diet.title': 'Meal photos',
320        'onboarding.diet.desc': 'Start with one photo',
321        'onboarding.exercise.title': 'Exercise',
322        'onboarding.exercise.desc': 'Steps or workout media',
323        'onboarding.sleep.title': 'Mind',
324        'onboarding.sleep.desc': 'Sleep, meditation, gratitude',
325        'onboarding.gift.title': '200P welcome gift',
326        'onboarding.gift.desc': 'Given on top of your first log points.',
327        'onboarding.start': 'Get started',
328        // 첫 기록 결과
329        'firstRecord.kicker': 'First log done',
330        'firstRecord.title': 'You finished your first step toward this week’s health goal.',
331        'firstRecord.earned': 'Points for this log',
332        'firstRecord.welcome': 'Welcome gift',
333        'firstRecord.current': 'Current points',
334        'firstRecord.remaining': 'To 2,000P',
335        'firstRecord.finish': 'Done for today',
336        'firstRecord.continue': 'Continue'
337    }
338};
339
340const ENGLISH_TEXT_REPLACEMENTS = new Map([
341    ['AI 분석', 'AI analysis'],
342    ['분석 보기', 'View analysis'],
343    ['분석 중...', 'Analyzing...'],
344    ['사진 올리기', 'Upload photo'],
345    ['캡처 올리기', 'Upload screenshot'],
346    ['운동 이미지 올리기', 'Upload workout photo'],
347    ['운동 영상 올리기', 'Upload workout video'],
348    ['운동 이미지 등록', 'Add workout photo'],
349    ['운동 영상 등록', 'Add workout video'],
350    ['운동 이미지', 'Workout photo'],
351    ['운동 영상', 'Workout video'],
352    ['사진은 촬영 당일에만 등록 가능합니다.', 'Photos can only be uploaded on the day they were taken.'],
353    ['영상은 촬영 당일에만 등록 가능합니다.', 'Videos can only be uploaded on the day they were recorded.'],
354    ['삭제', 'Remove'],
355    ['X 삭제', 'X Remove'],
356    ['미달성', 'Not yet'],
357    ['입력', 'Enter'],
358    ['접기', 'Collapse'],
359    ['펼치기', 'Expand'],
360    ['시작', 'Start'],
361    ['일시정지', 'Pause'],
362    ['재개', 'Resume'],
363    ['중단', 'Stop'],
364    ['식단 저장하기', 'Save food log'],
365    ['운동 저장하기', 'Save exercise log'],
366    ['마음 저장하기', 'Save mind log'],
367    ['식단 저장하고 포인트 받기', 'Save food log'],
368    ['운동 저장하고 포인트 받기', 'Save exercise log'],
369    ['마음 저장하고 포인트 받기', 'Save mind log'],
370    ['2일 이상 지난 날짜에 저장해도 포인트는 올라가지 않습니다.', 'Past-date edits are saved, but points are not added.']
371]);
372
373const SELECTOR_TEXTS = [
374    ['.skip-to-content', 'Skip to main content'],
375    ['#loginBtn', 'Start with Google'],
376    // 로그인 화면이 /en 에서도 뜨게 되면서, 여기 한국어가 그대로 보였다.
377    ['#login-modal .login-title', 'Habit School'],
378    ['#simple-mode-default-btn', '한국어'],
379    ['button[onclick*="openTab(\'diet\')"]', '🥗 Food'],
380    ['button[onclick*="openTab(\'exercise\')"]', '🏃 Exercise'],
381    ['button[onclick*="openTab(\'sleep\')"]', '🧘 Mind'],
382    ['button[onclick*="openTab(\'profile\')"]', '👤 Profile'],
383    ['#diet .record-flow-kicker', 'Guide'],
384    ['#diet .simple-mode-record-title', 'Log your food today'],
385    ['#diet-guide-body h3', 'Save up to 4 meal photos.'],
386    ['#diet-guide-status', 'Add your first meal photo to start today’s food log.'],
387    ['#diet-guide-badge', '0/4'],
388    ['#diet-guide-body .record-flow-actions button:nth-child(1)', 'Take photo'],
389    ['#diet-guide-body .record-flow-actions button:nth-child(2)', 'Choose photo'],
390    ['#diet-guide-body .record-flow-actions button:nth-child(3)', 'Enter fasting metrics'],
391    ['#diet .card h3', '🍽️ Meal photos'],
392    ['#diet-box-breakfast .upload-area > span:first-child', 'Meal 1'],
393    ['#diet-box-lunch .upload-area > span:first-child', 'Meal 2'],
394    ['#diet-box-dinner .upload-area > span:first-child', 'Meal 3'],
395    ['#diet-box-snack .upload-area > span:first-child', 'Meal 4'],
396    ['#txt-breakfast', 'Upload photo'],
397    ['#txt-lunch', 'Upload photo'],
398    ['#txt-dinner', 'Upload photo'],
399    ['#txt-snack', 'Upload photo'],
400    ['#ai-btn-breakfast', '✨ AI analysis'],
401    ['#ai-btn-lunch', '✨ AI analysis'],
402    ['#ai-btn-dinner', '✨ AI analysis'],
403    ['#ai-btn-snack', '✨ AI analysis'],
404    ['.card-fasting h3', '📈 Morning fasting metrics'],
405    ['#fasting-graph-card h3', '📊 Fasting trends (last 30 days)'],
406    ['#exercise .record-flow-kicker', 'Guide'],
407    ['#exercise .simple-mode-record-title', 'Log your exercise today'],
408    ['#exercise-guide-body h3', 'Steps, workout photos, or workout videos all count.'],
409    ['#exercise-guide-status', 'Add steps, a workout photo, or a workout video to save today’s exercise log.'],
410    ['#exercise-guide-badge', '0 ready'],
411    ['#exercise-guide-body .record-flow-actions button:nth-child(1)', 'Enter steps'],
412    ['#exercise-guide-body .record-flow-actions button:nth-child(2)', 'Workout photo'],
413    ['#exercise-guide-body .record-flow-actions button:nth-child(3)', 'Workout video'],
414    ['#exercise .quest-item span:first-child', '🏃 Cardio/steps 10+5P, strength 10+5P'],
415    ['#quest-exercise', 'Not yet'],
416    ['#step-card h3', '👣 Today’s steps'],
417    ['#exercise-cardio-title', '📸 Workout photo'],
418    ['#exercise-cardio-date-note', '📸 Photos can only be uploaded on the day they were taken.'],
419    ['#exercise-strength-title', '📹 Workout video'],
420    ['#exercise-strength-date-note', '📹 Videos can only be uploaded on the day they were recorded.'],
421    ['#exercise-strength-date-note + .guide-btn', '📸 Easy 10-second hy
421perlapse guide'],
422    ['#sleep .record-flow-kicker', 'Guide'],
423    ['#sleep .simple-mode-record-title', 'Log your mind today'],
424    ['#sleep-guide-body h3', 'Reset with sleep, meditation, and gratitude.'],
425    ['#mind-guide-status', 'Try a sleep screenshot, meditation, or gratitude journal.'],
426    ['#mind-guide-badge', '0 ready'],
427    ['#sleep-guide-body .record-flow-actions button:nth-child(1)', 'Sleep screenshot'],
428    ['#sleep-guide-body .record-flow-actions button:nth-child(2)', 'Start meditation'],
429    ['#sleep-guide-body .record-flow-actions button:nth-child(3)', 'Gratitude journal'],
430    ['#sleep .quest-item span:first-child', '🧘 Mind/sleep log (10P each, up to 20P)'],
431    ['#quest-mind', 'Not yet'],
432    ['#sleep .upload-cta-text strong', 'Upload a sleep screenshot'],
433    ['#sleep .upload-cta-text span', 'AI will analyze your sleep pattern and suggest improvements.'],
434    ['#sleep .card h3', '🌙 Sleep analysis'],
435    ['#sleep-date-note', '📸 Photos can only be uploaded on the day they were taken.'],
436    ['#txt-sleep', 'Upload screenshot'],
437    ['#ai-btn-sleep', '✨ AI analysis'],
438    ['#meditation-card-title', '🧘 Today’s meditation'],
439    ['#meditation-practice-card .meditation-practice-kicker', 'Breathing guide'],
440    ['.meditation-common-note', 'Sit comfortably. Do not force it. Stop if you feel dizzy.'],
441    ['.meditation-journal-label', 'Three-line gratitude journal after meditation'],
442    ['#gratitude-voice-btn', 'Voice input']
443];
444
445// 화면에 같은 요소가 여러 벌 있는 곳(버전 스위처는 로그인 화면과 프로필 탭에 하나씩).
446// setText 는 첫 번째만 바꾸므로, 전부 바꿔야 하는 선택자는 여기 둔다.
447// (기본 목록을 통째로 querySelectorAll 로 바꾸면 '#diet .card h3' 같은 선택자가
448//  의도치 않은 요소까지 덮어쓴다.)
449const SELECTOR_TEXTS_ALL = [
450    ['.version-switcher [data-version="ko"]', 'Full'],
451    ['.version-switcher [data-version="simple"]', 'Simple'],
452    ['.version-switcher [data-version="en"]', 'English'],
453    ['.version-switcher [data-version="app"]', 'Lite'],
454    // 프로필 탭 약관·방침 링크 텍스트 (로그인 화면·프로필에 각각 있을 수 있음)
455    ['a[href="/terms.html"]', 'Terms'],
456    ['a[href="/privacy.html"]', 'Privacy']
457];
458
459const SELECTOR_ATTRS = [
460    // 화면에 보이는 글자는 다 영어인데 스크린리더가 읽는 값만 한국어로 남아 있었다.
461    ['#login-modal > img', 'alt', 'Habit School logo'],
462    ['#invite-landing-card', 'alt', 'Record card shared by the friend who invited you'],
463    ['#loginBtn', 'aria-label', 'Sign in with Google'],
464    ['.version-switcher', 'aria-label', 'App version'],
465    ['#guest-demo-entry-btn', 'aria-label', 'Try Habit School without signing in'],
466    ['#selected-date', 'aria-label', 'Date'],
467    ['#weight', 'placeholder', 'Weight (kg)'],
468    ['#glucose', 'placeholder', 'Fasting glucose (mg/dL)'],
469    ['#bp-systolic', 'placeholder', 'Systolic BP'],
470    ['#bp-diastolic', 'placeholder', 'Diastolic BP'],
471    ['#step-manual-input', 'placeholder', 'Enter manually'],
472    ['#sleep-img', 'aria-label', 'Upload sleep screenshot'],
473    ['#preview-breakfast', 'alt', 'Meal 1 preview'],
474    ['#preview-lunch', 'alt', 'Meal 2 preview'],
475    ['#preview-dinner', 'alt', 'Meal 3 preview'],
476    ['#preview-snack', 'alt', 'Meal 4 preview'],
477    ['#preview-sleep', 'alt', 'Sleep screenshot preview'],
478    ['#rm-breakfast', 'aria-label', 'Remove meal 1 photo'],
479    ['#rm-lunch', 'aria-label', 'Remove meal 2 photo'],
480    ['#rm-dinner', 'aria-label', 'Remove meal 3 photo'],
481    ['#rm-snack', 'aria-label', 'Remove meal 4 photo'],
482    ['#rm-sleep', 'aria-label', 'Remove sleep screenshot'],
483    ['#user-greeting', 'aria-label', 'Open profile'],
484    // 탭 버튼 aria-label (한국어가 그대로 남아 있었음)
485    ['button[onclick="openTab(\'diet\')"]', 'aria-label', 'Diet'],
486    ['button[onclick="openTab(\'exercise\')"]', 'aria-label', 'Exercise'],
487    ['button[onclick="openTab(\'sleep\')"]', 'aria-label', 'Mind'],
488    ['button[onclick="openTab(\'dashboard\')"]', 'aria-label', 'My records'],
489    ['button[onclick="openTab(\'assets\')"]', 'aria-label', 'Assets'],
490    ['#btn-tab-gallery', 'aria-label', 'Gallery'],
491    // 기록 가이드 카드 aria-label
492    ['[data-record-guide="diet"]', 'aria-label', 'Diet logging guide'],
493    ['[data-record-guide="exercise"]', 'aria-label', 'Exercise logging guide'],
494    ['[data-record-guide="sleep"]', 'aria-label', 'Mind & sleep logging guide'],
495    // 오류 제보 버튼
496    ['#bug-report-fab', 'aria-label', 'Report a bug'],
497    // 수면 시간 입력
498    ['#sleep-hours', 'placeholder', 'e.g. 7.5'],
499    ['#shared-import-preview', 'alt', 'Shared photo preview'],
500    ['#share-preview-thumb', 'alt', 'Share preview'],
501    ['#bug-report-modal .close-btn', 'aria-label', 'Close'],
502    ['#share-platform-modal .share-modal-close-x', 'aria-label', 'Close'],
503    // 프로필 탭 약관·방침 링크: 한국어 URL → 영문 URL
504    ['a[href="/terms.html"]', 'href', '/en/terms'],
505    ['a[href="/privacy.html"]', 'href', '/en/privacy']
506];
507
508function interpolate(template = '', params = {}) {
509    return String(template).replace(/\{(\w+)\}/g, (_, key) => (
510        params[key] == null ? '' : String(params[key])
511    ));
512}
513
514export function getLocale() {
515    return getRouteLocale();
516}
517
518export function isEnglishLocale() {
519    return getLocale() === ENGLISH_LOCALE;
520}
521
522export function t(key, params = {}) {
523    const locale = getLocale();
524    const table = MESSAGES[locale] || MESSAGES[DEFAULT_LOCALE];
525    const fallback = MESSAGES[DEFAULT_LOCALE] || {};
526    return interpolate(table[key] || fallback[key] || key, params);
527}
528
529export function formatDate(value = new Date(), options = {}) {
530    const date = value instanceof Date ? value : new Date(value);
531    const locale = isEnglishLocale() ? 'en-US' : 'ko-KR';
532    return new Intl.DateTimeFormat(locale, {
533        timeZone: options.timeZone || 'Asia/Seoul',
534        ...options
535    }).format(date);
536}
537
538export { buildLocalizedUrl };
539
540export function translateText(value = '') {
541    if (!isEnglishLocale()) return value;
542    const raw = String(value || '');
543    const trimmed = raw.trim();
544    if (!trimmed) return value;
545    return ENGLISH_TEXT_REPLACEMENTS.get(trimmed) || value;
546}
547
548function setText(doc, selector, value) {
549    const el = doc.querySelector(selector);
550    if (!el || el.dataset.i18nLocked === 'true') return;
551    if (String(el.textContent || '').trim() === value) return;
552    el.textContent = value;
553}
554
555function setTextAll(doc, selector, value) {
556    doc.querySelectorAll(selector).forEach((el) => {
557        if (el.dataset.i18nLocked === 'true') return;
558        if (String(el.textContent || '').trim() === value) return;
559        el.textContent = value;
560    });
561}
562
563function setAttr(doc, selector, attr, value) {
564    const el = doc.querySelector(selector);
565    if (!el) return;
566    if (el.getAttribute(attr) === value) return;
567    el.setAttribute(attr, value);
568}
569
570function replaceExactTextNodes(root) {
571    if (!root || !isEnglishLocale() || typeof document === 'undefined' || typeof document.createTreeWalker !== 'function') return;
572    const filter = window.NodeFilter || NodeFilter;
573    const walker = document.createTreeWalker(root, filter.SHOW_TEXT, {
574        acceptNode(node) {
575            const text = String(node.nodeValue || '').trim();
576            if (!text || !ENGLISH_TEXT_REPLACEMENTS.has(text)) return filter.FILTER_REJECT;
577            const parent = node.parentElement;
578            if (!parent || parent.closest('script,style,noscript,template')) return filter.FILTER_REJECT;
579            return filter.FILTER_ACCEPT;
580        }
581    });
582    const nodes = [];
583    while (walker.nextNode()) nodes.push(walker.currentNode);
584    nodes.forEach((node) => {
585        const original = String(node.nodeValue || '');
586        const trimmed = original.trim();
587        node.nodeValue = original.replace(trimmed, ENGLISH_TEXT_REPLACEMENTS.get(trimmed));
588    });
589}
590
591export function applyDomTranslations(doc = document) {
592    if (!isEnglishLocale()) return;
593
594    SELECTOR_TEXTS.forEach(([selector, value]) => setText(doc, selector, value));
595    SELECTOR_TEXTS_ALL.forEach(([selector, value]) => setTextAll(doc, selector, value));
596    SELECTOR_ATTRS.forEach(([selector, attr, value]) => setAttr(doc, selector, attr, value));
597
598    doc.querySelectorAll('[data-i18n]').forEach((el) => {
599        const key = el.getAttribute('data-i18n');
600        if (key) el.textContent = t(key);
601    });
602    // 동의 문구처럼 안에 링크가 있는 곳은 textContent로 갈아끼우면 링크가 사라진다.
603    // 값은 이 파일 안의 상수뿐이라(사용자 입력이 아니다) innerHTML로 넣어도 안전하다.
604    doc.querySelectorAll('[data-i18n-html]').forEach((el) => {
605        const key = el.getAttribute('data-i18n-html');
606        if (!key) return;
607        const html = t(key);
608        // 같은 값을 다시 넣으면 안쪽 링크가 통째로 새로 만들어진다. 누르는 도중에
609        // 그러면 클릭이 사라진 요소에 남는다.
610        if (el.innerHTML === html) return;
611        el.innerHTML = html;
612    });
613    doc.querySelectorAll('[data-i18n-placeholder]').forEach((el) => {
614        const key = el.getAttribute('data-i18n-placeholder');
615        if (key) el.setAttribute('placeholder', t(key));
616    });
617    doc.querySelectorAll('[data-i18n-aria-label]').forEach((el) => {
618        const key = el.getAttribute('data-i18n-aria-label');
619        if (key) el.setAttribute('aria-label', t(key));
620    });
621
622    ['diet', 'exercise', 'sleep', 'profile', 'submit-bar'].forEach((id) => {
623        replaceExactTextNodes(doc.getElementById(id));
624    });
625}
626
627let localeObserver = null;
628let localeTranslateQueued = false;
629
630export function markLocaleReady(doc = document) {
631    doc.documentElement?.classList.remove('locale-loading');
632}
633
634export function installLocaleDomObserver(doc = document) {
635    if (!isEnglishLocale() || localeObserver || typeof MutationObserver !== 'function') {
636        markLocaleReady(doc);
637        return;
638    }
639    const run = () => {
640        localeTranslateQueued = false;
641        applyDomTranslations(doc);
642        markLocaleReady(doc);
643    };
644    localeObserver = new MutationObserver(() => {
645        if (localeTranslateQueued) return;
646        localeTranslateQueued = true;
647        requestAnimationFrame(run);
648    });
649    localeObserver.observe(doc.body || doc.documentElement, {
650        childList: true,
651        subtree: true,
652        characterData: true
653    });
654    run();
655}
656
657if (typeof window !== 'undefined') {
658    window.HabitSchoolI18n = {
659        getLocale,
660        t,
661        formatDate,
662        buildLocalizedUrl,
663        applyDomTranslations,
664        translateText,
665        isEnglishLocale
666    };
667}

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.