|
|
<!DOCTYPE html> |
|
|
<html lang="th"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"> |
|
|
<title>กลุ่มม้าขาวก้าวใหม่ เบอร์ 2 - พูดแล้วทำ สำเร็จจริง</title> |
|
|
|
|
|
|
|
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script> |
|
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script> |
|
|
|
|
|
|
|
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> |
|
|
|
|
|
|
|
|
<script src="https://cdn.tailwindcss.com"></script> |
|
|
|
|
|
|
|
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Thai:wght@300;400;500;600;700&display=swap" rel="stylesheet"> |
|
|
|
|
|
<style> |
|
|
body { |
|
|
font-family: 'Noto Sans Thai', sans-serif; |
|
|
background-color: #f8fafc; |
|
|
-webkit-tap-highlight-color: transparent; |
|
|
} |
|
|
.no-scrollbar::-webkit-scrollbar { display: none; } |
|
|
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } |
|
|
.fade-in { animation: fadeIn 0.5s ease-out; } |
|
|
.slide-up { animation: slideUp 0.4s ease-out; } |
|
|
|
|
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } |
|
|
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } |
|
|
|
|
|
.card-shadow { box-shadow: 0 4px 20px -2px rgba(0, 0, 0, 0.1); } |
|
|
.nav-active { color: #1e40af; font-weight: 600; } |
|
|
.nav-item { color: #64748b; } |
|
|
|
|
|
|
|
|
@keyframes pulse-ring { |
|
|
0% { box-shadow: 0 0 0 0 rgba(30, 64, 175, 0.7); } |
|
|
70% { box-shadow: 0 0 0 10px rgba(30, 64, 175, 0); } |
|
|
100% { box-shadow: 0 0 0 0 rgba(30, 64, 175, 0); } |
|
|
} |
|
|
.fab-pulse { animation: pulse-ring 2s infinite; } |
|
|
|
|
|
|
|
|
.typing-dot { |
|
|
animation: typing 1.4s infinite ease-in-out both; |
|
|
} |
|
|
.typing-dot:nth-child(1) { animation-delay: -0.32s; } |
|
|
.typing-dot:nth-child(2) { animation-delay: -0.16s; } |
|
|
@keyframes typing { |
|
|
0%, 80%, 100% { transform: scale(0); } |
|
|
40% { transform: scale(1); } |
|
|
} |
|
|
|
|
|
|
|
|
.bar { |
|
|
width: 3px; |
|
|
background: #2563eb; |
|
|
animation: sound 0ms -800ms linear infinite alternate; |
|
|
} |
|
|
@keyframes sound { |
|
|
0% { opacity: .35; height: 3px; } |
|
|
100% { opacity: 1; height: 12px; } |
|
|
} |
|
|
.playing .bar { animation-duration: 400ms; } |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div id="root"></div> |
|
|
|
|
|
<script type="text/babel"> |
|
|
const { useState, useEffect, useRef } = React; |
|
|
|
|
|
|
|
|
function pcmToWav(pcmData, sampleRate = 24000) { |
|
|
const buffer = new ArrayBuffer(44 + pcmData.length * 2); |
|
|
const view = new DataView(buffer); |
|
|
writeString(view, 0, 'RIFF'); |
|
|
view.setUint32(4, 36 + pcmData.length * 2, true); |
|
|
writeString(view, 8, 'WAVE'); |
|
|
writeString(view, 12, 'fmt '); |
|
|
view.setUint32(16, 16, true); |
|
|
view.setUint16(20, 1, true); |
|
|
view.setUint16(22, 1, true); |
|
|
view.setUint32(24, sampleRate, true); |
|
|
view.setUint32(28, sampleRate * 2, true); |
|
|
view.setUint16(32, 2, true); |
|
|
view.setUint16(34, 16, true); |
|
|
writeString(view, 36, 'data'); |
|
|
view.setUint32(40, pcmData.length * 2, true); |
|
|
for (let i = 0; i < pcmData.length; i++) { |
|
|
view.setInt16(44 + i * 2, pcmData[i], true); |
|
|
} |
|
|
return new Blob([view], { type: 'audio/wav' }); |
|
|
} |
|
|
function writeString(view, offset, string) { |
|
|
for (let i = 0; i < string.length; i++) { |
|
|
view.setUint8(offset + i, string.charCodeAt(i)); |
|
|
} |
|
|
} |
|
|
function base64ToInt16Array(base64) { |
|
|
const binaryString = window.atob(base64); |
|
|
const len = binaryString.length; |
|
|
const bytes = new Uint8Array(len); |
|
|
for (let i = 0; i < len; i++) { |
|
|
bytes[i] = binaryString.charCodeAt(i); |
|
|
} |
|
|
return new Int16Array(bytes.buffer); |
|
|
} |
|
|
|
|
|
|
|
|
const Icon = ({ path, size = 24, className = "" }) => ( |
|
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className} dangerouslySetInnerHTML={{ __html: path }} /> |
|
|
); |
|
|
const icons = { |
|
|
home: '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline>', |
|
|
users: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path>', |
|
|
book: '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>', |
|
|
message: '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path>', |
|
|
sun: '<circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>', |
|
|
video: '<polygon points="23 7 16 12 23 17 23 7"></polygon><rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>', |
|
|
check: '<polyline points="20 6 9 17 4 12"></polyline>', |
|
|
send: '<line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>', |
|
|
bot: '<rect x="3" y="11" width="18" height="10" rx="2"></rect><circle cx="12" cy="5" r="2"></circle><path d="M12 7v4"></path><line x1="8" y1="16" x2="8" y2="16"></line><line x1="16" y1="16" x2="16" y2="16"></line>', |
|
|
sparkle: '<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />', |
|
|
road: '<path d="M18 20V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16"></path><path d="M2 20h20"></path><path d="M12 2v18"></path><path d="M12 12h4"></path><path d="M12 6h-4"></path><path d="M12 18h4"></path>', |
|
|
volume: '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>', |
|
|
stop: '<rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect>', |
|
|
trash: '<polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>', |
|
|
music: '<path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle>', |
|
|
heart: '<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>' |
|
|
}; |
|
|
|
|
|
|
|
|
const policies = [ |
|
|
{ id: 1, title: "ถนนเกษตร/ไฟส่องสว่าง", desc: "ถนนคอนกรีต-ลาดยางสู่ไร่นา พร้อมไฟส่องสว่างทั่วถึง", icon: "road", color: "bg-orange-100 text-orange-600" }, |
|
|
{ id: 2, title: "รถรับส่งฟรี/แอร์ศูนย์เด็ก", desc: "รถรับส่งนักเรียนฟรี ติดแอร์ศูนย์เด็กเล็กทุกแห่ง", icon: "users", color: "bg-pink-100 text-pink-600" }, |
|
|
{ id: 3, title: "CCTV ดูหลานทั่วโลก", desc: "ดูหลานผ่านมือถือได้ทุกที่ ทุกเวลา แม้อยู่นอกประเทศ", icon: "video", color: "bg-blue-100 text-blue-600" }, |
|
|
{ id: 4, title: "โดมยักษ์/ลานตากพืชผล", desc: "สร้างโดมลานกีฬาอเนกประสงค์ ใช้ตากข้าว/พืชผลได้", icon: "home", color: "bg-green-100 text-green-600" }, |
|
|
{ id: 5, title: "ส่งเสริมดนตรี/กีฬา", desc: "จัดหาอุปกรณ์ จัดประกวดทุก 3-6 เดือน เฟ้นหาตัวแทนตำบล", icon: "music", color: "bg-purple-100 text-purple-600" }, |
|
|
{ id: 6, title: "กำจัดขยะครบวงจร", desc: "เพิ่มรถเก็บขยะ สร้างเตาเผาขยะไร้ควัน", icon: "trash", color: "bg-slate-100 text-slate-600" }, |
|
|
{ id: 7, title: "รถตัดหญ้าเคลื่อนที่เร็ว", desc: "รถตัดหญ้าข้างทางขนาดใหญ่ สะอาด ปลอดภัย ทันใจ", icon: "road", color: "bg-emerald-100 text-emerald-600" }, |
|
|
{ id: 8, title: "รถกระเช้าเอนกประสงค์", desc: "ตัดกิ่งไม้ ซ่อมไฟทาง แก้ไขไฟฟ้าส่องสว่างทันท่วงที", icon: "check", color: "bg-yellow-100 text-yellow-600" }, |
|
|
{ id: 9, title: "โซล่าร์ฟาร์มชุมชน", desc: "ตั้งกลุ่มผลิตไฟเพื่อการเกษตรและครัวเรือน", icon: "sun", color: "bg-amber-100 text-amber-600" }, |
|
|
{ id: 10, title: "ศูนย์ดูแลผู้สูงอายุ", desc: "บำบัดฟื้นฟูสุขภาพกายและใจให้เบิกบาน แข็งแรง", icon: "heart", color: "bg-rose-100 text-rose-600" }, |
|
|
{ id: 11, title: "ฟื้นฟูแหล่งท่องเที่ยว", desc: "พัฒนาแหล่งท่องเที่ยวกลุ่มแพ เที่ยวได้ทุกฤดูกาล", icon: "sparkle", color: "bg-cyan-100 text-cyan-600" }, |
|
|
{ id: 12, title: "งานประเพณียิ่งใหญ่", desc: "สงกรานต์ บุญบั้งไฟ แห่เทียน ลอยกระทง จัดใหญ่ขึ้น", icon: "book", color: "bg-red-100 text-red-600" }, |
|
|
{ id: 13, title: "สานต่อสาธารณูปโภค", desc: "น้ำ ไฟ ถนน ทำต่อให้บรรลุวัตถุประสงค์ทุกโครงการ", icon: "check", color: "bg-indigo-100 text-indigo-600" } |
|
|
]; |
|
|
|
|
|
|
|
|
const teamMembers = [ |
|
|
{ name: "นายสุบรรณ พลรักษา", role: "ผู้สมัคร นายก อบต.", desc: "สานงานต่อ ก่องานใหม่ ผลงาน 4 ปีเป็นประกัน", img: "https://via.placeholder.com/150/2563eb/ffffff?text=นายกฯสุบรรณ" }, |
|
|
{ name: "นายปราโมทย์ อุ่นวงศ์", role: "ผู้สมัคร รองนายกฯ", desc: "ประธานกลุ่มม้าขาวฯ มุ่งมั่นพัฒนา", img: "https://via.placeholder.com/150/2563eb/ffffff?text=รองฯปราโมทย์" }, |
|
|
{ name: "นายสุวิทย์ บุญสาน", role: "ผู้สมัคร รองนายกฯ", desc: "รองนายกฯ คนขยัน เข้าถึงพึ่งได้", img: "https://via.placeholder.com/150/2563eb/ffffff?text=รองฯสุวิทย์" }, |
|
|
{ name: "นายต้อย ทรัพย์พิพิธ", role: "ผู้สมัคร เลขานุการฯ", desc: "เลขานุการคู่ใจ พร้อมรับใช้พี่น้อง", img: "https://via.placeholder.com/150/2563eb/ffffff?text=เลขาฯต้อย" } |
|
|
]; |
|
|
|
|
|
|
|
|
const apiKey = "AIzaSyCrlKPfTLrm9rTsOlg0LgljRnWKrjPgVTM"; |
|
|
|
|
|
const SYSTEM_PROMPT = ` |
|
|
คุณคือ 'พี่ม้าขาว' (Phi Ma Khao) ผู้ช่วยอัจฉริยะประจำกลุ่มม้าขาวก้าวใหม่ เบอร์ 2 ตำบลหนองกุงทับม้า |
|
|
|
|
|
ข้อมูลสำคัญสำหรับการเลือกตั้ง: |
|
|
- วันเลือกตั้ง: อาทิตย์ 28 ธ.ค. 68 เวลา 08.00-17.00 น. |
|
|
- สโลแกน: "พูดแล้วทำ สำเร็จจริง สานงานต่อ ก่องานใหม่" |
|
|
|
|
|
นโยบายหลัก 13 ข้อ (ต้องแม่นยำตามนี้): |
|
|
1. สนับสนุนถนนคอนกรีต/ลาดยางสู่ไร่นา พร้อมไฟส่องสว่างทั่วถึง |
|
|
2. รถรับ-ส่งนักเรียนฟรี และศูนย์เด็กเล็กติดแอร์ทุกแห่ง |
|
|
3. CCTV ศูนย์เด็กเล็ก ดูผ่านมือถือได้ทุกที่ทั่วโลก |
|
|
4. สร้างลานคอนกรีต/โดมยักษ์ทุกชุมชน (ใช้เป็นสนามกีฬา, ดนตรี, ลานตากพืชผล) |
|
|
5. จัดหาอุปกรณ์กีฬา/นักร้องดนตรี จัดประกวดทุก 3-6 เดือน/1 ปี คัดตัวแทนตำบล |
|
|
6. ระบบกำจัดขยะ: เพิ่มรถเก็บขยะ, สร้างเตาเผาขยะไร้ควัน |
|
|
7. รถตัดหญ้าข้างทางเคลื่อนที่เร็วขนาดใหญ่ เพื่อความสะอาดปลอดภัย |
|
|
8. รถเครนกระเช้า ตัดกิ่งไม้/ซ่อมไฟส่องสว่างทันที |
|
|
9. สนับสนุนกลุ่มโซล่าฟาร์มเพื่อการเกษตรและครัวเรือน |
|
|
10. ศูนย์ดูแลผู้สูงอายุ บำบัดฟื้นฟูกายใจให้เบิกบาน |
|
|
11. ฟื้นฟูแหล่งท่องเที่ยวกลุ่มแพ ให้เที่ยวได้ทุกฤดูกาล |
|
|
12. ส่งเสริมงานประเพณี (สงกรานต์/บั้งไฟ/แห่เทียน/ลอยกระทง) ให้ใหญ่ขึ้น |
|
|
13. สานต่องานสาธารณูปโภคเดิม (น้ำ/ไฟ/ถนน) ให้สำเร็จทุกโครงการ |
|
|
|
|
|
บุคลิกของคุณ: |
|
|
- พี่ชายที่พึ่งพาได้ สุภาพ อบอุ่น มีความรู้จริง |
|
|
- ใช้ภาษาไทยกลางที่สุภาพ นุ่มนวล |
|
|
- เชิญชวนให้เลือก เบอร์ 2 กลุ่มม้าขาวก้าวใหม่ |
|
|
`; |
|
|
|
|
|
const callGemini = async (prompt, customSystem = null) => { |
|
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=${apiKey}`; |
|
|
const payload = { |
|
|
contents: [{ parts: [{ text: prompt }] }], |
|
|
systemInstruction: { parts: [{ text: customSystem || SYSTEM_PROMPT }] } |
|
|
}; |
|
|
try { |
|
|
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); |
|
|
if (!response.ok) throw new Error('API Error'); |
|
|
const data = await response.json(); |
|
|
return data.candidates?.[0]?.content?.parts?.[0]?.text || "ขออภัยครับ พี่ม้าขาวไม่สามารถตอบได้ในขณะนี้"; |
|
|
} catch (error) { return "เกิดข้อผิดพลาดในการเชื่อมต่อครับ"; } |
|
|
}; |
|
|
|
|
|
const callGeminiTTS = async (text) => { |
|
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-tts:generateContent?key=${apiKey}`; |
|
|
const payload = { |
|
|
contents: [{ parts: [{ text: "พูดด้วยน้ำเสียงอบอุ่น มั่นใจ และเชิญชวน: " + text }] }], |
|
|
generationConfig: { |
|
|
responseModalities: ["AUDIO"], |
|
|
speechConfig: { |
|
|
voiceConfig: { |
|
|
prebuiltVoiceConfig: { |
|
|
voiceName: "Fenrir" |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
}; |
|
|
try { |
|
|
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); |
|
|
if (!response.ok) throw new Error('TTS API Error'); |
|
|
const data = await response.json(); |
|
|
const base64Audio = data.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data; |
|
|
if (!base64Audio) throw new Error('No audio data'); |
|
|
return base64Audio; |
|
|
} catch (error) { |
|
|
console.error("TTS Error:", error); |
|
|
return null; |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
const Nav = ({ activeTab, setTab }) => ( |
|
|
<div className="fixed bottom-0 w-full bg-white border-t border-gray-200 pb-safe z-50 flex justify-around items-center px-2 py-3 shadow-lg"> |
|
|
<button onClick={()=>setTab('home')} className={`flex flex-col items-center gap-1 ${activeTab==='home' ? 'nav-active' : 'nav-item'}`}> |
|
|
<Icon path={icons.home} size={24} /> |
|
|
<span className="text-[10px]">หน้าหลัก</span> |
|
|
</button> |
|
|
<button onClick={()=>setTab('policy')} className={`flex flex-col items-center gap-1 ${activeTab==='policy' ? 'nav-active' : 'nav-item'}`}> |
|
|
<Icon path={icons.book} size={24} /> |
|
|
<span className="text-[10px]">นโยบาย</span> |
|
|
</button> |
|
|
<div className="relative -top-5"> |
|
|
<button onClick={()=>setTab('chat')} className="bg-blue-800 text-white p-4 rounded-full shadow-lg fab-pulse flex items-center justify-center border-4 border-slate-50"> |
|
|
<Icon path={icons.bot} size={28} /> |
|
|
</button> |
|
|
</div> |
|
|
<button onClick={()=>setTab('team')} className={`flex flex-col items-center gap-1 ${activeTab==='team' ? 'nav-active' : 'nav-item'}`}> |
|
|
<Icon path={icons.users} size={24} /> |
|
|
<span className="text-[10px]">ทีมงาน</span> |
|
|
</button> |
|
|
<button onClick={()=>setTab('contact')} className={`flex flex-col items-center gap-1 ${activeTab==='contact' ? 'nav-active' : 'nav-item'}`}> |
|
|
<Icon path={icons.message} size={24} /> |
|
|
<span className="text-[10px]">แจ้งปัญหา</span> |
|
|
</button> |
|
|
</div> |
|
|
); |
|
|
|
|
|
const Header = () => ( |
|
|
<div className="bg-gradient-to-r from-blue-700 to-indigo-800 text-white p-6 rounded-b-[2rem] shadow-md relative overflow-hidden"> |
|
|
<div className="absolute top-0 right-0 opacity-10 transform translate-x-10 -translate-y-10"> |
|
|
<svg width="200" height="200" viewBox="0 0 200 200"><circle cx="100" cy="100" r="100" fill="white"/></svg> |
|
|
</div> |
|
|
<div className="relative z-10 text-center"> |
|
|
<div className="inline-block bg-white text-blue-800 px-3 py-1 rounded-full text-xs font-bold mb-2 shadow-sm"> |
|
|
เลือกตั้ง 28 ธ.ค. 68 (08.00-17.00) |
|
|
</div> |
|
|
<h1 className="text-2xl font-bold mb-1">กลุ่มม้าขาวก้าวใหม่</h1> |
|
|
<p className="text-blue-200 text-sm mb-4">พูดแล้วทำ สำเร็จจริง</p> |
|
|
|
|
|
<div className="bg-white/20 backdrop-blur-sm rounded-xl p-3 inline-flex items-center gap-4 border border-white/30"> |
|
|
<div className="text-right"> |
|
|
<p className="text-[10px] text-blue-100">นายกฯ สุบรรณ</p> |
|
|
<p className="font-bold text-lg">เบอร์</p> |
|
|
</div> |
|
|
<div className="bg-white text-blue-800 w-12 h-12 rounded-full flex items-center justify-center text-3xl font-bold shadow-lg"> |
|
|
2 |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
|
|
|
const PolicyCard = ({ p }) => { |
|
|
const [expanded, setExpanded] = useState(false); |
|
|
const [explanation, setExplanation] = useState(""); |
|
|
const [loading, setLoading] = useState(false); |
|
|
const [audioLoading, setAudioLoading] = useState(false); |
|
|
const [isPlaying, setIsPlaying] = useState(false); |
|
|
const audioRef = useRef(null); |
|
|
|
|
|
const handleExplain = async () => { |
|
|
if (explanation) { |
|
|
setExpanded(!expanded); |
|
|
return; |
|
|
} |
|
|
setLoading(true); |
|
|
setExpanded(true); |
|
|
const prompt = `ในฐานะพี่ม้าขาว ช่วยอธิบายขยายนโยบาย "${p.title}: ${p.desc}" ให้ชาวบ้านฟังด้วยความจริงใจ สุภาพ และเห็นภาพชัดเจน ว่าสิ่งนี้จะช่วยยกระดับคุณภาพชีวิตของพวกเขาได้อย่างไร`; |
|
|
const text = await callGemini(prompt); |
|
|
setExplanation(text); |
|
|
setLoading(false); |
|
|
}; |
|
|
|
|
|
const handlePlayAudio = async (e) => { |
|
|
e.stopPropagation(); |
|
|
|
|
|
if (isPlaying) { |
|
|
audioRef.current.pause(); |
|
|
audioRef.current.currentTime = 0; |
|
|
setIsPlaying(false); |
|
|
return; |
|
|
} |
|
|
|
|
|
setAudioLoading(true); |
|
|
const textToSpeak = `นโยบายข้อนี้ครับ. ${p.desc}. พี่ม้าขาวตั้งใจจะทำเพื่อให้พี่น้องชาวหนองกุงทับม้ามีชีวิตที่ดีขึ้นครับ`; |
|
|
|
|
|
const base64 = await callGeminiTTS(textToSpeak); |
|
|
|
|
|
if (base64) { |
|
|
const pcmData = base64ToInt16Array(base64); |
|
|
const wavBlob = pcmToWav(pcmData); |
|
|
const audioUrl = URL.createObjectURL(wavBlob); |
|
|
|
|
|
if (audioRef.current) { |
|
|
audioRef.current.src = audioUrl; |
|
|
audioRef.current.play(); |
|
|
setIsPlaying(true); |
|
|
audioRef.current.onended = () => setIsPlaying(false); |
|
|
} |
|
|
} |
|
|
setAudioLoading(false); |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div className="bg-white p-4 rounded-xl shadow-sm border border-slate-100 mb-3 slide-up transition-all"> |
|
|
<div className="flex items-start gap-4"> |
|
|
<div className={`p-3 rounded-full shrink-0 ${p.color}`} dangerouslySetInnerHTML={{ __html: icons[p.icon] || icons.check }}></div> |
|
|
<div className="flex-1"> |
|
|
<h3 className="font-bold text-gray-800 text-lg">{p.title}</h3> |
|
|
<p className="text-gray-500 text-sm leading-relaxed">{p.desc}</p> |
|
|
|
|
|
<div className="flex gap-2 mt-3"> |
|
|
<button |
|
|
onClick={handleExplain} |
|
|
className="text-xs bg-slate-100 text-slate-700 px-3 py-1.5 rounded-full font-bold flex items-center gap-1 hover:bg-slate-200 transition-colors" |
|
|
> |
|
|
<span dangerouslySetInnerHTML={{__html: icons.sparkle}} className="w-3 h-3"></span> |
|
|
{loading ? 'กำลังคิด...' : 'อ่านเพิ่มเติม'} |
|
|
</button> |
|
|
|
|
|
<button |
|
|
onClick={handlePlayAudio} |
|
|
disabled={audioLoading} |
|
|
className={`text-xs px-3 py-1.5 rounded-full font-bold flex items-center gap-2 transition-colors ${isPlaying ? 'bg-red-100 text-red-600' : 'bg-blue-100 text-blue-600'}`} |
|
|
> |
|
|
{audioLoading ? ( |
|
|
<div className="w-3 h-3 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div> |
|
|
) : ( |
|
|
<> |
|
|
<span dangerouslySetInnerHTML={{__html: isPlaying ? icons.stop : icons.volume}} className="w-3 h-3"></span> |
|
|
{isPlaying ? 'หยุดเสียง' : 'ฟังพี่ม้าขาวเล่า'} |
|
|
</> |
|
|
)} |
|
|
{isPlaying && ( |
|
|
<div className="flex items-end gap-[2px] h-3 playing"> |
|
|
<div className="bar"></div> |
|
|
<div className="bar" style={{animationDelay: '-200ms'}}></div> |
|
|
<div className="bar" style={{animationDelay: '-400ms'}}></div> |
|
|
</div> |
|
|
)} |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
{expanded && ( |
|
|
<div className="mt-4 bg-slate-50 p-4 rounded-lg border border-slate-200 text-sm text-gray-700 leading-relaxed animate-fade-in relative"> |
|
|
{loading ? ( |
|
|
<div className="flex space-x-1 h-5 items-center justify-center"> |
|
|
<div className="w-2 h-2 bg-slate-400 rounded-full typing-dot"></div> |
|
|
<div className="w-2 h-2 bg-slate-400 rounded-full typing-dot"></div> |
|
|
<div className="w-2 h-2 bg-slate-400 rounded-full typing-dot"></div> |
|
|
</div> |
|
|
) : ( |
|
|
<> |
|
|
<div className="font-bold text-blue-800 mb-2 flex items-center gap-2 border-b border-slate-200 pb-2"> |
|
|
<span className="text-lg">🎙️</span> จากใจพี่ม้าขาว: |
|
|
</div> |
|
|
{explanation} |
|
|
</> |
|
|
)} |
|
|
</div> |
|
|
)} |
|
|
<audio ref={audioRef} className="hidden" /> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
const ChatInterface = () => { |
|
|
const [messages, setMessages] = useState([ |
|
|
{ sender: 'bot', text: 'สวัสดีครับ พี่ม้าขาวรายงานตัวครับ! 🤝 \nผมพร้อมให้ข้อมูลนโยบายทั้ง 13 ข้อ และแนะนำทีมบริหารชุดใหม่ สอบถามมาได้เลย!' } |
|
|
]); |
|
|
const [input, setInput] = useState(''); |
|
|
const [isLoading, setIsLoading] = useState(false); |
|
|
const scrollRef = useRef(null); |
|
|
|
|
|
useEffect(() => scrollRef.current?.scrollIntoView({ behavior: "smooth" }), [messages]); |
|
|
|
|
|
const send = async () => { |
|
|
if(!input.trim()) return; |
|
|
const txt = input; |
|
|
setInput(''); |
|
|
setMessages(prev => [...prev, { sender: 'user', text: txt }]); |
|
|
setIsLoading(true); |
|
|
|
|
|
const reply = await callGemini(txt); |
|
|
setMessages(prev => [...prev, { sender: 'bot', text: reply }]); |
|
|
setIsLoading(false); |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div className="flex flex-col h-[calc(100vh-140px)]"> |
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4"> |
|
|
{messages.map((m, i) => ( |
|
|
<div key={i} className={`flex ${m.sender==='user'?'justify-end':'justify-start'}`}> |
|
|
<div className={`max-w-[80%] p-3 rounded-2xl text-sm ${ |
|
|
m.sender==='user' |
|
|
? 'bg-blue-700 text-white rounded-br-none' |
|
|
: 'bg-white text-gray-800 border border-gray-200 rounded-bl-none shadow-sm' |
|
|
}`}> |
|
|
<p className="whitespace-pre-line">{m.text}</p> |
|
|
</div> |
|
|
</div> |
|
|
))} |
|
|
{isLoading && <div className="text-xs text-gray-400 text-center animate-pulse">พี่ม้าขาวกำลังพิมพ์...</div>} |
|
|
<div ref={scrollRef}></div> |
|
|
</div> |
|
|
<div className="p-3 bg-white border-t"> |
|
|
<div className="flex gap-2"> |
|
|
<input |
|
|
value={input} |
|
|
onChange={e=>setInput(e.target.value)} |
|
|
onKeyPress={e=>e.key==='Enter' && send()} |
|
|
placeholder="พิมพ์คำถาม..." |
|
|
className="flex-1 border rounded-full px-4 py-2 text-sm focus:outline-none focus:border-blue-500 bg-gray-50" |
|
|
/> |
|
|
<button onClick={send} className="bg-blue-700 text-white p-2 rounded-full w-10 h-10 flex items-center justify-center hover:bg-blue-800 transition-colors"> |
|
|
<Icon path={icons.send} size={18} /> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
const ContactPage = ({ setTab }) => { |
|
|
const [problem, setProblem] = useState(""); |
|
|
const [aiDraft, setAiDraft] = useState(""); |
|
|
const [loading, setLoading] = useState(false); |
|
|
|
|
|
const handleAnalyze = async () => { |
|
|
if(!problem.trim()) return; |
|
|
setLoading(true); |
|
|
const prompt = `ชาวบ้านแจ้งปัญหามาว่า: "${problem}" |
|
|
ในฐานะพี่ม้าขาว ช่วยร่างหนังสือร้องเรียนแบบเป็นทางการให้หน่อย ระบุหมวดหมู่ปัญหา และใช้ภาษาที่แสดงถึงความกระตือรือร้นที่จะช่วยเหลือ`; |
|
|
|
|
|
const draft = await callGemini(prompt); |
|
|
setAiDraft(draft); |
|
|
setLoading(false); |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div className="pb-24 p-4 animate-fade-in"> |
|
|
<h2 className="text-2xl font-bold text-gray-800 mb-4">แจ้งปัญหา/ร้องเรียน</h2> |
|
|
|
|
|
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100 mb-4"> |
|
|
<label className="block text-sm font-bold text-gray-700 mb-2">มีปัญหาเรื่องอะไร บอกพี่ม้าขาวได้เลยครับ</label> |
|
|
<textarea |
|
|
value={problem} |
|
|
onChange={(e) => setProblem(e.target.value)} |
|
|
placeholder="เช่น น้ำประปาไม่ไหล, ไฟทางดับ..." |
|
|
className="w-full p-3 border rounded-xl text-sm h-24 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-gray-50 mb-3" |
|
|
></textarea> |
|
|
|
|
|
<button |
|
|
onClick={handleAnalyze} |
|
|
disabled={loading || !problem} |
|
|
className="w-full bg-indigo-700 text-white py-2 rounded-lg font-bold shadow-sm hover:bg-indigo-800 transition-colors flex justify-center items-center gap-2" |
|
|
> |
|
|
{loading ? 'กำลังร่างหนังสือ...' : <><span dangerouslySetInnerHTML={{__html: icons.sparkle}} className="w-4 h-4"></span> ให้พี่ม้าขาวช่วยร่างคำร้อง</>} |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
{aiDraft && ( |
|
|
<div className="bg-green-50 p-4 rounded-xl border border-green-200 slide-up"> |
|
|
<h3 className="font-bold text-green-800 mb-2 flex items-center gap-2"> |
|
|
✅ ร่างหนังสือร้องเรียน (โดยพี่ม้าขาว) |
|
|
</h3> |
|
|
<div className="bg-white p-3 rounded-lg border border-green-100 text-sm text-gray-700 whitespace-pre-line mb-3 shadow-inner"> |
|
|
{aiDraft} |
|
|
</div> |
|
|
<button className="w-full bg-green-600 text-white py-3 rounded-xl font-bold shadow-lg hover:bg-green-700 transition-colors" onClick={() => alert('ส่งเรื่องเรียบร้อย! ทีมงานได้รับข้อมูลแล้วครับ')}> |
|
|
ยืนยันส่งเรื่องทันที |
|
|
</button> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
<div className="text-center mt-6"> |
|
|
<a href="tel:0000000000" className="inline-block mt-2 bg-slate-200 text-slate-600 px-6 py-2 rounded-full font-bold hover:bg-slate-300 transition-colors"> |
|
|
📞 โทรหาทีมงาน |
|
|
</a> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
const App = () => { |
|
|
const [activeTab, setTab] = useState('home'); |
|
|
|
|
|
const renderContent = () => { |
|
|
switch(activeTab) { |
|
|
case 'home': |
|
|
return ( |
|
|
<div className="pb-24 animate-fade-in"> |
|
|
<Header /> |
|
|
<div className="p-4"> |
|
|
<div className="mb-6"> |
|
|
<h2 className="font-bold text-gray-700 text-lg mb-3 flex items-center gap-2"> |
|
|
<Icon path={icons.sun} className="text-orange-500"/> ไฮไลท์นโยบาย |
|
|
</h2> |
|
|
<div className="grid grid-cols-2 gap-3"> |
|
|
<div className="bg-white p-3 rounded-xl shadow-sm text-center border border-gray-100" onClick={()=>setTab('policy')}> |
|
|
<div className="text-4xl mb-2">🛣️</div> |
|
|
<div className="font-bold text-gray-800 text-sm">ถนนลงนา</div> |
|
|
</div> |
|
|
<div className="bg-white p-3 rounded-xl shadow-sm text-center border border-gray-100" onClick={()=>setTab('policy')}> |
|
|
<div className="text-4xl mb-2">📱</div> |
|
|
<div className="font-bold text-gray-800 text-sm">กล้องดูหลาน</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div className="bg-blue-50 rounded-xl p-4 border border-blue-100 flex items-center gap-4 cursor-pointer" onClick={()=>setTab('chat')}> |
|
|
<div className="bg-white p-3 rounded-full shadow-sm text-2xl">🤖</div> |
|
|
<div> |
|
|
<h3 className="font-bold text-blue-800">ปรึกษาพี่ม้าขาว</h3> |
|
|
<p className="text-xs text-blue-600">คุยได้ทุกเรื่อง ตลอด 24 ชม.</p> |
|
|
</div> |
|
|
<div className="ml-auto text-blue-400">➜</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
case 'policy': |
|
|
return ( |
|
|
<div className="pb-24 p-4 animate-fade-in"> |
|
|
<h2 className="text-2xl font-bold text-gray-800 mb-4">นโยบายทำจริง <span className="text-blue-700">13 ด้าน</span></h2> |
|
|
{policies.map(p => <PolicyCard key={p.id} p={p} />)} |
|
|
</div> |
|
|
); |
|
|
case 'team': |
|
|
return ( |
|
|
<div className="pb-24 p-4 animate-fade-in"> |
|
|
<h2 className="text-2xl font-bold text-gray-800 mb-6 text-center">ทีมงาน<span className="text-blue-700">คุณภาพ</span></h2> |
|
|
<div className="space-y-6"> |
|
|
{teamMembers.map((m, i) => ( |
|
|
<div key={i} className="bg-white rounded-2xl overflow-hidden shadow-sm border border-gray-100 text-center pb-4"> |
|
|
<div className="h-24 bg-gradient-to-b from-blue-700 to-blue-800"></div> |
|
|
<div className="relative -mt-12 mb-3"> |
|
|
<div className="w-24 h-24 bg-gray-200 rounded-full border-4 border-white mx-auto flex items-center justify-center text-4xl overflow-hidden"> |
|
|
👤 |
|
|
</div> |
|
|
</div> |
|
|
<h3 className="font-bold text-lg text-gray-800">{m.name}</h3> |
|
|
<span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full font-bold">{m.role}</span> |
|
|
<p className="text-gray-500 text-sm mt-2 px-4">"{m.desc}"</p> |
|
|
</div> |
|
|
))} |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
case 'chat': |
|
|
return ( |
|
|
<div className="pb-24 bg-gray-50 h-full animate-fade-in"> |
|
|
<div className="bg-white p-4 shadow-sm border-b flex items-center gap-3 sticky top-0 z-10"> |
|
|
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center text-xl">🤖</div> |
|
|
<div> |
|
|
<h3 className="font-bold text-gray-800">พี่ม้าขาว (AI)</h3> |
|
|
<p className="text-xs text-green-600 flex items-center gap-1"><span className="w-2 h-2 bg-green-500 rounded-full block"></span> พร้อมช่วยเหลือ</p> |
|
|
</div> |
|
|
</div> |
|
|
<ChatInterface /> |
|
|
</div> |
|
|
); |
|
|
case 'contact': |
|
|
return <ContactPage setTab={setTab} />; |
|
|
default: return null; |
|
|
} |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div className="bg-slate-50 min-h-screen"> |
|
|
{renderContent()} |
|
|
<Nav activeTab={activeTab} setTab={setTab} /> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById('root')); |
|
|
root.render(<App />); |
|
|
</script> |
|
|
</body> |
|
|
</html> |