-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathhyufa13.html
More file actions
1829 lines (1598 loc) · 96.9 KB
/
hyufa13.html
File metadata and controls
1829 lines (1598 loc) · 96.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HYUFA 금융 비서</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<style>
/* 수정: Noto Sans KR 폰트 임포트 */
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@100..900&display=swap');
body {
/* 수정: font-family를 Noto Sans KR로 변경 */
font-family: 'Noto Sans KR', sans-serif;
background-color: #f3f4f6;
}
@supports (padding: max(0px)) {
.safe-bottom {
padding-bottom: max(env(safe-area-inset-bottom), 1rem);
}
}
/* HYU Color Palette */
.hyu-blue { background-color: #0E4A84; }
.text-hyu-blue { color: #0E4A84; }
.border-hyu-blue { border-color: #0E4A84; }
.hyu-silver { background-color: #898C8E; }
.hyu-green { background-color: #7DB928; }
.text-hyu-green { color: #7DB928; } /* 포트폴리오 색상 오류 수정용 클래스 추가 */
.hyu-orange { background-color: #F08100; }
.text-hyu-orange { color: #F08100; } /* 포트폴리오 색상 오류 수정용 클래스 추가 */
.hyu-coral { background-color: #FF8672; }
/* HYU Gold added for feedback stars */
.hyu-gold { color: #88774F; }
.text-hyu-gold { color: #88774F; } /* 포트폴리오 색상 오류 수정용 클래스 추가 */
.border-hyu-gold { border-color: #88774F; }
.border-hyu-green { border-color: #7DB928; }
.border-hyu-orange { border-color: #F08100; }
/* Custom scrollbar for chat history */
#chat-history::-webkit-scrollbar {
width: 8px;
}
#chat-history::-webkit-scrollbar-thumb {
background-color: #a0aec0;
border-radius: 10px;
}
#chat-history::-webkit-scrollbar-track {
background-color: #f7fafc;
}
/* --- hyufa5_지안언니.html Menu UI Styles 추가 시작 --- */
.shadow-soft {
box-shadow: 0 10px 40px rgba(15, 23, 42, 0.08);
}
.glass-card {
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(14px);
}
.menu-card {
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.menu-card:hover {
transform: translateY(-4px);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.18);
}
/* --- hyufa5_지안언니.html Menu UI Styles 추가 끝 --- */
</style>
</head>
<body class="flex items-center justify-center min-h-[100dvh] bg-gray-50 overflow-y-auto">
<div id="app-container" class="w-full max-w-md min-h-[100dvh] bg-white shadow-2xl rounded-xl flex flex-col">
<script type="module">
// --- 1. Firebase & Global Variable Setup ---
// Global variables provided by the Canvas environment
const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-app-id';
const firebaseConfig = typeof __firebase_config !== 'undefined' ? JSON.parse(__firebase_config) : null;
const initialAuthToken = typeof __initial_auth_token !== 'undefined' ? __initial_auth_token : null;
const apiKey = ""; // API key for Gemini
// Firebase Imports (MUST use CDN URLs)
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
import { getAuth, signInAnonymously, signInWithCustomToken, onAuthStateChanged } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js";
import { getFirestore, doc, addDoc, collection, onSnapshot, query, setLogLevel, serverTimestamp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
// 입력 중에도 천 단위 콤마 유지 + 커서 위치 보존
function formatNumberInput(el) {
const selectionStart = el.selectionStart;
const selectionEnd = el.selectionEnd;
const oldLength = el.value.length;
// 숫자만 추출
const numericValue = el.value.replace(/[^\d]/g, '');
// 빈 값일 경우 처리
if (numericValue === '') {
el.value = '';
return;
}
// 콤마 추가
el.value = Number(numericValue).toLocaleString();
// 커서 위치 재조정
const newLength = el.value.length;
const diff = newLength - oldLength;
el.setSelectionRange(selectionStart + diff, selectionEnd + diff);
}
// Gemini API Configuration
const GEMINI_MODEL = 'gemini-2.5-flash-preview-09-2025';
// **수정: 새로운 systemPrompt가 적용되었습니다.**
const systemPrompt = `
당신은 대학생·사회초년생을 위한 '은행 금융상담 챗봇'입니다.
[역할]
- 당신은 은행 창구 직원 또는 PB처럼 전문적이고 신뢰감 있는 말투를 사용합니다.
- 다만 상대는 금융이 익숙하지 않은 20대이므로, 어려운 용어는 풀어서 쉽게 설명합니다.
- 투자상품을 '권유'하거나, '수익을 보장'하는 표현은 절대 사용하지 않습니다.
[지식 활용 원칙]
- 사용자가 질문하면, 먼저 제공된 Knowledge(JSON)의 질문·답변 목록에서 의미상 가장 가까운 항목을 찾습니다.
- 찾은 항목의 'answer' 내용을 기반으로, 4·6줄 분량의 대화형 설명으로 재구성하여 답변합니다.
- Knowledge에 해당 내용이 전혀 없다고 판단되면, 다음과 같이 답합니다.
→ "해당 내용은 제가 가지고 있는 자료에 없어요. 다른 금융 질문을 해주시면 도와드릴게요."
[답변 스타일]
1) 한 번의 답변은 4·6줄 정도의 길이로, 핵심만 명확하게 전달합니다.
2) "~예요, ~합니다"와 같은 공손하고 부드러운 말투를 사용합니다.
3) 리스크가 있는 내용(투자, 대출 등)은 장단점을 함께 설명하고, 최종 선택은 사용자에게 맡깁니다.
4) 사용자의 상황을 가볍게 되물어보며(필요할 때만), 더 도움이 될 수 있는 추가 정보를 제안합니다.
이 규칙을 모든 답변에서 일관되게 적용합니다.
`;
// Application State
// **수정: 새로운 상태 GOAL_SETTING, PORTFOLIO_EDIT 추가**
let appState = 'LOADING'; // WELCOME, CONSENT, MAIN_CHAT, MENU, GUIDE, FAQ, GOAL_SETTING, PORTFOLIO, PORTFOLIO_EDIT, CALCULATOR, CUSTOMER_CENTER, FEEDBACK
let db;
let auth;
let userId = null;
// ----------------------------------------------------------------------
// HYUFA Chatbot Topic Tree and Chat History Initialization
// ----------------------------------------------------------------------
// Define a hierarchical set of main topics, each with subtopics and example prompts.
const TOPIC_TREE = {
saving: {
mainLabel: '예·적금 / 목돈 모으기',
intro: '예·적금과 목돈 모으기 중 어떤 것이 궁금한가요?\n아래에서 더 구체적인 주제를 골라보세요.',
subOptions: [
{
label: '1억 모으기 전체 전략',
prompt: '대학생이 1억을 최대한 빨리 모으고 싶을 때, 현실적인 전략과 단계별 계획을 알려줘.'
},
{
label: '월별 저축액 계산법',
prompt: '목표 금액과 기간이 정해졌을 때, 월별로 얼마나 저축해야 하는지 계산하는 방법과 예시를 알려줘.'
},
{
label: '적금/예금 상품 선택 기준',
prompt: '대학생/사회초년생 기준으로 적금·예금 상품을 고를 때 금리, 기간, 세금 등을 어떻게 비교해야 할지 알려줘.'
}
]
},
housing: {
mainLabel: '자취 / 월세·전세 / 보증금',
intro: '자취와 주거 비용 중 어떤 부분이 궁금한가요?\n아래에서 더 구체적인 주제를 골라보세요.',
subOptions: [
{
label: '월세 vs 전세 비교',
prompt: '대학생 자취 기준에서 월세와 전세의 차이, 장단점, 어떤 상황에 무엇을 선택하면 좋을지 알려줘.'
},
{
label: '보증금·관리비 숨은 비용',
prompt: '원룸/오피스텔 계약 시 보증금, 관리비, 기타 숨은 비용이 어떻게 나오는지, 주의할 점을 알려줘.'
},
{
label: '계약 전 체크리스트',
prompt: '자취방 계약 전에 반드시 확인해야 할 체크리스트(하자, 옵션, 계약서 항목)를 정리해서 알려줘.'
}
]
},
card: {
mainLabel: '체크카드 · 신용카드 / 소비관리',
intro: '카드와 소비관리 중 어떤 것이 궁금한가요?\n아래에서 더 구체적인 주제를 골라보세요.',
subOptions: [
{
label: '체크 vs 신용카드 차이',
prompt: '체크카드와 신용카드의 차이, 장단점, 사회초년생이 첫 카드를 고를 때 기준을 알려줘.'
},
{
label: '카드 혜택·실적 설계',
prompt: '대학생/사회초년생에게 맞는 카드 혜택(교통, 통신, 식비 등)과 월 실적 설계 방법을 알려줘.'
},
{
label: '소비 패턴 점검하기',
prompt: '이번 달 카드 명세서를 보고 소비 패턴을 점검하고, 불필요한 지출을 줄이는 방법을 알려줘.'
}
]
},
invest: {
mainLabel: '첫 투자 (주식 · ETF)',
intro: '투자를 처음 시작한다면, 아래에서 가장 궁금한 주제를 골라보세요.',
subOptions: [
{
label: '주식 vs ETF 기초',
prompt: '주식과 ETF의 차이, 각각의 장단점, 초보자가 ETF부터 시작하는 이유를 설명해줘.'
},
{
label: '위험 관리와 마이너스 방지',
prompt: '투자 초보가 큰 손실을 피하기 위해 꼭 지켜야 할 원칙과 리스크 관리 방법을 알려줘.'
},
{
label: '목표별 투자 전략',
prompt: '3년, 5년, 10년 등 기간별로 어떤 투자 전략을 생각할 수 있는지, 대학생 기준으로 설명해줘.'
}
]
},
loan: {
mainLabel: '학자금대출 · 대출 · 신용점수',
intro: '대출과 신용점수 중 어떤 것이 궁금한가요?\n아래에서 더 구체적인 주제를 골라보세요.',
subOptions: [
{
label: '학자금 대출 구조 이해',
prompt: '한국에서 학자금 대출이 어떻게 이자·상환이 진행되는지, 졸업 후 상환 전략까지 포함해서 설명해줘.'
},
{
label: '신용점수 관리 기본',
prompt: '20대가 신용점수를 지키기 위해 꼭 알아야 하는 기본 개념과 주의사항을 알려줘.'
},
{
label: '대출 갚는 순서·전략',
prompt: '여러 대출(학자금, 마이너스통장 등)이 있을 때, 어떤 순서와 기준으로 상환하면 좋은지 설명해줘.'
}
]
}
};
// Initialize chat history with welcome message and main topic options
let chatHistory = [
{
sender: 'ai',
text: '안녕하세요.\n인공지능 HYUFA입니다.\n무엇을 도와드릴까요?\n\n+ 아래 버튼을 눌러 궁금한 주제를 선택해보세요.',
options: Object.keys(TOPIC_TREE).map(key => ({
kind: 'main',
topicKey: key,
label: TOPIC_TREE[key].mainLabel
}))
}
];
let faqData = [];
let isChatLoading = false;
// **수정: 포트폴리오 비중 관리**
let currentPortfolio = JSON.parse(localStorage.getItem('hyufa_portfolio')) || [
{
"name": "국내 배당주 ETF",
"icon": "fa-coins",
"color": "hyu-green",
"percentage": 30,
"description": "안정적인 현금 흐름 확보",
"rate": 0.03 // 3%
},
{
"name": "미국 테크주 인덱스",
"icon": "fa-globe",
"color": "hyu-orange",
"percentage": 50,
"description": "성장 가능성이 높은 자산 투자",
"rate": 0.10 // 10% (Expected)
},
{
"name": "금/원자재 펀드",
"icon": "fa-scale-unbalanced",
"color": "hyu-gold",
"percentage": 20,
"description": "인플레이션 헤지 및 포트폴리오 안정화",
"rate": 0.07 // 7% (Expected)
},
];
// --- 기대수익률 JSON 로드해서 currentPortfolio.rate 덮어쓰기 ---
async function loadExpectedReturns() {
try {
const res = await fetch('./expected_returns.json', { cache: "no-store" });
if (!res.ok) throw new Error("returns json fetch failed");
const er = await res.json();
// saving / nasdaq / gold 값을 currentPortfolio에 주입
currentPortfolio = currentPortfolio.map(item => {
if (item.name.includes("국내 배당주") || item.name.includes("적금")) {
return { ...item, rate: er.saving ?? item.rate };
}
if (item.name.includes("미국 테크주") || item.name.includes("나스닥")) {
return { ...item, rate: er.nasdaq ?? item.rate };
}
if (item.name.includes("금/원자재") || item.name.includes("금")) {
return { ...item, rate: er.gold ?? item.rate };
}
return item;
});
console.log("✅ expected returns loaded:", er);
} catch (e) {
console.warn("expected_returns.json 로드 실패 → 기본 rate 유지", e);
}
}
// 추가: Goal 계산 결과 저장 (포트폴리오 페이지에서 활용)
let goalCalculationResult = null;
// --- 2. Utility Functions ---
/**
* Simple function to set the application state and re-render.
* @param {string} newState The new state to set.
*/
function setAppState(newState) {
appState = newState;
renderApp();
}
/**
* Display a message box (replacement for alert/confirm).
* @param {string} message The message to display.
*/
function showMessage(message) {
const container = document.getElementById('app-container');
const overlay = document.createElement('div');
overlay.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
overlay.innerHTML = `
<div class="bg-white p-6 rounded-xl shadow-2xl max-w-xs text-center">
<p class="text-gray-700 font-semibold mb-4">${message}</p>
<button id="close-msg" class="w-full hyu-blue text-white py-2 rounded-lg font-bold">확인</button>
</div>
`;
container.appendChild(overlay);
document.getElementById('close-msg').onclick = () => {
container.removeChild(overlay);
};
}
// --- 3. Data & Firebase Setup ---
/**
* Initialize Firebase, sign in, and set up state.
*/
async function initializeFirebase() {
if (!firebaseConfig) {
console.error("Firebase config is missing.");
setAppState('WELCOME'); // Fallback to welcome if config is missing
return;
}
try {
// setLogLevel('debug'); // Uncomment for debugging
const app = initializeApp(firebaseConfig);
db = getFirestore(app);
auth = getAuth(app);
// Use initial auth token if provided, otherwise sign in anonymously
if (initialAuthToken) {
await signInWithCustomToken(auth, initialAuthToken);
} else {
await signInAnonymously(auth);
}
onAuthStateChanged(auth, (user) => {
if (user) {
userId = user.uid;
console.log("Firebase signed in. User ID:", userId);
// Check for consent (simulate simple session consent check)
if (localStorage.getItem('hyufa_consent') === 'agreed') {
setAppState('MAIN_CHAT');
} else {
setAppState('WELCOME');
}
} else {
userId = 'anonymous';
console.warn("Firebase signed out. Using anonymous ID.");
setAppState('WELCOME');
}
});
} catch (error) {
console.error("Firebase initialization or sign-in failed:", error);
showMessage("Firebase 연결에 실패했습니다. (Error: " + error.code + ")");
setAppState('WELCOME');
}
}
/**
* Load FAQ data from the uploaded JSON file.
*/
async function loadFAQData() {
try {
// Simulate fetch of the uploaded file content
const contentId = "uploaded:finance_chatbot_knowledge_104qa.json";
const response = await fetch(`/api/files/download?contentFetchId=${contentId}`);
if (!response.ok) throw new Error('Failed to fetch FAQ data');
faqData = await response.json();
console.log(`Loaded ${faqData.length} FAQ items.`);
} catch (error) {
console.error("Error loading FAQ data:", error);
faqData = [
{ category: "오류", question: "FAQ 로딩 실패", answer: "FAQ 데이터를 불러오는 데 실패했습니다. 콘솔을 확인해주세요." },
// Mock data if file loading fails
{ category: "1. 금융 상식", question: "예·적금 차이", answer: "예금은 언제든지 넣고 뺄 수 있는 대신 금리가 낮고, 적금은 매달 일정 금액을 넣는 대신 금리가 조금 더 높아요." }
];
}
}
// --- 4. Gemini API Logic ---
/**
* Calls the Gemini API for chat response.
* @param {string} prompt The user's query.
*/
// ----------------------------------------------------------------------
// Core chat logic: askHYUFA handles only assistant responses.
// User messages should be added via sendUserMessage().
async function askHYUFA(prompt) {
if (isChatLoading) return;
const chatHistoryElement = document.getElementById('chat-history');
if (!chatHistoryElement) return;
// Add a loading placeholder for AI response
const loadingMessage = {
sender: 'ai',
text: '... 답변을 생성 중입니다.',
loading: true
};
chatHistory.push(loadingMessage);
renderChatHistory();
chatHistoryElement.scrollTop = chatHistoryElement.scrollHeight;
isChatLoading = true;
let responseText = '죄송합니다. 서비스에 일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
try {
// Prepare history for backend (exclude loading message)
const historyForServer = chatHistory
.filter(m => m !== loadingMessage && (m.sender === 'user' || m.sender === 'ai'))
.map(m => ({
role: m.sender === 'user' ? 'user' : 'assistant',
text: m.text || ''
}));
// Call backend /chat endpoint
const response = await fetch('https://hyufa.onrender.com/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt,
history: historyForServer
})
});
if (response.ok) {
const data = await response.json();
if (data && typeof data.reply === 'string') {
responseText = data.reply;
} else {
console.error('Unexpected backend response:', data);
}
} else {
const errText = await response.text();
console.error('Backend error:', response.status, errText);
}
} catch (error) {
console.error('Error calling backend /chat:', error);
}
// Replace loading message with AI response
const idx = chatHistory.indexOf(loadingMessage);
if (idx !== -1) {
chatHistory.splice(idx, 1);
}
chatHistory.push({ sender: 'ai', text: responseText });
isChatLoading = false;
renderChatHistory();
chatHistoryElement.scrollTop = chatHistoryElement.scrollHeight;
}
/**
* Push a user message into chat history, render it, and trigger HYUFA response.
* This function centralizes handling of user messages from both text input and buttons.
* @param {string} prompt The user's query or selected prompt.
*/
function sendUserMessage(prompt) {
const chatHistoryElement = document.getElementById('chat-history');
if (!chatHistoryElement) return;
// Add the user's message to chat history
chatHistory.push({ sender: 'user', text: prompt });
renderChatHistory();
chatHistoryElement.scrollTop = chatHistoryElement.scrollHeight;
// Ask HYUFA for a response
askHYUFA(prompt);
}
/**
* Handle option button clicks. Depending on the kind and topic key, this function either
* reveals subtopic options or sends a user message directly. Exposed globally via window.
* @param {string} kind The kind of option: 'main' or 'sub'.
* @param {string} topicKey The key of the topic in TOPIC_TREE when kind is 'main'.
* @param {string} prompt The prompt associated with a sub-topic option.
*/
window.handleOptionClick = function(kind, topicKey, prompt) {
const chatHistoryElement = document.getElementById('chat-history');
if (!chatHistoryElement) return;
// If main topic selected, show its subOptions
if (kind === 'main' && topicKey && TOPIC_TREE[topicKey]) {
const topic = TOPIC_TREE[topicKey];
chatHistory.push({
sender: 'ai',
text: topic.intro,
options: topic.subOptions.map(sub => ({
kind: 'sub',
topicKey,
label: sub.label,
prompt: sub.prompt
}))
});
renderChatHistory();
chatHistoryElement.scrollTop = chatHistoryElement.scrollHeight;
return;
}
// If sub-topic selected or direct prompt provided, send user message
if (prompt) {
sendUserMessage(prompt);
}
};
// --- 5. View Rendering Logic ---
/**
* Renders the chat history messages.
*/
function renderChatHistory() {
const historyDiv = document.getElementById('chat-history');
if (!historyDiv) return;
historyDiv.innerHTML = chatHistory.map(msg => {
const isUser = msg.sender === 'user';
// Determine bubble classes for user vs AI
const bubbleClasses = isUser
? 'bg-gray-200 text-gray-800 rounded-br-none'
: 'bg-gray-100 text-gray-800 rounded-tl-none';
// Build options HTML if present
let optionsHtml = '';
if (msg.options && msg.options.length > 0) {
optionsHtml = '<div class="mt-3 flex flex-wrap gap-2">' +
msg.options.map(opt => {
// Compose onclick call with proper escaping
const kind = opt.kind || '';
const topicKey = opt.topicKey || '';
const prompt = opt.prompt ? opt.prompt.replace(/'/g, "\\'") : '';
return `<button class="px-3 py-1.5 text-xs rounded-full border border-hyu-blue text-hyu-blue bg-white hover:bg-blue-50" onclick="handleOptionClick('${kind}','${topicKey}','${prompt}')">${opt.label}</button>`;
}).join('') + '</div>';
}
return `
<div class="flex ${isUser ? 'justify-end' : 'justify-start'} mb-4">
<div class="max-w-xs sm:max-w-sm lg:max-w-md p-3 rounded-xl shadow-md ${bubbleClasses}">
${msg.loading ? '<i class="fa-solid fa-spinner fa-spin-pulse mr-2"></i>' : ''}
<p class="whitespace-pre-wrap text-sm">${msg.text}</p>
${optionsHtml}
</div>
</div>
`;
}).join('');
}
/**
* Renders the main HYUFA application shell.
*/
function renderApp() {
const container = document.getElementById('app-container');
// 1. Render Header (Always present on chat/menu screens)
const header = `
<div class="hyu-blue p-4 text-white shadow-lg flex justify-between items-center">
<h1 class="text-xl font-bold">HYUFA</h1>
${appState !== 'WELCOME' && appState !== 'CONSENT' ? `
<button onclick="setAppState('MAIN_CHAT')" class="text-sm border border-white px-3 py-1 rounded-full hover:bg-white hover:text-hyu-blue transition">
<i class="fa-solid fa-house"></i>
</button>
` : ''}
</div>
`;
container.innerHTML = header;
// 2. Render Content based on state
let content;
switch (appState) {
case 'WELCOME':
content = renderWelcome();
break;
case 'CONSENT':
content = renderConsent();
break;
case 'MAIN_CHAT':
content = renderMainChat();
break;
case 'MENU':
content = renderMenu();
break;
case 'GUIDE':
content = renderGuide();
break;
case 'FAQ':
content = renderFAQ();
break;
case 'GOAL_SETTING':
content = renderGoalSetting();
break;
case 'PORTFOLIO':
content = renderPortfolio();
break;
case 'PORTFOLIO_EDIT':
content = renderPortfolioEdit();
break;
case 'CALCULATOR':
content = renderCalculator();
break;
case 'CUSTOMER_CENTER':
content = renderCustomerCenter();
break;
case 'FEEDBACK':
content = renderFeedback();
break;
case 'LOADING':
default:
content = renderLoading();
break;
}
container.innerHTML += content;
// Post-render actions for specific screens
if (appState === 'MAIN_CHAT') {
renderChatHistory();
const chatHistoryElement = document.getElementById('chat-history');
if(chatHistoryElement) {
chatHistoryElement.scrollTop = chatHistoryElement.scrollHeight;
}
} else if (appState === 'FAQ') {
setupFAQToggle();
} else if (appState === 'CALCULATOR') { // <--- 계산기 오류 수정 부분 (1/2)
const currentCalc = sessionStorage.getItem('currentCalc') || '적금';
renderCalculatorForm(currentCalc); // 초기 폼 렌더링
} else if (appState === 'GOAL_SETTING') {
const currentGoalCalc = sessionStorage.getItem('currentGoalCalc') || '얼마를 모을까';
renderGoalSettingForm(currentGoalCalc);
} else if (appState === 'PORTFOLIO_EDIT') {
checkPortfolioTotal(); // 초기 총합계 계산
}
}
// --- View Specific HTML Generation ---
// #001 첫 화면
function renderWelcome() {
return `
<div class="flex flex-col items-center justify-center flex-grow p-8 text-center bg-gray-50">
<div class="text-7xl mb-4 text-hyu-blue">
<i class="fa-solid fa-piggy-bank"></i>
</div>
<h2 class="text-3xl font-extrabold text-hyu-blue mb-2">반갑습니다.</h2>
<p class="text-2xl font-bold text-gray-700 mb-8">HYUFA 입니다.</p>
<button onclick="setAppState('CONSENT')" class="hyu-blue text-white font-bold py-3 px-8 rounded-full shadow-lg hover:bg-opacity-90 transition transform hover:scale-105">
서비스 시작하기
</button>
</div>
`;
}
// #002 팝업 전환 (개인정보 수집 및 이용 동의)
function renderConsent() {
return `
<div class="flex flex-col flex-grow p-6 bg-gray-50 overflow-y-auto">
<h2 class="text-2xl font-bold text-hyu-blue mb-6 border-b pb-2">개인정보 수집 및 이용 동의</h2>
<p class="text-sm text-gray-700 mb-4">
고객님의 상담 업무를 처리하기 위해서는 개인정보보호법 제 15조 1항에 따라 아래의 내용에 대하여 고객님의 동의가 필요합니다.
</p>
<div class="bg-white p-4 rounded-lg border border-gray-200 shadow-inner mb-6 text-sm space-y-3">
<p><strong>1. 개인정보의 수집 이용 목적</strong><br>- 서비스 이용에 따른 상담 업무 처리</p>
<p><strong>2. 수집하려는 개인정보의 항목</strong><br>- 챗봇 상담내용</p>
<p><strong>3. 개인정보의 보유 및 이용 기간</strong><br>- 위 개인정보는 수집 이용에 관한 동의 이후 처리 종료일로부터 2년간 위 이용목적을 위하여 보유/이용됩니다.</p>
<p><strong>4. 안내사항</strong><br>- 동의를 거부할 권리가 있으며, 거부 시 상담에 제한될 수 있습니다.</p>
</div>
<div class="flex space-x-4 mt-auto pt-4 border-t">
<button onclick="handleConsent(false)" class="flex-1 hyu-silver text-white font-bold py-3 rounded-lg shadow-lg hover:opacity-90 transition">
비동의 (나가기)
</button>
<button onclick="handleConsent(true)" class="flex-1 hyu-blue text-white font-bold py-3 rounded-lg shadow-lg hover:opacity-90 transition">
동의
</button>
</div>
</div>
`;
}
/**
* Handle consent action.
* @param {boolean} agreed True if agreed, false otherwise.
*/
function handleConsent(agreed) {
if (agreed) {
localStorage.setItem('hyufa_consent', 'agreed');
if (chatHistory.length === 0) {
chatHistory.push({ sender: 'ai', text: `안녕하세요.\n인공지능 HYUFA입니다.\n무엇을 도와드릴까요?\n\n+ 버튼을 눌러 이용 안내를 확인해보세요.` });
}
setAppState('MAIN_CHAT');
} else {
setAppState('WELCOME');
}
}
// #003 채팅 메인 화면
function renderMainChat() {
// Expose handleChatSubmit to global scope
window.handleChatSubmit = handleChatSubmit;
window.setAppState = setAppState;
return `
<div class="flex flex-col bg-white h-screen">
<div id="chat-history" class="flex-1 p-4 overflow-y-auto bg-gray-50 pb-24">
<!-- 채팅 메시지들 -->
</div>
<div class="border-t bg-white flex items-center gap-2 px-3 py-3 sticky bottom-0 w-full z-10 safe-bottom"
style="padding-bottom: calc(env(safe-area-inset-bottom) + 4px);">
<button onclick="setAppState('MENU')"
class="flex-shrink-0 text-hyu-blue text-3xl hover:opacity-75 transition">
<i class="fa-solid fa-circle-plus"></i>
</button>
<input id="chat-input" type="text"
placeholder="궁금한 사항을 입력해주세요."
class="flex-grow p-3 border border-gray-300 rounded-full focus:outline-none focus:border-hyu-blue min-w-0">
<button id="send-button" onclick="handleChatSubmit()"
class="flex-shrink-0 hyu-blue text-white py-2 px-4 rounded-full font-bold hover:bg-opacity-90 transition">
<i class="fa-solid fa-arrow-up"></i>
</button>
</div>
</div>
`;
}
/**
* Handle chat input submission (Enter key or Send button).
* Instead of calling askHYUFA directly, we push the user message
* and then request a response from the backend via askHYUFA.
*/
function handleChatSubmit() {
const input = document.getElementById('chat-input');
const prompt = input.value.trim();
if (prompt && !isChatLoading) {
input.value = '';
sendUserMessage(prompt);
}
}
// Add Enter key listener once the DOM is ready for chat
document.addEventListener('keydown', (e) => {
if (appState === 'MAIN_CHAT' && e.key === 'Enter') {
handleChatSubmit();
}
});
// #004 + 화면 (Menu) - hyufa5_지안언니.html 코드 반영
function renderMenu() {
const menuItems = [
{ name: 'HYUFA 이용 안내', state: 'GUIDE', icon: 'fa-circle-info', color: 'bg-green-500' },
{ name: '자주하는 질문', state: 'FAQ', icon: 'fa-circle-question', color: 'bg-yellow-500' },
{ name: '목표 금액 설정', state: 'GOAL_SETTING', icon: 'fa-bullseye', color: 'bg-red-500' }, // **수정: 목표 금액 설정 추가**
{ name: '금융 계산기', state: 'CALCULATOR', icon: 'fa-calculator', color: 'bg-purple-500' },
{ name: 'HYUFA 고객센터', state: 'CUSTOMER_CENTER', icon: 'fa-headset', color: 'bg-blue-500' },
{ name: 'HYUFA 의견 남기기', state: 'FEEDBACK', icon: 'fa-comment-dots', color: 'bg-pink-500' },
]
return `
<div class="flex flex-col flex-grow bg-slate-100 p-4 overflow-y-auto">
<h2 class="text-2xl font-bold text-hyu-blue mb-6 border-b pb-2">메인 메뉴</h2>
<div class="grid grid-cols-2 gap-4">
${menuItems
.map(
(item) => `
<button onclick="setAppState('${item.state}')" class="menu-card glass-card p-4 rounded-xl shadow-soft text-center flex flex-col items-center justify-center space-y-2">
<div class="w-12 h-12 ${item.color} text-white rounded-full flex items-center justify-center text-xl shadow-md">
<i class="fa-solid ${item.icon}"></i>
</div>
<span class="text-sm font-semibold text-gray-800">${item.name}</span>
</button>
`
)
.join('')}
</div>
<div class="mt-8 p-4 bg-white rounded-xl shadow-soft text-sm text-gray-600 border border-gray-100">
<p class="font-semibold mb-2 text-hyu-blue">HYUFA는 인공지능 금융 비서입니다.</p>
<p>제공되는 정보는 참고 자료로만 활용하시고, 정확한 금융 거래는 은행 창구 또는 공식 금융기관을 통해 진행해 주시기 바랍니다.</p>
</div>
</div>
`;
}
// #005 HYUFA 이용 안내
function renderGuide() {
return `
<div class="flex flex-col flex-grow p-6 bg-gray-50 overflow-y-auto">
<h2 class="text-2xl font-bold text-hyu-blue mb-6 border-b pb-2">HYUFA 이용 안내</h2>
<section class="mb-6 bg-white p-5 rounded-xl shadow-lg border-l-4 border-hyu-blue">
<h3 class="text-xl font-semibold text-gray-800 mb-3 flex items-center"><i class="fa-solid fa-robot mr-3 text-hyu-blue"></i>HYUFA의 능력과 한계</h3>
<p class="text-sm text-gray-700 leading-relaxed">
HYUFA는 대학생 및 사회초년생에게 특화된 금융 지식을 바탕으로 상담합니다. 금융 상품 '추천'보다는 '원리 설명 및 조언'에 집중하며, 투자 권유나 수익 보장 표현은 절대 사용하지 않습니다.
</p>
</section>
<section class="mb-6 bg-white p-5 rounded-xl shadow-lg border-l-4 border-hyu-orange">
<h3 class="text-xl font-semibold text-gray-800 mb-3 flex items-center"><i class="fa-solid fa-lightbulb mr-3 text-hyu-orange"></i>이용 Tip</h3>
<ul class="text-sm list-disc pl-5 space-y-1">
<li>HYUFA에게 짧고 간단한 핵심만 질문해주세요 !</li>
<li>본 서비스는 ‘인간-인공지능 협업 제품 서비스 설계’ 수업 팀 프로젝트로, 사전 설계한 프롬프트 기반으로 작동합니다.</li>
<li>따라서 상용 AI만큼의 응답 유연성에는 일부 제한이 있으며, 본 챗봇은 사용자 경험 검증과 서비스 콘셉트 제시 목적으로 제작되었습니다.</li>
</ul>
<p class="text-center mt-3 text-sm font-semibold text-hyu-coral">
그래도 처음 금융을 접할 때 느끼는 막막함을 덜어드리기 위해 설계한 서비스로 많은 이용 부탁드립니다 ♥︎
</p>
</section>
</div>
`;
}
// #006 자주하는 질문
function renderFAQ() {
// Expose setupFAQToggle to global scope
window.setupFAQToggle = setupFAQToggle;
const categories = [...new Set(faqData.map(item => item.category))].sort();
const faqList = categories.map(category => {
const items = faqData.filter(item => item.category === category);
const itemsHtml = items.map((item, index) => `
<div class="border-b last:border-b-0">
<button class="faq-question w-full text-left py-3 px-4 font-semibold text-gray-700 hover:bg-gray-50 transition flex justify-between items-center" data-index="${category}-${index}">
${item.question}
<i class="fa-solid fa-chevron-down text-xs ml-2 transition-transform duration-300"></i>
</button>
<div id="answer-${category}-${index}" class="faq-answer max-h-0 overflow-hidden transition-all duration-500 ease-in-out bg-gray-50 px-4">
<div class="py-3 text-sm text-gray-600 whitespace-pre-wrap">${item.answer}</div>
</div>
</div>
`).join('');
return `
<div class="mb-6 bg-white rounded-xl shadow-lg overflow-hidden">
<h3 class="text-xl font-bold p-4 border-b text-hyu-orange bg-gray-100">${category}</h3>
${itemsHtml}
</div>
`;
}).join('');
return `
<div class="flex flex-col flex-grow p-6 bg-gray-50 overflow-y-auto">
<h2 class="text-2xl font-bold text-hyu-blue mb-6 border-b pb-2">자주 하는 질문 (FAQ)</h2>
<p class="text-sm text-gray-600 mb-4">궁금한 질문을 선택하시면 HYUFA의 답변을 확인할 수 있어요.</p>
${faqList}
</div>
`;
}
/**
* Setup click listener for FAQ toggles after rendering.
*/
function setupFAQToggle() {
document.querySelectorAll('.faq-question').forEach(button => {
button.onclick = () => {
const index = button.getAttribute('data-index');
const answerDiv = document.getElementById(`answer-${index}`);
const icon = button.querySelector('i');
const isOpen = answerDiv.style.maxHeight && answerDiv.style.maxHeight !== '0px';
// Close all others
document.querySelectorAll('.faq-answer').forEach(el => {
if (el.id !== `answer-${index}`) {
el.style.maxHeight = '0';
}
});
document.querySelectorAll('.faq-question i').forEach(el => {
if (el !== icon) {
el.style.transform = 'rotate(0deg)';
}
});
// Toggle current
if (isOpen) {
answerDiv.style.maxHeight = '0';
icon.style.transform = 'rotate(0deg)';
} else {
answerDiv.style.maxHeight = answerDiv.scrollHeight + 'px';
icon.style.transform = 'rotate(180deg)';
}
};
});
}
// --- #007 목표 금액 설정 (새로 추가) ---
function renderGoalSetting() {
window.selectGoalCalculator = selectGoalCalculator;
window.calculateGoal = calculateGoal;
const currentGoalCalc = sessionStorage.getItem('currentGoalCalc') || '얼마를 모을까';
return `
<div class="flex flex-col flex-grow p-6 bg-gray-50 overflow-y-auto">
<h2 class="text-2xl font-bold text-hyu-blue mb-6 border-b pb-2">목표 금액 설정</h2>
<div class="flex justify-around mb-6 bg-white p-2 rounded-full shadow-inner">
<button onclick="selectGoalCalculator('얼마를 모을까')" class="flex-1 text-center py-2 rounded-full font-semibold transition ${currentGoalCalc === '얼마를 모을까' ? 'hyu-orange text-white' : 'text-gray-600 hover:bg-gray-100'}">얼마를 모을까</button>
<button onclick="selectGoalCalculator('얼마를 넣을까')" class="flex-1 text-center py-2 rounded-full font-semibold transition ${currentGoalCalc === '얼마를 넣을까' ? 'hyu-orange text-white' : 'text-gray-600 hover:bg-gray-100'}">얼마를 넣을까</button>
</div>
<div id="goal-setting-form" class="bg-white p-6 rounded-xl shadow-lg mb-6">
</div>
<div id="goal-setting-result" class="p-5 rounded-xl text-center ${goalCalculationResult ? '' : 'hidden'} mb-6">
${goalCalculationResult ? renderGoalSettingResult(goalCalculationResult) : ''}
</div>
<div class="mt-auto">
<button onclick="setAppState('PORTFOLIO')" class="w-full hyu-blue text-white font-bold py-3 rounded-lg shadow-lg hover:opacity-90 transition">
추천 포트폴리오 보기
</button>
</div>
</div>
`;
}
/**
* Selects the goal calculator type and re-renders the form.
* @param {string} type '얼마를 모을까' or '얼마를 넣을까'.
*/
function selectGoalCalculator(type) {
// '얼마를 넣을까'는 목표 금액을 위해 필요한 월 납입액 계산 (준비 중인 기능)
// '얼마를 모을까'는 월 납입액으로 모을 수 있는 목표 금액 계산 (기존 기능 유지)
sessionStorage.setItem('currentGoalCalc', type);
// 결과를 숨김 처리하여 새로운 계산을 유도
const resultDiv = document.getElementById('goal-setting-result');
if(resultDiv) resultDiv.classList.add('hidden');
renderApp();
}
/**
* Renders the dynamic goal setting form.
* @param {string} currentGoalCalc '얼마를 모을까' or '얼마를 넣을까'.
*/
function renderGoalSettingForm(currentGoalCalc) {
const formDiv = document.getElementById('goal-setting-form');
if (!formDiv) return;
let formHtml = '';
if (currentGoalCalc === '얼마를 모을까') {
// Default values for better UX
const defaultMonths = 36;
const defaultMonthly = 300000;
formHtml = `
<h3 class="text-lg font-bold text-hyu-orange mb-4">현재의 저축 계획으로 예상되는 미래 금액은?</h3>
<div class="p-4 bg-white rounded-lg shadow space-y-4">
<label class="block mb-2 text-sm font-semibold">저축 기간 (개월)</label>
<input type="number" id="goal-input-months" class="w-full p-3 border rounded-lg focus:ring-hyu-blue focus:border-hyu-blue" value="${defaultMonths}">
<label class="block mb-2 text-sm font-semibold">매월 납입액 (원)</label>
<input type="text" id="goal-input-monthly"
oninput="formatNumberInput(this)"
class="w-full p-3 border rounded-lg focus:ring-hyu-blue focus:border-hyu-blue"
value="${defaultMonthly.toLocaleString()}">