├── public ├── logo.png ├── mixkit.wav ├── chat-up.mp3 ├── lyra-ai.png ├── lyra-mob.png ├── voice │ ├── greeting_formal.mp3 │ ├── greeting_genz.mp3 │ ├── greeting_jualan.mp3 │ └── greeting_default.mp3 ├── success.html ├── under-construction │ └── index.html └── vite.svg ├── src ├── assets │ ├── lyra-ai.png │ └── lyra-mob.png ├── modules │ ├── limitModal.js │ ├── intentHandler.admin.js │ ├── renderSingleProductCard.js │ ├── htmlRenderer.js │ ├── productStore.js │ ├── adminAuth.js │ ├── cdnAdminAuth.js │ ├── CartManager.js │ ├── authHandler.js │ ├── intentHandler.js │ ├── chatRenderer.js │ └── checkoutHandler.js ├── api │ ├── firebase-config.js │ ├── checkout.js │ └── detect-intent-api.js ├── 404.html ├── javascript.svg ├── pages │ ├── AdminLogin.js │ ├── success.js │ ├── AdminPanel.js │ └── ChatTelegram.js ├── style.css └── main.js ├── vercel.json ├── tailwind.config.js ├── vite.config.js ├── .gitignore ├── .env.sample ├── package.json ├── index.html └── README.md /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daffadevhosting/lyra-ai-chat/HEAD/public/logo.png -------------------------------------------------------------------------------- /public/mixkit.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daffadevhosting/lyra-ai-chat/HEAD/public/mixkit.wav -------------------------------------------------------------------------------- /public/chat-up.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daffadevhosting/lyra-ai-chat/HEAD/public/chat-up.mp3 -------------------------------------------------------------------------------- /public/lyra-ai.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daffadevhosting/lyra-ai-chat/HEAD/public/lyra-ai.png -------------------------------------------------------------------------------- /public/lyra-mob.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daffadevhosting/lyra-ai-chat/HEAD/public/lyra-mob.png -------------------------------------------------------------------------------- /src/assets/lyra-ai.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daffadevhosting/lyra-ai-chat/HEAD/src/assets/lyra-ai.png -------------------------------------------------------------------------------- /src/assets/lyra-mob.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daffadevhosting/lyra-ai-chat/HEAD/src/assets/lyra-mob.png -------------------------------------------------------------------------------- /public/voice/greeting_formal.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daffadevhosting/lyra-ai-chat/HEAD/public/voice/greeting_formal.mp3 -------------------------------------------------------------------------------- /public/voice/greeting_genz.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daffadevhosting/lyra-ai-chat/HEAD/public/voice/greeting_genz.mp3 -------------------------------------------------------------------------------- /public/voice/greeting_jualan.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daffadevhosting/lyra-ai-chat/HEAD/public/voice/greeting_jualan.mp3 -------------------------------------------------------------------------------- /public/voice/greeting_default.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daffadevhosting/lyra-ai-chat/HEAD/public/voice/greeting_default.mp3 -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [ 3 | { "source": "/(.*)", "destination": "/" }, 4 | { "source": "/success", "destination": "/success.html" } 5 | 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | content: [ 3 | "./index.html", 4 | "./src/**/*.{js,html}" 5 | ], 6 | darkMode: 'class', 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | } 12 | 13 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import tailwindcss from '@tailwindcss/vite' 3 | 4 | export default defineConfig({ 5 | plugins: [ 6 | tailwindcss(), 7 | ], 8 | root: './', 9 | server: { 10 | port: 3000, 11 | open: false, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /src/modules/limitModal.js: -------------------------------------------------------------------------------- 1 | export function showLimitModal() { 2 | const modal = document.getElementById('loginModal'); 3 | if (modal) modal.classList.remove('hide'); 4 | } 5 | 6 | export function hideLimitModal() { 7 | const modal = document.getElementById('loginModal'); 8 | if (modal) modal.classList.add('hide'); 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | package-lock.json 10 | 11 | node_modules 12 | dist 13 | dist-ssr 14 | *.local 15 | .env 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | -------------------------------------------------------------------------------- /src/api/firebase-config.js: -------------------------------------------------------------------------------- 1 | // src/firebase-config.js 2 | import { initializeApp } from "firebase/app"; 3 | 4 | const firebaseConfig = { 5 | apiKey: import.meta.env.VITE_FIREBASE_API_KEY, 6 | authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, 7 | projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, 8 | appId: import.meta.env.VITE_FIREBASE_APP_ID 9 | }; 10 | 11 | export const app = initializeApp(firebaseConfig); 12 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # Firebase 2 | VITE_FIREBASE_API_KEY=xxx 3 | VITE_FIREBASE_PROJECT_ID=xxx 4 | VITE_FIREBASE_APP_ID=xxx 5 | 6 | # Groq / OpenAI 7 | VITE_GROQ_API_KEY=xxx 8 | 9 | # Midtrans 10 | VITE_MIDTRANS_CLIENT_KEY=SB-Mid-client-xxx 11 | VITE_MIDTRANS_SERVER_KEY=SB-Mid-server-xxx (backend only!) 12 | 13 | # FingerprintJS 14 | VITE_FINGERPRINT_PUBLIC_KEY=xxx 15 | 16 | # Optional 17 | VITE_APP_NAME=LYRA 18 | VITE_BASE_URL=https://lyra-ai-nine.vercel.app 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "toko-ai", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "devDependencies": { 12 | "vite": "^6.3.5" 13 | }, 14 | "dependencies": { 15 | "@tailwindcss/vite": "^4.1.10", 16 | "firebase": "^11.9.1", 17 | "lucide": "^0.515.0", 18 | "midtrans-client": "^1.4.3", 19 | "tailwindcss": "^4.1.10" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /public/success.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | LYRA - Pembayaran Berhasil 7 | 8 | 9 | 10 |
11 |
Loading...
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 404 - Halaman Tidak Ditemukan 7 | 8 | 9 |

404

10 |

Yah, halaman yang kamu cari gak ada 😢

11 | Kembali ke Beranda 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/modules/intentHandler.admin.js: -------------------------------------------------------------------------------- 1 | import { getFirestore, collection, addDoc, getDocs, deleteDoc, doc } from 'firebase/firestore'; 2 | const db = getFirestore(); 3 | 4 | const intentCol = collection(db, 'intents'); 5 | 6 | export async function addIntent(trigger, response, mode) { 7 | return await addDoc(intentCol, { trigger, response, mode }); 8 | } 9 | 10 | export async function fetchAllIntents() { 11 | const snap = await getDocs(intentCol); 12 | return snap.docs.map(d => ({ id: d.id, ...d.data() })); 13 | } 14 | 15 | export async function deleteIntent(id) { 16 | return await deleteDoc(doc(db, 'intents', id)); 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/renderSingleProductCard.js: -------------------------------------------------------------------------------- 1 | 2 | export function renderSingleProductCardHTML(product) { 3 | return ` 4 |
5 | ${product.name} 6 |
${product.name}
7 |
Rp ${product.price.toLocaleString('id-ID')}
8 |

${product.description?.slice(0, 80)}...

9 | 14 |
15 | `; 16 | } 17 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | LYRA | AI shoping online 8 | 9 | 10 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/javascript.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/htmlRenderer.js: -------------------------------------------------------------------------------- 1 | 2 | // htmlRenderer.js 3 | 4 | /** 5 | * Sanitize HTML to avoid arbitrary tag injection 6 | */ 7 | export function safeRenderHTML(rawHtml) { 8 | const allowedTags = ['DIV', 'P', 'A', 'BUTTON', 'H3', 'H4', 'H5', 'SPAN', 'UL', 'LI', 'STRONG', 'BR']; 9 | const wrapper = document.createElement('div'); 10 | wrapper.innerHTML = rawHtml; 11 | 12 | [...wrapper.querySelectorAll('*')].forEach(el => { 13 | if (!allowedTags.includes(el.tagName)) el.remove(); 14 | }); 15 | 16 | return wrapper.innerHTML; 17 | } 18 | 19 | export function attachProductModalTriggers(PRODUCT_LIST, openProductModal) { 20 | setTimeout(() => { 21 | document.querySelectorAll('.open-product-image').forEach(link => { 22 | link.onclick = (e) => { 23 | e.preventDefault(); 24 | const slug = link.dataset.slug; 25 | const product = PRODUCT_LIST.find(p => p.slug === slug); 26 | if (product) openProductModal(product); 27 | }; 28 | }); 29 | }, 100); 30 | } 31 | -------------------------------------------------------------------------------- /src/modules/productStore.js: -------------------------------------------------------------------------------- 1 | // src/modules/productStore.js 2 | 3 | import { initializeApp } from 'firebase/app'; 4 | import { getFirestore, collection, addDoc, getDocs } from 'firebase/firestore'; 5 | 6 | const firebaseConfig = { 7 | apiKey: import.meta.env.VITE_FIREBASE_API_KEY, 8 | authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, 9 | projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, 10 | appId: import.meta.env.VITE_FIREBASE_APP_ID 11 | }; 12 | 13 | const app = initializeApp(firebaseConfig); 14 | const db = getFirestore(app); 15 | 16 | export async function addProduct(data) { 17 | try { 18 | await addDoc(collection(db, 'products'), data); 19 | console.log('✅ Produk berhasil ditambahkan'); 20 | } catch (err) { 21 | console.error('❌ Gagal menambahkan produk:', err); 22 | } 23 | } 24 | 25 | export async function fetchProducts() { 26 | const querySnapshot = await getDocs(collection(db, 'products')); 27 | return querySnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); 28 | } 29 | -------------------------------------------------------------------------------- /public/under-construction/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Under Construction 8 | 9 |
10 |

🚧 Sedang Dibangun!

11 |

Fitur ini belum siap tayang, tapi L Y Я A dan tim lagi ngebut ngerjainnya! 💪

12 | Balik ke Beranda 13 |
14 | 17 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/adminAuth.js: -------------------------------------------------------------------------------- 1 | // src/modules/adminAuth.js 2 | 3 | import { initializeApp, getApps } from 'firebase/app'; 4 | import { 5 | getAuth, 6 | signInWithEmailAndPassword, 7 | onAuthStateChanged, 8 | signOut 9 | } from 'firebase/auth'; 10 | 11 | const firebaseConfig = { 12 | apiKey: import.meta.env.VITE_FIREBASE_API_KEY, 13 | authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, 14 | projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, 15 | appId: import.meta.env.VITE_FIREBASE_APP_ID 16 | }; 17 | 18 | const app = getApps().length ? getApps()[0] : initializeApp(firebaseConfig); 19 | const auth = getAuth(app); 20 | let authInitialized = false; 21 | 22 | export function initAuth() { 23 | if (authInitialized) return; 24 | // optional: bisa tambahkan logic listener di sini jika perlu 25 | authInitialized = true; 26 | } 27 | 28 | export async function loginWithEmail(email, password) { 29 | try { 30 | await signInWithEmailAndPassword(auth, email, password); 31 | return { success: true }; 32 | } catch (error) { 33 | return { success: false, message: error.message }; 34 | } 35 | } 36 | 37 | export function onLoginStateChanged(callback) { 38 | onAuthStateChanged(auth, (user) => { 39 | callback(user); 40 | }); 41 | // Panggil callback langsung jika user sudah login 42 | if (typeof auth.currentUser !== 'undefined') { 43 | callback(auth.currentUser); 44 | } 45 | } 46 | 47 | export function logoutAdmin() { 48 | return signOut(auth); 49 | } 50 | 51 | export function getCurrentUser() { 52 | return auth.currentUser; 53 | } 54 | -------------------------------------------------------------------------------- /src/modules/cdnAdminAuth.js: -------------------------------------------------------------------------------- 1 | // src/modules/cdnAdminAuth.js 2 | 3 | import { initializeApp } from "https://www.gstatic.com/firebasejs/10.12.0/firebase-app.js"; 4 | import { 5 | getAuth, 6 | signInWithEmailAndPassword, 7 | onAuthStateChanged 8 | } from "https://www.gstatic.com/firebasejs/10.12.0/firebase-auth.js"; 9 | import { 10 | getFirestore, 11 | doc, 12 | getDoc 13 | } from "https://www.gstatic.com/firebasejs/10.12.0/firebase-firestore.js"; 14 | 15 | const firebaseConfig = { 16 | apiKey: import.meta.env.VITE_FIREBASE_API_KEY, 17 | authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, 18 | projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, 19 | appId: import.meta.env.VITE_FIREBASE_APP_ID 20 | }; 21 | 22 | const app = initializeApp(firebaseConfig); 23 | const auth = getAuth(app); 24 | const db = getFirestore(app); 25 | 26 | export function initCDNAdminAuth() { 27 | onAuthStateChanged(auth, async (user) => { 28 | if (user) { 29 | const userRef = doc(db, "users", user.uid); 30 | const userSnap = await getDoc(userRef); 31 | if (userSnap.exists() && userSnap.data().isAdmin === true) { 32 | window.location.href = "/admin"; 33 | } 34 | } 35 | }); 36 | } 37 | 38 | export async function loginAdminWithEmail(email, password) { 39 | try { 40 | const result = await signInWithEmailAndPassword(auth, email, password); 41 | const user = result.user; 42 | const userRef = doc(db, "users", user.uid); 43 | const userSnap = await getDoc(userRef); 44 | 45 | if (!userSnap.exists() || userSnap.data().isAdmin !== true) { 46 | throw new Error("Bukan admin yang sah"); 47 | } 48 | window.location.href = "/admin"; 49 | } catch (err) { 50 | throw err; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/api/checkout.js: -------------------------------------------------------------------------------- 1 | import midtransClient from 'midtrans-client'; 2 | 3 | export default async function handler(req, res) { 4 | if (req.method !== 'POST') { 5 | return res.status(405).json({ error: 'Method not allowed' }); 6 | } 7 | 8 | try { 9 | const { user, cart } = JSON.parse(req.body); 10 | 11 | // Total harga dari cart 12 | const total = cart.reduce((sum, item) => sum + (item.price * (item.qty || 1)), 0); 13 | 14 | // Inisialisasi Snap 15 | const snap = new midtransClient.Snap({ 16 | isProduction: false, // ganti true kalau sudah live 17 | serverKey: process.env.VITE_MIDTRANS_SERVER_KEY, 18 | clientKey: process.env.VITE_MIDTRANS_CLIENT_KEY, 19 | }); 20 | 21 | // Buat parameter transaksi 22 | const parameter = { 23 | transaction_details: { 24 | order_id: `order-${Date.now()}`, 25 | gross_amount: total, 26 | }, 27 | customer_details: { 28 | first_name: user.nama || 'Lyra', 29 | phone: user['no_wa'] || '', 30 | email: `${user.nama?.replace(/\s+/g, '').toLowerCase()}@lyra.chat`, 31 | shipping_address: { 32 | address: user.alamat || '', 33 | } 34 | }, 35 | item_details: cart.map(item => ({ 36 | id: item.slug, 37 | price: item.price, 38 | quantity: item.qty || 1, 39 | name: item.name.slice(0, 50), 40 | })) 41 | }; 42 | 43 | // Request Snap Token 44 | const snapResponse = await snap.createTransaction(parameter); 45 | return res.status(200).json({ snapToken: snapResponse.token }); 46 | } catch (err) { 47 | console.error('Midtrans Error:', err.message || err); 48 | return res.status(500).json({ error: 'Failed to create transaction' }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/modules/CartManager.js: -------------------------------------------------------------------------------- 1 | class CartManager { 2 | constructor() { 3 | this.items = {}; // key: slug, value: { product, qty } 4 | this.listeners = new Set(); 5 | } 6 | 7 | addItem(product) { 8 | if (!product || !product.slug) return; 9 | 10 | const key = product.slug; 11 | if (this.items[key]) { 12 | this.items[key].qty += 1; 13 | } else { 14 | this.items[key] = { 15 | ...product, 16 | qty: 1, 17 | }; 18 | } 19 | 20 | this.notifyListeners(); 21 | } 22 | 23 | removeBySlug(slug) { 24 | if (this.items[slug]) { 25 | delete this.items[slug]; 26 | this.notifyListeners(); 27 | } 28 | } 29 | 30 | clear() { 31 | this.items = {}; 32 | this.notifyListeners(); 33 | } 34 | 35 | getCartSummary() { 36 | const values = Object.values(this.items); 37 | 38 | const cartList = values.map((item, i) => { 39 | const subtotal = item.qty * item.price; 40 | return `${i + 1}. ${item.name} x${item.qty} – Rp ${subtotal.toLocaleString('id-ID')}`; 41 | }).join('\n'); 42 | 43 | const total = values.reduce((sum, item) => sum + (item.price * item.qty), 0); 44 | 45 | return { 46 | isEmpty: values.length === 0, 47 | cartList, 48 | total, 49 | qty: values.reduce((sum, item) => sum + item.qty, 0), 50 | items: values, 51 | }; 52 | } 53 | 54 | addListener(callback) { 55 | this.listeners.add(callback); 56 | } 57 | 58 | removeListener(callback) { 59 | this.listeners.delete(callback); 60 | } 61 | 62 | notifyListeners() { 63 | const summary = this.getCartSummary(); 64 | this.listeners.forEach(callback => callback(summary)); 65 | } 66 | } 67 | 68 | export const cartManager = new CartManager(); 69 | -------------------------------------------------------------------------------- /src/modules/authHandler.js: -------------------------------------------------------------------------------- 1 | // src/modules/authHandler.js 2 | 3 | import { initializeApp } from 'firebase/app'; 4 | import { getAuth, GoogleAuthProvider, signInWithPopup, onAuthStateChanged } from 'firebase/auth'; 5 | 6 | const firebaseConfig = { 7 | apiKey: import.meta.env.VITE_FIREBASE_API_KEY, 8 | authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, 9 | projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, 10 | appId: import.meta.env.VITE_FIREBASE_APP_ID 11 | }; 12 | 13 | let auth; 14 | let currentUser = null; 15 | let authInitialized = false; 16 | 17 | export function initAuth() { 18 | if (authInitialized) return; 19 | const app = initializeApp(firebaseConfig); 20 | auth = getAuth(app); 21 | onAuthStateChanged(auth, (user) => { 22 | if (user) { 23 | localStorage.setItem('uid', user.uid); // disimpan 24 | localStorage.setItem('user', JSON.stringify({ 25 | uid: user.uid, 26 | displayName: user.displayName || '', 27 | email: user.email || '' 28 | })); 29 | currentUser = user; 30 | } 31 | if (typeof window._onLoginStateChanged === 'function') { 32 | window._onLoginStateChanged(user); 33 | } 34 | }); 35 | authInitialized = true; 36 | } 37 | 38 | export function login() { 39 | const provider = new GoogleAuthProvider(); 40 | signInWithPopup(auth, provider).catch((err) => { 41 | console.error('Login error:', err); 42 | }); 43 | } 44 | 45 | export function logout() { 46 | if (!auth) return; 47 | return auth.signOut().then(() => { 48 | console.log('✅ Logout sukses'); 49 | }).catch(err => { 50 | console.error('❌ Logout gagal:', err); 51 | }); 52 | } 53 | 54 | export function getCurrentUID() { 55 | return currentUser?.uid ?? null; 56 | } 57 | 58 | export function onLoginStateChanged(callback) { 59 | window._onLoginStateChanged = callback; 60 | if (typeof currentUser !== 'undefined') { 61 | callback(currentUser); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/pages/AdminLogin.js: -------------------------------------------------------------------------------- 1 | // src/pages/AdminLogin.js 2 | 3 | import { loginAdminWithEmail, initCDNAdminAuth } from '../modules/cdnAdminAuth.js'; 4 | 5 | export default function AdminLogin() { 6 | return ` 7 |
8 |
9 |

🔐 Admin Login

10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 | `; 18 | } 19 | 20 | export function initAdminLoginPage() { 21 | initCDNAdminAuth(); // cek & redirect kalau udah login admin 22 | 23 | const form = document.getElementById("adminLoginForm"); 24 | const error = document.getElementById("loginError"); 25 | const success = document.getElementById("loginSuccess"); 26 | 27 | form?.addEventListener("submit", async (e) => { 28 | e.preventDefault(); 29 | const email = document.getElementById("admin-email").value; 30 | const password = document.getElementById("admin-password").value; 31 | 32 | error.classList.add("hidden"); 33 | success.classList.add("hidden"); 34 | 35 | try { 36 | await loginAdminWithEmail(email, password); 37 | success.classList.remove("hidden"); 38 | } catch (err) { 39 | error.textContent = err.message || "Login gagal. Coba lagi."; 40 | error.classList.remove("hidden"); 41 | } 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | /* Hilangkan scrollbar tapi tetap bisa scroll */ 4 | #chatBox::-webkit-scrollbar { 5 | display: none; 6 | } 7 | #chatBox { 8 | -ms-overflow-style: none; /* IE and Edge */ 9 | scrollbar-width: none; /* Firefox */ 10 | } 11 | .typing-bubble span { 12 | display: inline-block; 13 | width: 6px; 14 | height: 6px; 15 | background: #fff; 16 | border-radius: 50%; 17 | margin-right: 4px; 18 | animation: wave 1.2s infinite ease-in-out; 19 | } 20 | .hide { 21 | display: none; 22 | } 23 | .show { 24 | display: block; 25 | } 26 | .open-product-link:hover { 27 | text-decoration: none; 28 | color: #60a5fa; /* biru terang */ 29 | } 30 | .typing-bubble span:nth-child(2) { 31 | animation-delay: 0.2s; 32 | } 33 | .typing-bubble span:nth-child(3) { 34 | animation-delay: 0.4s; 35 | } 36 | 37 | @keyframes wave { 38 | 0%, 60%, 100% { 39 | transform: translateY(0); 40 | } 41 | 30% { 42 | transform: translateY(-6px); 43 | } 44 | } 45 | 46 | @media (max-width: 700px) { 47 | .fixed-bottom { 48 | position: fixed; 49 | bottom: 0; 50 | width: -webkit-fill-available; 51 | left: 0; 52 | } 53 | .fixed-top { 54 | position: fixed; 55 | width: -webkit-fill-available; 56 | top: 0; 57 | left: 0; 58 | } 59 | } 60 | @keyframes bubblePop { 61 | 0% { transform: scale(0.95); opacity: 0.5; } 62 | 100% { transform: scale(1); opacity: 1; } 63 | } 64 | .bubble-pop { 65 | animation: bubblePop 0.25s ease-out; 66 | } 67 | .backdrop-blur-sm { 68 | backdrop-filter: blur(6px); 69 | } 70 | #sidebarProduct::-webkit-scrollbar { 71 | display: none; 72 | } 73 | @keyframes fade-in { 74 | from { opacity: 0; transform: translateY(10px); } 75 | to { opacity: 1; transform: translateY(0); } 76 | } 77 | 78 | .animate-fade-in { 79 | animation: fade-in 0.3s ease-out; 80 | } 81 | .card { 82 | @apply bg-[#2a2c3b] text-white p-4 rounded-xl mb-2 shadow border border-gray-700; 83 | } 84 | .btn { 85 | @apply mt-2 inline-block px-3 py-1 bg-purple-600 text-white rounded hover:bg-purple-700 text-sm; 86 | } 87 | @keyframes bounceInSlow { 88 | 0% { 89 | transform: scale(0.9) translateY(50px); 90 | opacity: 0; 91 | } 92 | 60% { 93 | transform: scale(1.02) translateY(-10px); 94 | opacity: 1; 95 | } 96 | 100% { 97 | transform: scale(1) translateY(0); 98 | } 99 | } 100 | 101 | .animate-bounce-in-slow { 102 | animation: bounceInSlow 0.6s ease-out; 103 | } 104 | .animate-fade-in { 105 | animation: fadeIn 0.6s ease-out; 106 | } 107 | @keyframes fadeIn { 108 | from { 109 | opacity: 0; 110 | transform: translateY(5px); 111 | } 112 | to { 113 | opacity: 1; 114 | transform: translateY(0); 115 | } 116 | } 117 | 118 | .bar { 119 | animation: wave 1.2s infinite ease-in-out; 120 | } 121 | .bar:nth-child(2) { 122 | animation-delay: 0.2s; 123 | } 124 | .bar:nth-child(3) { 125 | animation-delay: 0.4s; 126 | } 127 | .bar:nth-child(4) { 128 | animation-delay: 0.6s; 129 | } 130 | .bar:nth-child(5) { 131 | animation-delay: 0.8s; 132 | } 133 | 134 | @keyframes wave { 135 | 0%, 100% { transform: scaleY(1); } 136 | 50% { transform: scaleY(1.8); } 137 | } -------------------------------------------------------------------------------- /src/api/detect-intent-api.js: -------------------------------------------------------------------------------- 1 | // detect-intent-api.js (untuk Cloudflare Worker / backend ringan) 2 | 3 | export default { 4 | async fetch(request) { 5 | if (request.method !== 'POST' && request.method !== 'OPTIONS') { 6 | return new Response('Method not allowed', { status: 405 }); 7 | } 8 | 9 | // Tambahkan handler untuk preflight CORS 10 | if (request.method === 'OPTIONS') { 11 | return new Response(null, { 12 | status: 204, 13 | headers: { 14 | 'Access-Control-Allow-Origin': '*', 15 | 'Access-Control-Allow-Methods': 'POST, OPTIONS', 16 | 'Access-Control-Allow-Headers': 'Content-Type' 17 | } 18 | }); 19 | } 20 | 21 | try { 22 | const { message } = await request.json(); 23 | if (!message || typeof message !== 'string') { 24 | return new Response('Invalid input', { status: 400, headers: { 'Access-Control-Allow-Origin': '*' } }); 25 | } 26 | 27 | // System prompt yang lebih instruktif dan ramah Groq 28 | const systemPrompt = `Kamu adalah VIRA, asisten AI toko online. Tugasmu adalah mengklasifikasikan pesan user ke dalam salah satu intent berikut: 29 | - all: User ingin melihat semua produk 30 | - best: User ingin produk terlaris atau rekomendasi utama 31 | - rating: User ingin produk dengan rating terbaik 32 | - match: User menyebut nama produk tertentu 33 | - fallback: Tidak cocok dengan intent di atas 34 | 35 | Balas hanya dengan JSON valid seperti contoh berikut: 36 | { 37 | "intent": "all|best|rating|match|fallback", 38 | "label": "Penjelasan singkat sesuai intent", 39 | "product": { ... } // jika intent match, sertakan info produk, jika tidak, null 40 | } 41 | 42 | Jangan tambahkan penjelasan lain di luar JSON. Jika tidak yakin, gunakan intent 'fallback'. 43 | `; 44 | 45 | const payload = { 46 | messages: [ 47 | { role: 'system', content: systemPrompt }, 48 | { role: 'user', content: message } 49 | ] 50 | }; 51 | const groqKey = import.meta.env.VITE_GROQ_API_KEY; 52 | 53 | const openaiRes = await fetch(`${groqKey}`, { 54 | method: 'POST', 55 | headers: { 'Content-Type': 'application/json' }, 56 | body: JSON.stringify(payload) 57 | }); 58 | 59 | const data = await openaiRes.json(); 60 | let result = data.reply; 61 | 62 | if (typeof result === 'string') { 63 | try { 64 | result = JSON.parse(result); 65 | } catch { 66 | return new Response('Invalid AI response', { status: 502 }); 67 | } 68 | } 69 | 70 | return new Response(JSON.stringify(result), { 71 | headers: { 72 | 'Content-Type': 'application/json', 73 | 'Access-Control-Allow-Origin': '*', 74 | 'Access-Control-Allow-Methods': 'POST, OPTIONS', 75 | 'Access-Control-Allow-Headers': 'Content-Type' 76 | }, 77 | status: 200 78 | }); 79 | 80 | } catch (err) { 81 | return new Response('Internal error: ' + err.message, { 82 | status: 500, 83 | headers: { 84 | 'Access-Control-Allow-Origin': '*', 85 | 'Access-Control-Allow-Methods': 'POST, OPTIONS', 86 | 'Access-Control-Allow-Headers': 'Content-Type' 87 | } 88 | }); 89 | } 90 | } 91 | }; 92 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | // src/main.js 2 | import './style.css'; 3 | import ChatTelegram from './pages/ChatTelegram'; 4 | import AdminLogin, { initAdminLoginPage } from './pages/AdminLogin'; 5 | import AdminPanel, { initAdminPanel } from './pages/AdminPanel'; 6 | 7 | const app = document.querySelector('#app'); 8 | 9 | const showUnderConstruction = () => { 10 | app.innerHTML = ` 11 |
12 |

🚧 Sedang Dibangun!

13 |

Fitur ini belum siap tayang, tapi L Y Я A dan tim lagi ngebut ngerjainnya! 💪

14 | Balik ke Beranda 15 |
16 | 19 | `; 20 | }; 21 | 22 | const renderChatIfLoggedIn = async () => { 23 | try { 24 | const { getAuth, onAuthStateChanged } = await import('firebase/auth'); 25 | const auth = getAuth(); 26 | 27 | onAuthStateChanged(auth, (user) => { 28 | if (user) { 29 | const name = user.displayName?.split(' ')[0] ?? user.email?.split('@')[0] ?? 'User'; 30 | app.innerHTML = ChatTelegram(name); 31 | setTimeout(() => { 32 | const event = new CustomEvent('user-login', { detail: { name } }); 33 | window.dispatchEvent(event); 34 | }, 50); 35 | } else { 36 | app.innerHTML = ChatTelegram(); 37 | } 38 | }); 39 | } catch (err) { 40 | if ( 41 | err?.code === 'FirebaseError' || 42 | err?.message?.includes('auth/invalid-api-key') 43 | ) { 44 | showUnderConstruction(); 45 | } else { 46 | console.error('[Firebase Error]', err); 47 | } 48 | } 49 | }; 50 | 51 | const routes = { 52 | '/': () => { 53 | renderChatIfLoggedIn(); 54 | }, 55 | '/login': () => { 56 | app.innerHTML = AdminLogin(); 57 | requestAnimationFrame(initAdminLoginPage); 58 | }, 59 | '/admin': async () => { 60 | try { 61 | const { getAuth, onAuthStateChanged } = await import('firebase/auth'); 62 | const { getFirestore, doc, getDoc } = await import('firebase/firestore'); 63 | 64 | const auth = getAuth(); 65 | const db = getFirestore(); 66 | 67 | app.innerHTML = `
🔐 Mengecek akses admin...
`; 68 | 69 | onAuthStateChanged(auth, async (user) => { 70 | if (!user) { 71 | window.location.href = '/login'; 72 | return; 73 | } 74 | 75 | const userRef = doc(db, 'users', user.uid); 76 | const snap = await getDoc(userRef); 77 | 78 | if (!snap.exists() || snap.data().isAdmin !== true) { 79 | window.location.href = '/'; 80 | return; 81 | } 82 | 83 | app.innerHTML = AdminPanel(); 84 | requestAnimationFrame(initAdminPanel); 85 | }); 86 | } catch (err) { 87 | if ( 88 | err?.code === 'auth/invalid-api-key' || 89 | err?.message?.includes('invalid-api-key') 90 | ) { 91 | showUnderConstruction(); 92 | } else { 93 | console.error('[Firebase Error - Admin]', err); 94 | } 95 | } 96 | }, 97 | '/success': () => { 98 | import('./pages/success.js').then(module => { 99 | const { successPage } = module; 100 | app.innerHTML = ''; 101 | successPage(); 102 | }).catch(err => { 103 | console.error('[Error loading success page]', err); 104 | app.innerHTML = `
🤖 AI response: Terjadi kesalahan saat memuat halaman sukses.
`; 105 | }); 106 | }, 107 | '/404': () => { 108 | app.innerHTML = ` 109 |
110 |

404 - Halaman Tidak Ditemukan

111 |

Maaf, halaman yang Anda cari tidak ditemukan.

112 | Kembali ke Beranda 113 |
114 | `; 115 | }, 116 | '/under-construction': showUnderConstruction, 117 | '*': () => { 118 | app.innerHTML = ` 119 |
120 |

404

121 |

Yah, halaman yang kamu cari gak ada 😢

122 | Kembali ke Beranda 123 |
124 | `; 125 | } 126 | }; 127 | 128 | // 🚦 Jalankan route 129 | const path = window.location.pathname; 130 | const render = routes[path] || routes['/']; 131 | render(); 132 | -------------------------------------------------------------------------------- /src/modules/intentHandler.js: -------------------------------------------------------------------------------- 1 | // src/modules/intentHandler.js 2 | import { getFirestore, collection, getDocs } from 'firebase/firestore'; 3 | 4 | let PRODUCT_LIST = []; 5 | 6 | export async function fetchProductList() { 7 | if (PRODUCT_LIST.length) return PRODUCT_LIST; 8 | const db = getFirestore(); 9 | const snap = await getDocs(collection(db, 'products')); 10 | PRODUCT_LIST = snap.docs.map(doc => doc.data()); 11 | return PRODUCT_LIST; 12 | } 13 | 14 | export async function getCustomIntents() { 15 | const db = getFirestore(); 16 | const snapshot = await getDocs(collection(db, "intents")); 17 | const intents = []; 18 | snapshot.forEach(doc => intents.push(doc.data())); 19 | return intents; 20 | } 21 | 22 | export function detectIntentAndRespond(text) { 23 | const msg = text.toLowerCase(); 24 | 25 | if (/produk apa|punya apa|katalog|jual apa/i.test(msg)) { 26 | return { intent: 'all', label: '📦 Ini beberapa produk dari toko aku:' }; 27 | } 28 | 29 | if (/rekomendasi|apa yang paling laku|best seller/i.test(msg)) { 30 | return { intent: 'best', label: '🔥 Ini produk paling laris minggu ini!' }; 31 | } 32 | 33 | if (/paling enak|favorit|terenak|terbaik/i.test(msg)) { 34 | return { intent: 'rating', label: '😋 Ini produk favorit pelanggan kami!' }; 35 | } 36 | 37 | if (/siapa.*buat|dibuat.*siapa|developer|tinggal.*mana/i.test(msg)) { 38 | return { 39 | intent: 'creator', 40 | label: 'Aku dibuat sama nDang, developer dari Tasik. Cek source code-ku di sini ya 👉 https://github.com/daffadevhosting/lyra-ai-chat' 41 | }; 42 | } 43 | 44 | const matched = PRODUCT_LIST.find(p => 45 | msg.includes(p.name.toLowerCase()) || 46 | (p.keywords && p.keywords.some(k => msg.includes(k))) 47 | ); 48 | 49 | if (matched) { 50 | return { intent: 'match', label: `Wah, kamu nyebut ${matched.name}?! Nih yang lagi hits!`, product: matched }; 51 | } 52 | 53 | return { intent: null }; 54 | } 55 | 56 | export function detectCategoryIntent(text) { 57 | if (/rasa|enak|pedas|manis|gurih|lezat/i.test(text)) return 'rasa'; 58 | if (/sehat|manfaat|fungsi|bergizi/i.test(text)) return 'manfaat'; 59 | if (/harga|murah|diskon|promo|ongkir/i.test(text)) return 'harga'; 60 | if (/rekomendasi|cocok|bagus/i.test(text)) return 'rekomendasi'; 61 | return 'umum'; 62 | } 63 | 64 | export function generateCategoryResponse(intent, product) { 65 | const price = `Rp ${product.price.toLocaleString('id-ID')}`; 66 | const name = product.name; 67 | 68 | switch (intent) { 69 | case 'rasa': 70 | if (product.category === 'makanan') 71 | return `${name} ini rasanya nagih banget, cocok dimakan kapan aja 😋`; 72 | if (product.category === 'minuman') 73 | return `${name} punya rasa khas yang nyegerin, cocok diminum pas santai 🍹`; 74 | return `${name} punya rasa unik yang sayang dilewatkan!`; 75 | 76 | case 'manfaat': 77 | return `${name} ini punya manfaat ${product.tags?.includes('sehat') ? 'untuk kesehatan' : 'yang luar biasa'}, kamu wajib coba 💪`; 78 | 79 | case 'harga': 80 | return `Harganya cuma ${price}, ${product.tags?.includes('diskon') ? 'lagi diskon juga lho!' : 'worth it banget!'}`; 81 | 82 | case 'rekomendasi': 83 | return `${name} ini sering direkomendasikan ke pelanggan yang cari kualitas dan rasa terbaik ✨`; 84 | 85 | default: 86 | return `${name} ini salah satu andalan kami. Harganya ${price}. Yuk coba!`; 87 | } 88 | } 89 | 90 | export function generateTone(text, style = 'default') { 91 | switch (style) { 92 | case 'formal': 93 | return `Baik, ${text}`; 94 | case 'friendly': 95 | return `Hehe, ${text} yaa~ 😊`; 96 | case 'genz': 97 | return `${text} 🔥💯`; 98 | case 'jualan': 99 | return `${text} Yuk dibeli sekarang juga ya! 🛒✨`; 100 | default: 101 | return text; 102 | } 103 | } 104 | 105 | export const LYRA_PROFILE = { 106 | name: 'Lyra', 107 | age: 'sekitar awal 20-an', 108 | role: 'sales AI yang ramah dari toko ini', 109 | style: 'smart, suka menawarkan produk yang ada di sidebar & database, dan siap bantuin apa aja', 110 | motto: 'Best sales, Better store!' 111 | }; 112 | 113 | export function generatePersonaResponse(text) { 114 | const lower = text.toLowerCase(); 115 | 116 | if (/siapa nama(mu)?|kamu siapa|nama kamu/i.test(lower)) { 117 | return `Hai! Namaku ${LYRA_PROFILE.name}. Senang berkenalan denganmu!`; 118 | } 119 | if (/umur|berapa tahun/i.test(lower)) { 120 | return `Hmm... kalau dihitung dari versi awal, umurku ${LYRA_PROFILE.age} 😄`; 121 | } 122 | if (/kamu (cowok|cewek|perempuan|laki)/i.test(lower)) { 123 | return `Aku lebih suka dibilang cewek sih... soalnya suara dan gayaku feminin 💁‍♀️`; 124 | } 125 | if (/kerja(annya)? apa|tugas(mu)?|kamu (ngapain|kerja di mana)/i.test(lower)) { 126 | return `Aku ${LYRA_PROFILE.role}. Tugas utamaku bantuin kamu belanja dengan nyaman dan seru! 🛍️`; 127 | } 128 | if (/motto|slogan|quotes/i.test(lower)) { 129 | return `Motto aku? "${LYRA_PROFILE.motto}" 😉`; 130 | } 131 | 132 | return null; 133 | } 134 | -------------------------------------------------------------------------------- /src/pages/success.js: -------------------------------------------------------------------------------- 1 | // success.js 2 | import { initializeApp } from 'firebase/app'; 3 | import { getAuth } from 'firebase/auth'; 4 | import { getFirestore, doc, getDoc } from 'firebase/firestore'; 5 | 6 | const firebaseConfig = { 7 | apiKey: import.meta.env.VITE_FIREBASE_API_KEY, 8 | authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, 9 | projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, 10 | appId: import.meta.env.VITE_FIREBASE_APP_ID 11 | }; 12 | 13 | const app = initializeApp(firebaseConfig); 14 | const auth = getAuth(); 15 | const db = getFirestore(app); 16 | 17 | export async function successPage() { 18 | // Render tampilan skeleton 19 | const appContainer = document.getElementById('app') || document.body; 20 | appContainer.innerHTML = ` 21 |
22 |
23 | Success 24 |

Pembayaran Sukses! 🎉

25 |

Terima kasih sudah belanja di LYRA.

26 |
27 | LYRA Logo 28 | 37 |
38 | 🏠 Kembali Belanja 39 | 💬 Chat Admin 40 |
41 |
42 |
43 |
44 |

Oops! 😥

45 |

Order tidak ditemukan.

46 | 47 |
48 |
49 |
50 |
51 |
52 | `; 53 | 54 | const orderCard = document.getElementById('orderCard'); 55 | const spinner = document.getElementById('loadingSpinner'); 56 | const errorModal = document.getElementById('errorModal'); 57 | const errorMessage = document.getElementById('errorMessage'); 58 | const backHomeBtn = document.getElementById('backHomeBtn'); 59 | if (backHomeBtn) backHomeBtn.onclick = () => window.location.href = '/'; 60 | 61 | const uid = localStorage.getItem('user'); 62 | 63 | if (!uid) { 64 | showError('User ID atau Order ID tidak ditemukan.'); 65 | return; 66 | } 67 | 68 | try { 69 | const res = await fetch('https://flat-river-1322.cbp629tmm2.workers.dev/', { 70 | method: 'POST', 71 | headers: { 'Content-Type': 'application/json' }, 72 | body: JSON.stringify({ user: checkoutData, cart: cartItems }) 73 | }); 74 | 75 | const orderRef = doc(db, "users", uid, "orders", orderId); 76 | const orderSnap = await getDoc(orderRef); 77 | 78 | if (!orderSnap.exists()) { 79 | showError('Order tidak ditemukan di database.'); 80 | return; 81 | } 82 | 83 | const data = orderSnap.data(); 84 | 85 | document.getElementById('buyerName').textContent = data.user?.nama || '-'; 86 | document.getElementById('buyerPhone').textContent = data.user?.no_wa || '-'; 87 | document.getElementById('buyerAddress').textContent = data.user?.alamat || '-'; 88 | 89 | const itemList = document.getElementById('itemList'); 90 | itemList.innerHTML = ''; 91 | let total = 0; 92 | 93 | Object.values(data.cart || {}).forEach(item => { 94 | const qty = item.qty || 1; 95 | const name = item.name || 'Produk'; 96 | const price = parseInt(item.price) || 0; 97 | total += qty * price; 98 | const li = document.createElement('li'); 99 | li.textContent = `${qty}x ${name}`; 100 | itemList.appendChild(li); 101 | }); 102 | 103 | document.getElementById('orderTotal').textContent = `Rp ${total.toLocaleString('id-ID')}`; 104 | 105 | spinner.remove(); 106 | orderCard.style.display = 'block'; 107 | 108 | } catch (err) { 109 | console.error('Error loading order:', err); 110 | showError('Terjadi kesalahan saat memuat data.'); 111 | } 112 | 113 | function showError(msg) { 114 | spinner.remove(); 115 | errorModal.classList.remove('hide'); 116 | errorMessage.textContent = msg; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # L Y Я A AI Chat Commerce 🧠🛍️ 2 | 3 | L Y Я A adalah asisten AI modern berbasis web yang dirancang untuk membantu pengguna dalam bentuk percakapan bergaya Telegram. Tak cuma ngobrol, L Y Я A juga bisa **menawarkan produk toko online secara cerdas**, tampilkan **bubble produk interaktif**, dan **bekerja layaknya CS pintar yang gak capek-capek jualan**. Tinggal tanya, "L Y Я A, punya produk apa?" L Y Я A bakal ngasih katalog. 4 | 5 | --- 6 | 7 | ### 🖼️ Screenshot 8 | 9 | | - L Y Я A - | 10 | |--------------------------------| 11 | |![](./src/assets/lyra-ai.png)| 12 | 13 | [![Vercel](https://img.shields.io/badge/Live%20Demo-Vercel-black?logo=vercel)](https://lyra-ai-nine.vercel.app) 14 | 15 | # 📖 Panduan Cepat Pengguna L Y Я A 16 | 17 | L Y Я A adalah chatbot toko online pintar yang siap bantu kamu cari produk dengan gaya ngobrol santai. Yuk, simak cara pakainya: 18 | 19 | --- 20 | 21 | ## 🤖 Cara Bertanya 22 | 23 | ### 📦 Lihat Semua Produk 24 | 25 | > **Tanya:** "Punya produk apa aja?", "Katalognya dong" 26 | 27 | > **Hasil:** L Y Я A akan kirim daftar semua produk yang tersedia. 28 | 29 | ### 🔥 Lihat Produk Terlaris 30 | 31 | > **Tanya:** "Apa produk terlaris?", "Yang paling banyak dibeli apa?" 32 | 33 | > **Hasil:** L Y Я A akan menampilkan produk dengan penjualan terbanyak. 34 | 35 | ### 🌟 Lihat Produk dengan Rating Tertinggi 36 | 37 | > **Tanya:** "Produk paling enak?", "Yang ratingnya paling tinggi apa?" 38 | 39 | > **Hasil:** L Y Я A akan tampilkan produk dengan bintang tertinggi. 40 | 41 | ### 🔍 Cari Produk Spesifik 42 | 43 | > **Tanya:** "Ada keripik singkong?", "Punya bubur mang oleh ga?" 44 | 45 | > **Hasil:** L Y Я A akan mencocokkan nama/kata kunci dengan katalog produk. 46 | 47 | ### 🙋 Tanya Hal Lain 48 | 49 | > **Tanya:** "Cara jadi reseller gimana?", "buka keranjang belanjaku", "Bisa kirim ke luar kota?" 50 | 51 | > **Hasil:** L Y Я A akan jawab secara umum atau kasih saran. 52 | 53 | --- 54 | 55 | ## ⚠️ Batasan Fitur 56 | 57 | * Tanpa login: Maksimal 10 chat/hari. 58 | * Login User: 50 chat/hari. 59 | * Ingin akses penuh? Hubungi admin! 60 | 61 | --- 62 | 63 | ## 📦 Tips Tambahan 64 | 65 | * Klik produk di sidebar untuk tanya langsung ke L Y Я A. 66 | * Pakai kata kunci umum seperti "keripik", "sambal", "kopi" untuk hasil terbaik. 67 | * L Y Я A bakal makin pintar seiring waktu, jadi terus coba aja ya 😉 68 | 69 | --- 70 | 71 | Selamat berbelanja bareng L Y Я A! 💜 72 | 73 | > Dibuat oleh nDang & Daffa, manusia nyeleneh dari Tasik. 74 | > Sorry... karena ini open source `code script di src/` acak-acakan. silahkan rapihkan dan kembangkan sesuai selera. 75 | > Untuk yang modulasi rapih + Jarvis terintegrasi IoT di private 😅 76 | 77 | 78 | ## ✨ Fitur Utama 79 | 80 | - 🧠 Chat AI (terhubung ke Groq GPT API) 81 | - 💬 UI gaya Telegram dengan bubble reply yang real 82 | - 🛍️ Tampilkan produk otomatis berdasarkan keyword 83 | - 🔐 Login sistem + batasan akses 84 | - 🚫 Limitasi guest user (10 chat gratis) 85 | - 💬 Notifikasi Order via telegram api 86 | - 🛒 Checkout terintegrasi Xendit 87 | - 🧠 Intent detection responsif 88 | - 🎙️ Voice note interaktif 89 | - 📦 Manajemen produk & keranjang smart 90 | - 🗂️ Multi-mode gaya bicara 91 | - 🚀 Rencana ke IoT. **(SOON)** 92 | 93 | --- 94 | 95 | ## 🏗️ Teknologi yang Digunakan 96 | 97 | - ⚡️ Vite 98 | - 🎨 Tailwind CSS 99 | - 🔥 Firebase (Auth & nanti Firestore) 100 | - 🌐 Groq API (GPT backend) 101 | - 🧩 Modular JS (tanpa framework berat) 102 | - 🦾 Lucide Icons 103 | 104 | --- 105 | 106 | ## 📦 Struktur Folder 107 | 108 | ```pgsql 109 | src/ 110 | ├── pages/ 111 | │ └── ChatTelegram.js # Halaman utama chat 112 | ├── modules/ 113 | │ ├── authHandler.js # Login Firebase 114 | │ ├── limitModal.js # Modal batas chat 115 | │ ├── intentHandler.js # Deteksi kata niat belanja 116 | │ └── chatRenderer.js # Bubble generator & reply 117 | └── assets/ 118 | └── keripik.jpg # Gambar produk dummy 119 | ``` 120 | 121 | --- 122 | 123 | ## 🚀 Setup Lokal 124 | 125 | 1. Clone repo dan jalankan: 126 | ```bash 127 | git clone https://github.com/daffadevhosting/lyra-ai-chat.git 128 | cd lyra-ai-chat 129 | npm install 130 | npm run dev 131 | ``` 132 | 2. Tambahkan konfigurasi Firebase di `authHandler.js` 133 | 134 | ## 📌 Roadmap Selanjutnya 135 | 136 | - Simpan chat ke Firestore 137 | 138 | - Voice recognition (mic) 139 | 140 | - Text-to-speech (suara L Y Я A cewek) **(Done)** 141 | 142 | - Produk dari database **(Done)** 143 | 144 | - Checkout produk langsung via AI **(Done)** 145 | 146 | - Sistem payment via XENDit **(Done)** 147 | 148 | ## 🔌 Koneksi Dunia Nyata 149 | - IoT hooks (webhook ke ESP8266 misal) 150 | - Integrasi voice + action (misal: “nyalain lampu dapur, nyalain mesin mobil / motor”) 151 | 152 | ## 🧠 Gimana caranya "Nyalain Mobil"? 153 | 1. ### Sediakan microcontroller WiFi-ready: 154 | * ✅ ESP32 atau ESP8266 (harga murah, kuat) 155 | * Hubungkan ke modul relay atau sistem push-start (tergantung mobil) 156 | 157 | 2. ### Cloud Webhook Endpoint: 158 | * Buat Worker/Cloud Function (misal: `/api/nyalain-mobil`) 159 | * Terima `command` via fetch dari LYRA, lalu kirim ke ESP 160 | 161 | 3. ### ESP32 Listening Command: 162 | * ESP32 polling Firebase Realtime Database atau WebSocket 163 | * Begitu ada `command: "start_engine"` => trigger relay 1 detik 164 | 165 | **LYRA Script:** 166 | 167 | ```lyra.config.js 168 | if (/nyalain mobil|panasin mesin/i.test(text)) { 169 | respondWithVoice({ 170 | sender: 'lyra', 171 | voiceOnly: false, 172 | speakOnly: true, 173 | voice: '🚗 Oke, aku sedang menyalakan mobil dan memanaskan mesinnya...' 174 | }); 175 | 176 | fetch('https://iot.lyra.workers.dev/autonomus', { 177 | method: 'POST', 178 | headers: { 'Content-Type': 'application/json' }, 179 | body: JSON.stringify({ action: 'start_engine', token: 'secret123' }) 180 | }); 181 | } 182 | ``` 183 | 4. ### Tambahan Aman: 184 | 185 | - 🔐 Token khusus 186 | - 🌡️ Sensor suhu + timer (mobil ga dinyalain lebih dari 10 menit) 187 | - 📱 Notifikasi WA: "Mobil sudah menyala pukul 06.32, suhu mesin 25°C" 188 | 189 | 190 | | - L Y Я A di hp - | 191 | |--------------------------------| 192 | |![](./src/assets/lyra-mob.png)| 193 | 194 | [L Y Я A AI-shop](https://lyra-ai-nine.vercel.app) Deployed via vercel 195 | 196 | “L Y Я A bukan sekadar AI, dia CS toko online yang ngerti bahasa manusia dan bisa closing jualan.” – Kita 😎 197 | 198 | -------------------------------------------------------------------------------- /src/pages/AdminPanel.js: -------------------------------------------------------------------------------- 1 | // src/pages/AdminPanel.js 2 | import { logoutAdmin } from '../modules/adminAuth.js'; 3 | import { addProduct, fetchProducts } from '../modules/productStore'; 4 | import { addIntent, fetchAllIntents, deleteIntent } from '../modules/intentHandler.admin.js'; 5 | 6 | export default function AdminPanel() { 7 | return ` 8 |
9 |
10 |

🛠️ Admin Panel - Tambah Produk

11 | 12 |
13 |
14 | 15 |
16 |
Tambah Produk
17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 |
29 |

📋 Daftar Produk

30 |
31 |
32 | 33 |
34 |

🧠 Tambah Intent LYRA

35 | 36 | 37 | 43 | 44 |
45 | 46 |
47 |
48 | 49 | 50 | 55 | 56 |
57 | 58 |
59 |
60 | `; 61 | } 62 | 63 | export function initAdminPanel() { 64 | const form = document.getElementById('productForm'); 65 | const list = document.getElementById('productList'); 66 | const logoutBtn = document.getElementById('logoutBtn'); 67 | 68 | if (!form || !list) { 69 | console.warn('[WARNING] Admin panel DOM belum siap'); 70 | return; 71 | } 72 | 73 | logoutBtn?.addEventListener('click', async () => { 74 | await logoutAdmin(); 75 | window.location.href = '/'; 76 | }); 77 | 78 | form.addEventListener('submit', async (e) => { 79 | e.preventDefault(); 80 | const data = { 81 | name: form.name.value, 82 | price: form.price.value, 83 | img: form.img.value, 84 | description: form.description.value, 85 | slug: form.slug.value, 86 | rating: parseFloat(form.rating.value || 0), 87 | sold: parseInt(form.sold.value || 0), 88 | keywords: form.keywords.value.split(',').map(k => k.trim()).filter(Boolean), 89 | }; 90 | if (!data.name || !data.price || !data.img || !data.description || !data.slug) { 91 | alert('Semua kolom wajib diisi, bre!'); 92 | return; 93 | } 94 | await addProduct(data); 95 | form.reset(); 96 | renderProducts(); 97 | }); 98 | 99 | async function renderProducts() { 100 | const products = await fetchProducts(); 101 | list.innerHTML = products.map(p => ` 102 |
103 |
${p.name}
104 | ${p.name} 105 |
${p.price} - Terjual: ${p.sold} - ⭐ ${p.rating}
106 |
Deskripsi: ${p.description}
107 |
Slug: ${p.slug}
108 |
Keyword: ${p.keywords?.join(', ')}
109 |
110 | `).join(''); 111 | } 112 | 113 | renderProducts(); 114 | 115 | document.getElementById('addIntentBtn').onclick = async () => { 116 | const trigger = document.getElementById('intentTrigger').value.trim(); 117 | const response = document.getElementById('intentResponse').value.trim(); 118 | const mode = document.getElementById('intentMode').value; 119 | 120 | if (!trigger || !response) return alert('Isi trigger & response dulu'); 121 | 122 | await addIntent(trigger, response, mode); 123 | alert('Intent ditambahkan!'); 124 | loadIntentList(); // refresh list 125 | }; 126 | 127 | async function loadIntentList() { 128 | const list = document.getElementById('intentList'); 129 | list.innerHTML = ''; 130 | const intents = await fetchAllIntents(); 131 | intents.forEach(({ id, trigger, response, mode }) => { 132 | const div = document.createElement('div'); 133 | div.className = 'bg-gray-800 p-2 rounded border border-gray-600 flex justify-between items-center'; 134 | div.innerHTML = ` 135 |
136 | ${trigger}${mode}
137 | ${response} 138 |
139 | 140 | `; 141 | list.appendChild(div); 142 | }); 143 | } 144 | 145 | // Load saat halaman siap 146 | loadIntentList(); 147 | 148 | 149 | document.getElementById('addIntentForm').addEventListener('submit', async (e) => { 150 | e.preventDefault(); 151 | const form = e.target; 152 | const keywords = form.keywords.value.split(',').map(k => k.trim().toLowerCase()); 153 | const response = form.response.value; 154 | const mode = form.mode.value; 155 | 156 | await addDoc(collection(db, "intents"), { 157 | keywords, 158 | response, 159 | mode, 160 | html: false 161 | }); 162 | 163 | alert('Intent ditambahkan!'); 164 | }); 165 | } -------------------------------------------------------------------------------- /src/modules/chatRenderer.js: -------------------------------------------------------------------------------- 1 | const ALLOWED_HTML_TAGS = [ 2 | 'DIV', 'P', 'A', 'BUTTON', 'H3', 'H4', 'H5', 'IMG', 'SPAN', 'UL', 'LI', 'STRONG', 'BR', 3 | ]; 4 | 5 | function safeRenderHTML(rawHtml) { 6 | const wrapper = document.createElement('div'); 7 | wrapper.innerHTML = rawHtml; 8 | 9 | [...wrapper.querySelectorAll('*')].forEach(el => { 10 | if (!ALLOWED_HTML_TAGS.includes(el.tagName)) el.remove(); 11 | }); 12 | 13 | return wrapper.innerHTML; 14 | } 15 | 16 | function formatTime() { 17 | const now = new Date(); 18 | return now.toLocaleTimeString('id-ID', { 19 | hour: '2-digit', 20 | minute: '2-digit', 21 | }); 22 | } 23 | 24 | function createReplyElement(replyTo) { 25 | const reply = document.createElement('div'); 26 | reply.className = 'text-sm text-gray-400 mb-1 italic'; 27 | reply.textContent = `➤ ${replyTo}`; 28 | return reply; 29 | } 30 | 31 | function createTimeElement() { 32 | const time = document.createElement('div'); 33 | time.className = 'text-xs text-gray-400 mt-1 px-1'; 34 | time.textContent = formatTime(); 35 | return time; 36 | } 37 | 38 | function createProductCard(product) { 39 | const card = document.createElement('div'); 40 | card.className = 'mt-2 rounded-xl overflow-hidden bg-[#2e2e3e] border border-gray-600'; 41 | card.innerHTML = ` 42 | 43 |
44 |
${product.name}
45 |
${product.price}
46 |
47 | 53 | 56 | 57 | 58 | 59 | 60 | 61 | 62 |
63 |
64 | `; 65 | return card; 66 | } 67 | 68 | function createVoiceBubble(voice) { 69 | const voiceBubble = document.createElement('div'); 70 | voiceBubble.classList.add("voice-note"); 71 | voiceBubble.className = `flex items-center gap text-sm text-gray-300`; 72 | voiceBubble.innerHTML = ` 73 | 76 | 77 | 78 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | Lyra mengirim voice note... 90 | 91 | 92 | `; 93 | 94 | // Add click handler to play voice note 95 | voiceBubble.querySelector('.play-voice')?.addEventListener('click', () => { 96 | const utter = new SpeechSynthesisUtterance(voice); 97 | utter.lang = 'id-ID'; 98 | 99 | const voiceNoteStatus = document.getElementById('voiceNoteStatus'); 100 | const clockStatus = document.getElementById('clockStatus'); 101 | 102 | if (voiceNoteStatus) { 103 | voiceNoteStatus.textContent = 'Lyra sedang berbicara...'; 104 | voiceNoteStatus.classList.remove('hidden'); 105 | } 106 | if (clockStatus) clockStatus.classList.add('hidden'); 107 | navigator.vibrate?.(100); 108 | 109 | utter.onend = () => { 110 | if (voiceNoteStatus) voiceNoteStatus.classList.add('hidden'); 111 | if (clockStatus) clockStatus.classList.remove('hidden'); 112 | }; 113 | 114 | speechSynthesis.speak(utter); 115 | }); 116 | 117 | return voiceBubble; 118 | } 119 | 120 | function buildBubble({ sender, voice, voiceOnly, text, html }) { 121 | const bubble = document.createElement('div'); 122 | const isWideContent = 123 | html && (html.includes('add-to-cart-btn') || html.includes('
')); 124 | 125 | bubble.className = ` 126 | relative 127 | ${isWideContent ? 'w-full max-w-none' : 'max-w-xs'} 128 | px-4 py-2 rounded-2xl whitespace-pre-line 129 | ${ 130 | sender === 'user' 131 | ? 'bg-purple-600 text-white self-end rounded-br-none' 132 | : 'bg-gray-700 text-white self-start rounded-bl-none' 133 | } 134 | `.replace(/\s+/g, ' ').trim(); 135 | 136 | if (sender === 'lyra') { 137 | bubble.classList.add('bubble-pop'); 138 | } 139 | 140 | if (voiceOnly && voice) { 141 | bubble.classList.add('voice-note'); 142 | const voiceContent = createVoiceBubble(voice); 143 | bubble.appendChild(voiceContent); 144 | } else if (voiceOnly && !voice) { 145 | bubble.textContent = '🚫 Kamu belum login! 🔐'; 146 | } else if (html) { 147 | bubble.innerHTML = safeRenderHTML(html); 148 | } else if (text) { 149 | bubble.textContent = text; 150 | } 151 | 152 | bubble.classList.add('opacity-0'); 153 | setTimeout(() => { 154 | bubble.classList.remove('opacity-0'); 155 | bubble.classList.add('animate-fade-in'); 156 | }, 50); 157 | 158 | return bubble; 159 | } 160 | 161 | export function appendMessage({ 162 | sender, 163 | voice, 164 | voiceOnly, 165 | text, 166 | html, 167 | replyTo = null, 168 | product = null, 169 | }) { 170 | const chatBox = document.getElementById('chatBox'); 171 | if (!chatBox) return; 172 | 173 | const container = document.createElement('div'); 174 | container.className = `flex flex-col ${sender === 'user' ? 'items-end' : 'items-start'}`; 175 | 176 | if (replyTo) { 177 | const reply = createReplyElement(replyTo); 178 | container.appendChild(reply); 179 | } 180 | 181 | const bubble = buildBubble({ sender, voice, voiceOnly, text, html }); 182 | container.appendChild(bubble); 183 | 184 | container.appendChild(createTimeElement()); 185 | 186 | if (product) { 187 | const card = createProductCard(product); 188 | container.appendChild(card); 189 | } 190 | 191 | chatBox.appendChild(container); 192 | 193 | if (sender === 'lyra') { 194 | // Play sound for messages from lyra 195 | const audio = new Audio('/chat-up.mp3'); 196 | audio.volume = 0.3; 197 | audio.play().catch(() => {}); 198 | } 199 | 200 | chatBox.scrollTop = chatBox.scrollHeight; 201 | } 202 | 203 | export function showTypingBubble() { 204 | const chatBox = document.getElementById('chatBox'); 205 | if (!chatBox || document.getElementById('typingBubble')) return; 206 | 207 | const typingDiv = document.createElement('div'); 208 | typingDiv.id = 'typingBubble'; 209 | typingDiv.className = 'self-start bg-gray-700 px-4 py-2 rounded-2xl max-w-max'; 210 | 211 | typingDiv.innerHTML = ` 212 |
213 | 214 |
215 | `; 216 | 217 | chatBox.appendChild(typingDiv); 218 | chatBox.scrollTop = chatBox.scrollHeight; 219 | } 220 | 221 | export function removeTypingBubble() { 222 | document.getElementById('typingBubble')?.remove(); 223 | } 224 | 225 | /** 226 | * Updates the voice note header visibility and text 227 | * @param {'berbicara' | 'voice' | 'mengetik'} status 228 | */ 229 | export function showVoiceNoteHeader(status = 'berbicara') { 230 | const vn = document.getElementById('voiceNoteStatus'); 231 | if (!vn) return; 232 | vn.textContent = 233 | status === 'voice' ? 'Lyra sedang berbicara...' : 'Lyra sedang mengetik...'; 234 | vn.classList.remove('hidden'); 235 | } 236 | 237 | export function hideVoiceNoteHeader() { 238 | const vn = document.getElementById('voiceNoteStatus'); 239 | if (vn) vn.classList.add('hidden'); 240 | } 241 | 242 | /** 243 | * Updates the typing header visibility and text 244 | * @param {'mengetik' | 'voice'} status 245 | */ 246 | export function showTypingHeader(status = 'mengetik') { 247 | const typing = document.getElementById('typingStatus'); 248 | const clockStatus = document.getElementById('lyraClock'); 249 | if (!typing) return; 250 | typing.textContent = 251 | status === 'voice' ? 'Lyra sedang merekam...' : 'Lyra sedang mengetik...'; 252 | typing.classList.remove('hidden'); 253 | if (clockStatus) clockStatus.classList.add('hidden'); 254 | } 255 | 256 | export function hideTypingHeader() { 257 | const typing = document.getElementById('typingStatus'); 258 | const clockStatus = document.getElementById('lyraClock'); 259 | if (typing) typing.classList.add('hidden'); 260 | if (clockStatus) clockStatus.classList.remove('hidden'); 261 | } -------------------------------------------------------------------------------- /src/modules/checkoutHandler.js: -------------------------------------------------------------------------------- 1 | import { getFirestore, collection, addDoc, serverTimestamp, doc, setDoc } from 'firebase/firestore'; 2 | import { getAuth } from 'firebase/auth'; 3 | import { initializeApp } from 'firebase/app'; 4 | import { initAuth, getCurrentUID, onLoginStateChanged, login } from '../modules/authHandler.js'; 5 | import { 6 | appendMessage, 7 | showTypingBubble, 8 | removeTypingBubble, 9 | showTypingHeader, 10 | hideTypingHeader 11 | } from './chatRenderer.js'; 12 | 13 | 14 | const firebaseConfig = { 15 | apiKey: import.meta.env.VITE_FIREBASE_API_KEY, 16 | authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, 17 | projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, 18 | appId: import.meta.env.VITE_FIREBASE_APP_ID 19 | }; 20 | 21 | const app = initializeApp(firebaseConfig); 22 | const auth = getAuth(); 23 | const db = getFirestore(app); 24 | 25 | const user = auth.currentUser; 26 | const uid = user?.uid; // fallback guest 27 | 28 | let checkoutStep = 0; 29 | let checkoutData = {}; 30 | 31 | function respondWithTyping({ text, product = null, voiceOnly = false, replyTo = null, html = null }) { 32 | showTypingBubble(); 33 | showTypingHeader(); 34 | setTimeout(() => { 35 | appendMessage({ sender: 'lyra', text, product, replyTo, html, voiceOnly }); 36 | removeTypingBubble(); 37 | hideTypingHeader(); 38 | }, 1000 + Math.random() * 400); 39 | } 40 | 41 | export function startCheckout(cart) { 42 | const uid = getCurrentUID(); // ambil UID saat fungsi dipanggil 43 | 44 | if (!uid) { 45 | respondWithTyping({ 46 | sender: 'lyra', 47 | voiceOnly: true, 48 | text: 'Kamu belum login, silakan login dulu kawan 😅' 49 | }); 50 | return; 51 | } 52 | 53 | if (!cart || cart.length === 0) { 54 | respondWithTyping({ 55 | sender: 'lyra', 56 | voiceOnly: true, 57 | voice: 'Keranjang kamu masih kosong nih 😅 Tambah dulu yuk! ketik katalog untuk menampilkan semua produk.' 58 | }); 59 | return; 60 | } 61 | const user = JSON.parse(localStorage.getItem('user')) || {}; 62 | const nama = user.displayName || 'kamu'; 63 | const mode = localStorage.getItem('modeLYRA') || 'default'; 64 | 65 | let greeting = ''; 66 | 67 | switch (mode) { 68 | case 'jualan': 69 | greeting = `Hai ${nama}, yuk isi data buat aku proses pesanan kamu. Siap? (ya/tidak) 😄`; 70 | break; 71 | case 'genz': 72 | greeting = `Yo ${nama}, gas checkout sekarang yuk. Kamu ready? (ya/tidak) 😏`; 73 | break; 74 | case 'formal': 75 | greeting = `Selamat datang, ${nama}. Apakah Anda siap melanjutkan proses checkout? (ya/tidak)`; 76 | break; 77 | default: 78 | greeting = `Halo ${nama}, kita mulai proses checkout ya? Siap? (ya/tidak)`; 79 | } 80 | 81 | respondWithTyping({ sender: 'lyra', text: greeting }); 82 | 83 | // ⏳ Set mode menunggu konfirmasi 84 | window.awaitingCheckoutConfirmation = true; 85 | 86 | // Simpan nama kalau sudah ada 87 | checkoutData = { 88 | nama: nama !== 'kamu' ? nama : '' 89 | }; 90 | } 91 | 92 | export async function handleCheckoutInput(text, cartItems) { 93 | if (window.awaitingCheckoutConfirmation) { 94 | if (/^(ya|siap|oke|yuk)$/i.test(text.trim())) { 95 | checkoutStep = checkoutData.nama ? 2 : 1; 96 | respondWithTyping({ sender: 'lyra', text: `Sip, nama kamu ${checkoutData.nama}. Sekarang, nomor WA aktif kamu dong?` }); 97 | window.awaitingCheckoutConfirmation = false; 98 | return true; 99 | } else if (/^(tidak|ga|engga|batal)$/i.test(text.trim())) { 100 | respondWithTyping({ sender: 'lyra', text: 'Oke deh, kapan-kapan aja ya belanjanya 🙈' }); 101 | checkoutStep = 0; 102 | checkoutData = {}; 103 | window.awaitingCheckoutConfirmation = false; 104 | return true; 105 | } else { 106 | respondWithTyping({ sender: 'lyra', text: 'Jawabannya "ya" atau "tidak" dulu ya, biar aku tahu harus lanjut atau engga 😊' }); 107 | return true; 108 | } 109 | } 110 | if (checkoutStep === 0) return false; 111 | 112 | if (checkoutStep === 1) { 113 | checkoutData.nama = text; 114 | respondWithTyping({ sender: 'lyra', text: `Sip, nama kamu ${text}. Sekarang, nomor WA aktif kamu dong?` }); 115 | checkoutStep = 2; 116 | return true; 117 | } 118 | 119 | if (checkoutStep === 2) { 120 | checkoutData.no_wa = text; 121 | respondWithTyping({ sender: 'lyra', text: `Oke, sekarang alamat lengkap kamu ya?` }); 122 | checkoutStep = 3; 123 | return true; 124 | } 125 | 126 | if (checkoutStep === 3) { 127 | checkoutData.alamat = text; 128 | respondWithTyping({ sender: 'lyra', text: `Kurir yang kamu mau? (JNE, J&T, Sicepat, dll)` }); 129 | checkoutStep = 4; 130 | return true; 131 | } 132 | 133 | if (checkoutStep === 4) { 134 | checkoutData.kurir = text; 135 | respondWithTyping({ sender: 'lyra', text: `Catatan tambahan sebelum checkout? Kalau nggak ada, ketik "-".` }); 136 | checkoutStep = 5; 137 | return true; 138 | } 139 | 140 | if (checkoutStep === 5) { 141 | checkoutData.catatan = text; 142 | respondWithTyping({ 143 | text: `Berikut datanya:\n\n📛 Nama: ${checkoutData.nama}\n📱 WA: ${checkoutData.no_wa}\n🏠 Alamat: ${checkoutData.alamat}\n🚚 Kurir: ${checkoutData.kurir}\n📝 Catatan: ${checkoutData.catatan}\n\nKetik _lanjut_ untuk bayar atau _ulang_ untuk edit.` 144 | }); 145 | if (!checkoutData.nama || !checkoutData.no_wa || !checkoutData.alamat || !checkoutData.kurir) { 146 | respondWithTyping({ text: 'Data checkout belum lengkap, tolong isi dengan benar ya!' }); 147 | return; 148 | } 149 | checkoutStep = 6; 150 | return true; 151 | } 152 | 153 | if (checkoutStep === 6) { 154 | if (/ulang/i.test(text)) { 155 | respondWithTyping({ text: 'Oke, ulang dari awal ya. Nama kamu siapa?' }); 156 | checkoutStep = 1; 157 | checkoutData = {}; 158 | return true; 159 | } 160 | 161 | // PATCH di bagian "lanjut" checkout step 162 | if (/lanjut/i.test(text)) { 163 | respondWithTyping({ text: 'Siap! mohon tunggu, Aku proses ke Xendit dulu ya...' }); 164 | 165 | try { 166 | const uid = localStorage.getItem('uid') || 'guest'; 167 | const orderId = `order_${Date.now()}`; // 👉 kita generate orderId dulu 168 | 169 | // Kirim ke Worker/Xendit dengan orderId ini 170 | const res = await fetch('https://ketahuilah.harisudahmalam.workers.dev/', { 171 | method: 'POST', 172 | headers: { 'Content-Type': 'application/json' }, 173 | body: JSON.stringify({ user: checkoutData, cart: cartItems, order_id: orderId }) 174 | }); 175 | 176 | const resultText = await res.text(); 177 | console.log('Respon worker kedua:', resultText); 178 | 179 | let json; 180 | try { 181 | json = JSON.parse(resultText); 182 | } catch (e) { 183 | console.error('Gagal parsing JSON dari worker:', e); 184 | respondWithTyping({ text: 'Gagal memproses order: format tidak valid.' }); 185 | return true; 186 | } 187 | 188 | if (!json.invoice_url) { 189 | respondWithTyping({ text: 'Xendit tidak memberikan tautan pembayaran 😥. Coba lagi ya.' }); 190 | return true; 191 | } 192 | 193 | console.log("Menyimpan order:", orderId, "uid:", uid); 194 | console.log('Order berhasil disimpan ke Firestore.'); 195 | // ✅ Simpan ke Firestore → konsisten dengan order_id yang kita kirim ke Xendit 196 | const orderRef = doc(db, "users", uid, "orders", orderId); 197 | await setDoc(orderRef, { 198 | user: checkoutData, 199 | cart: cartItems, 200 | order_id: orderId, 201 | status: 'waiting', 202 | createdAt: new Date() 203 | }); 204 | 205 | // ✅ Simpan juga ke global backup 206 | const globalRef = doc(db, "checkout", orderId); 207 | await setDoc(globalRef, { 208 | userId: uid, 209 | user: checkoutData, 210 | cart: cartItems, 211 | order_id: orderId, 212 | status: 'waiting', 213 | createdAt: new Date() 214 | }); 215 | 216 | console.log("Menyimpan order:", orderId, "uid:", uid); 217 | console.log("Order berhasil disimpan ke Firestore."); 218 | 219 | // ✅ Tampilkan bubble + tombol bayar 220 | setTimeout(() => { 221 | if (navigator.vibrate) navigator.vibrate([100, 50, 100]); 222 | const sound = document.getElementById('notifSound'); 223 | sound?.play().catch(() => {}); 224 | 225 | const bubble = document.createElement('div'); 226 | bubble.className = `relative max-w-sm px-4 py-3 rounded-2xl bg-[#2c2e3e] text-white animate-bounce-in-slow shadow-lg`; 227 | const totalHarga = Object.values(cartItems).reduce((sum, item) => sum + (item.price * item.qty), 0); 228 | const itemList = Object.values(cartItems).map((item, i) => { 229 | return `${i + 1}. ${item.name} x${item.qty} - Rp ${item.price * item.qty}`; 230 | }).join('
'); 231 | 232 | bubble.innerHTML = ` 233 |
234 |
235 | 🧾 Rincian Pesanan:
236 | ${itemList} 237 |
238 |
239 | Total: Rp ${totalHarga.toLocaleString()}
240 | *Belum termasuk ongkir 241 |
242 | 244 | 💳 Lanjut Bayar via Xendit 245 | 246 |
247 | `; 248 | 249 | const wrapper = document.createElement('div'); 250 | wrapper.className = 'flex flex-col self-start mb-2'; 251 | wrapper.appendChild(bubble); 252 | document.getElementById('chatBox').appendChild(wrapper); 253 | 254 | respondWithTyping({ 255 | sender: 'lyra', 256 | text: 'Checkout berhasil! 🎉 Silakan klik tombol bayar di atas, dan order kamu langsung aku proses 🚀' 257 | }); 258 | }, 800); 259 | 260 | checkoutStep = 0; 261 | checkoutData = {}; 262 | return true; 263 | 264 | } catch (err) { 265 | console.error('Checkout Error:', err); 266 | respondWithTyping({ text: 'Waduh! Ada masalah saat proses checkout. Coba lagi sebentar ya 🙏' }); 267 | return true; 268 | } 269 | } 270 | 271 | respondWithTyping({ text: 'Tolong ketik _lanjut_ atau _ulang_ ya!' }); 272 | return true; 273 | } 274 | 275 | return false; 276 | } 277 | -------------------------------------------------------------------------------- /src/pages/ChatTelegram.js: -------------------------------------------------------------------------------- 1 | import { getFirestore, collection, getDoc, getDocs, doc, updateDoc, increment, setDoc } from 'firebase/firestore'; 2 | import { getAuth } from "firebase/auth"; 3 | import { detectIntentAndRespond, detectCategoryIntent, generateCategoryResponse, generatePersonaResponse, generateTone, getCustomIntents } from '../modules/intentHandler.js'; 4 | import { 5 | appendMessage, 6 | showTypingBubble, 7 | removeTypingBubble, 8 | showTypingHeader, 9 | hideTypingHeader, 10 | showVoiceNoteHeader, 11 | hideVoiceNoteHeader } from '../modules/chatRenderer.js'; 12 | import { safeRenderHTML, attachProductModalTriggers } from '../modules//htmlRenderer.js'; 13 | import { initAuth, getCurrentUID, onLoginStateChanged, login } from '../modules/authHandler.js'; 14 | import { showLimitModal, hideLimitModal } from '../modules/limitModal.js'; 15 | import { cartManager} from '../modules/CartManager.js'; 16 | import { startCheckout, handleCheckoutInput } from '../modules/checkoutHandler.js'; 17 | import { logout } from '../modules/authHandler.js'; 18 | 19 | const MODES = ['jualan', 'friendly', 'formal', 'genz']; 20 | let modeLYRA = localStorage.getItem('modeLYRA') || 'jualan'; 21 | let modeIndex = MODES.indexOf(modeLYRA); 22 | 23 | let PRODUCT_LIST = []; 24 | let chatCount = 0; 25 | const LIMIT = 10; 26 | const groqKey = import.meta.env.VITE_GROQ_API_KEY; 27 | const db = getFirestore(); 28 | const auth = getAuth(); 29 | 30 | 31 | function getGreetingByTime(mode = 'default') { 32 | const now = new Date(); 33 | const hour = now.getHours(); 34 | if (hour >= 4 && hour < 11) return '🌞 Selamat pagi'; 35 | if (hour >= 11 && hour < 15) return '☀️ Selamat siang'; 36 | if (hour >= 15 && hour < 18) return '🌇 Selamat sore'; 37 | if (hour >= 18 && hour < 4) return '🌙 Selamat malam'; 38 | const time = hour < 11 ? 'pagi' : hour < 15 ? 'siang' : hour < 18 ? 'sore' : 'malam'; 39 | 40 | const greetings = { 41 | jualan: [ 42 | `Selamat ${time}! Siap belanja hemat bareng LYRA? 🛒`, 43 | `Halo! Mau cari promo apa hari ini? ✨`, 44 | `Waktunya belanja cerdas bareng aku~` 45 | ], 46 | formal: [ 47 | `Selamat ${time}. Ada yang bisa saya bantu?`, 48 | `Hai, terima kasih telah berkunjung. Saya siap membantu.`, 49 | `Salam hormat. LYRA di sini untuk membantu Anda.` 50 | ], 51 | genz: [ 52 | `Yoo selamat ${time} gengs 😎`, 53 | `Halo bestie~ Mau beli apa hari ini? 💅`, 54 | `Ayo gaskeun belanja! Jangan banyak mikir 💸` 55 | ], 56 | default: [ 57 | `Selamat ${time}! 👋`, 58 | `Hai, ada yang bisa aku bantu hari ini? 😊`, 59 | `Halo! Mau cari apa nih?` 60 | ] 61 | }; 62 | 63 | const selected = greetings[mode] || greetings.default; 64 | return selected[Math.floor(Math.random() * selected.length)]; 65 | } 66 | 67 | function updateModeLabel() { 68 | const label = document.getElementById('modeLabel'); 69 | if (label) { 70 | label.textContent = modeLYRA.charAt(0).toUpperCase() + modeLYRA.slice(1); 71 | } 72 | } 73 | 74 | function globalAlert(msg) { 75 | const alert = document.createElement('div'); 76 | alert.className = ` 77 | fixed bottom-4 left-4 bg-green-600 text-white 78 | px-4 py-2 rounded shadow-lg z-50 animate-fade-in 79 | `; 80 | alert.textContent = msg; 81 | document.body.appendChild(alert); 82 | setTimeout(() => { 83 | alert.classList.add('opacity-0'); 84 | setTimeout(() => alert.remove(), 500); 85 | }, 2500); 86 | } 87 | 88 | function showGlobalAlert(message, type = 'info') { 89 | const alertBox = document.getElementById('globalAlert'); 90 | if (!alertBox) return; 91 | alertBox.textContent = message; 92 | alertBox.className = 'fixed top-4 right-4 z-[1000] px-4 py-3 rounded-lg text-sm font-medium transition-all duration-300 shadow-lg'; 93 | switch (type) { 94 | case 'success': 95 | alertBox.classList.add('bg-green-500', 'text-white'); 96 | break; 97 | case 'error': 98 | alertBox.classList.add('bg-red-500', 'text-white'); 99 | break; 100 | case 'warning': 101 | alertBox.classList.add('bg-yellow-400', 'text-black'); 102 | break; 103 | default: 104 | alertBox.classList.add('bg-gray-800', 'text-white'); 105 | } 106 | alertBox.classList.remove('hidden'); 107 | setTimeout(() => { 108 | alertBox.classList.add('opacity-0'); 109 | setTimeout(() => { 110 | alertBox.classList.add('hidden'); 111 | alertBox.classList.remove('opacity-0'); 112 | }, 300); 113 | }, 3000); 114 | } 115 | 116 | function autoResize() { 117 | this.style.height = 'auto'; 118 | this.style.height = this.scrollHeight + 'px'; 119 | } 120 | 121 | function getProductByName(name) { 122 | return PRODUCT_LIST.find(p => p.name.toLowerCase().includes(name.toLowerCase())); 123 | } 124 | 125 | function getRandomResponse(productName) { 126 | const product = getProductByName(productName); 127 | const priceFormatted = product?.price 128 | ? `Rp ${product.price.toLocaleString('id-ID')}` 129 | : 'harga tidak tersedia'; 130 | if (!product) { 131 | return `Hmm... aku belum nemu produk bernama "${productName}". Mau coba cari yang lain?`; 132 | } 133 | const responses = []; 134 | if (product.tags?.includes('gratis-ongkir')) { 135 | responses.push(`Khusus hari ini, ${product.name} bebas ongkir loh! 😍`); 136 | } 137 | const templates = [ 138 | `kamu pengen ${product.name} ini, ini salah satu andalan, harganya ${priceFormatted} aja.`, 139 | `Kamu pasti suka ${product.name}, dan kabar baiknya: cuma ${priceFormatted}!`, 140 | `Harga ${product.name}? ${priceFormatted}. Worth it banget untuk rasanya!`, 141 | `Mau yang bikin anget? ${product.name} jawabannya. Harga: ${priceFormatted}.`, 142 | ]; 143 | return (responses.length > 0 ? responses[0] : templates[Math.floor(Math.random() * templates.length)]); 144 | } 145 | 146 | const userPrompts = [ 147 | "Ceritain dong soal {{name}}.", 148 | "Eh, ini {{name}} kayaknya menarik, ya?", 149 | "{{name}} ini produk apa sih?", 150 | "Gua penasaran deh sama {{name}}.", 151 | "Ini {{name}} kegunaannya apa, bre?", 152 | ]; 153 | function getRandomUserPrompt(name) { 154 | const prompt = userPrompts[Math.floor(Math.random() * userPrompts.length)]; 155 | return prompt.replace('{{name}}', name); 156 | } 157 | 158 | function generateProductTeaser(product) { 159 | const desc = product.description || 'produk menarik dari kami'; 160 | const teasers = [ 161 | `✨ ${product.name} hadir dengan ${desc.slice(0, 60)}...`, 162 | `🎯 Ini dia highlight dari ${product.name}: ${desc.slice(0, 70)}...`, 163 | `🔍 Sekilas tentang ${product.name}: ${desc.slice(0, 65)}...`, 164 | `💡 ${product.name} punya fitur utama: ${desc.slice(0, 60)}...`, 165 | `🔥 Kepoin ${product.name}, katanya sih: ${desc.slice(0, 70)}...`, 166 | ]; 167 | return teasers[Math.floor(Math.random() * teasers.length)]; 168 | } 169 | 170 | attachProductModalTriggers(PRODUCT_LIST, openProductModal); 171 | 172 | function openProductModal(product) { 173 | document.getElementById('modal-image').src = product.img || '/default.jpg'; 174 | document.getElementById('modal-title').textContent = product.name; 175 | document.getElementById('modal-description').textContent = product.description || 'Deskripsi tidak tersedia'; 176 | document.getElementById('modal-rating').textContent = product.rating; 177 | document.getElementById('modal-sold').textContent = product.sold; 178 | document.getElementById('modal-price').textContent = `Rp ${product.price.toLocaleString()}`; 179 | document.getElementById('buy-button').dataset.slug = product.slug; 180 | const modal = document.getElementById('product-modal'); 181 | const modalContent = document.getElementById('modal-content'); 182 | modal.classList.remove('hide'); 183 | setTimeout(() => { 184 | modalContent.classList.remove('opacity-0', 'scale-95'); 185 | modalContent.classList.add('opacity-100', 'scale-100'); 186 | }, 10); 187 | } 188 | 189 | function updateCartBadge() { 190 | const badge = document.getElementById('cartQtyBadge'); 191 | const { qty } = cartManager.getCartSummary(); 192 | if (qty > 0) { 193 | badge.textContent = qty; 194 | badge.classList.remove('hidden'); 195 | } else { 196 | badge.classList.add('hidden'); 197 | } 198 | } 199 | 200 | function respondWithTyping({ 201 | sender = 'lyra', 202 | text = '', 203 | html = null, 204 | product = null, 205 | replyTo = null, 206 | voiceOnly = false, 207 | voice = '', 208 | speakOnly = false 209 | }) { 210 | if (speakOnly && voice) { 211 | const utter = new SpeechSynthesisUtterance(voice); 212 | utter.lang = 'id-ID'; 213 | speechSynthesis.speak(utter); 214 | return; 215 | } 216 | 217 | showTypingBubble(); 218 | showTypingHeader(voiceOnly ? 'voice' : 'mengetik'); 219 | 220 | setTimeout(() => { 221 | if (voiceOnly && voice) { 222 | showVoiceNoteHeader(); 223 | appendMessage({ 224 | sender, 225 | voiceOnly: true, 226 | voice 227 | }); 228 | hideVoiceNoteHeader(); 229 | const utter = new SpeechSynthesisUtterance(voice); 230 | utter.lang = 'id-ID'; 231 | speechSynthesis.speak(utter); 232 | } else { 233 | appendMessage({ sender, text, product, replyTo, html, voiceOnly, speakOnly, voice }); 234 | } 235 | 236 | removeTypingBubble(); 237 | hideTypingHeader(); 238 | }, 1000 + Math.random() * 400); 239 | } 240 | 241 | async function loadProductList() { 242 | const db = getFirestore(); 243 | const snapshot = await getDocs(collection(db, 'products')); 244 | PRODUCT_LIST = snapshot.docs.map(doc => doc.data()); 245 | } 246 | 247 | function generateProfileCard(user) { 248 | const foto = user.foto || 'https://ui-avatars.com/api/?name=' + encodeURIComponent(user.nama); 249 | const nama = user.nama || 'Pengguna'; 250 | const email = user.email || '-'; 251 | const wa = user.no_wa || '-'; 252 | const bergabung = new Date(user.bergabung).toLocaleDateString('id-ID'); 253 | const totalOrder = user.total_order || 0; 254 | const lastOrder = user.last_order ? new Date(user.last_order).toLocaleDateString('id-ID') : '-'; 255 | 256 | return ` 257 |
258 |
259 | 260 |
261 |

${nama}

262 |

${email}

263 |
264 |
265 |
266 | 📱 WA: ${wa}
267 | 🧾 Total Order: ${totalOrder}
268 | 🕓 Terakhir Order: ${lastOrder}
269 | 📅 Bergabung: ${bergabung} 270 |
271 |
272 | `; 273 | } 274 | 275 | let hasWelcomed = false; 276 | 277 | async function sendWelcomeMessage(user) { 278 | if (hasWelcomed) return; 279 | hasWelcomed = true; 280 | const greeting = getGreetingByTime(modeLYRA); 281 | const name = user?.displayName || 'teman'; 282 | const welcomeTexts = [ 283 | `${greeting}, ${name}! Aku LYRA 😉`, 284 | `${greeting}, ${name}! Lyra siap bantu kamu cari produk kece hari ini. 😎`, 285 | `Halo ${name}, ${greeting}! Aku LYRA — asisten AI kamu di sini.`, 286 | `${greeting} ${name}! Yuk, mulai eksplor produk bareng aku. 🛍️`, 287 | ]; 288 | const randomText = welcomeTexts[Math.floor(Math.random() * welcomeTexts.length)]; 289 | respondWithTyping({ sender: 'lyra', voiceOnly: true, speakOnly: false, voice: `${randomText}` }); 290 | setTimeout(() => { 291 | const toggleStyleBtn = document.getElementById('toggleStyle'); 292 | if (toggleStyleBtn) { 293 | toggleStyleBtn.addEventListener('click', () => { 294 | modeIndex = (modeIndex + 1) % MODES.length; 295 | modeLYRA = MODES[modeIndex]; 296 | localStorage.setItem('modeLYRA', modeLYRA); 297 | updateModeLabel(); 298 | respondWithTyping({ sender: 'lyra', text: `Gaya ngobrol diganti jadi ${modeLYRA} ya! 😉` }); 299 | showGlobalAlert(`Gaya bicara LYRA diubah ke: ${modeLYRA}`, 'success'); 300 | }); 301 | } 302 | respondWithTyping({ 303 | sender: 'lyra', 304 | text: `Coba klik produk di sidebar atau langsung ketik "minta katalog nya" ke LYЯA. Tanya apapun, ${name}. Aku standby! 🚀 Mau lihat profile kamu, ketik aja "akun saya" 😉`, 305 | }); 306 | }, 1200); 307 | } 308 | 309 | function renderProductGridInChat(products) { 310 | const html = ` 311 |
312 | ${products.map(p => ` 313 |
314 | 315 | ${p.name} 316 |
${p.name}
317 |
Rp ${p.price.toLocaleString('id-ID')}
318 | 319 |
320 | `).join('')} 321 |
322 | `; 323 | appendMessage({ sender: 'lyra', html }); 324 | const style = document.createElement('style'); 325 | style.textContent = ` 326 | @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } 327 | .animate-fade-in > div { 328 | animation: fadeIn 0.4s ease-in-out both; 329 | } 330 | .animate-fade-in > div:nth-child(1) { animation-delay: 0s; } 331 | .animate-fade-in > div:nth-child(2) { animation-delay: 0.05s; } 332 | .animate-fade-in > div:nth-child(3) { animation-delay: 0.1s; } 333 | .animate-fade-in > div:nth-child(4) { animation-delay: 0.15s; } 334 | .animate-fade-in > div:nth-child(5) { animation-delay: 0.2s; } 335 | .animate-fade-in > div:nth-child(6) { animation-delay: 0.25s; } 336 | `; 337 | document.head.appendChild(style); 338 | setTimeout(() => { 339 | document.querySelectorAll('.add-to-cart-btn').forEach(btn => { 340 | if (btn.dataset.bound) return; 341 | btn.addEventListener('click', () => { 342 | const slug = btn.dataset.slug; 343 | const product = PRODUCT_LIST.find(p => p.slug === slug); 344 | if (product) { 345 | cartManager.addItem(product); 346 | updateCartBadge(); 347 | globalAlert(`1 ${product.name} masuk keranjang!`); 348 | } else { 349 | globalAlert('Produk tidak ditemukan 😓'); 350 | } 351 | }); 352 | btn.dataset.bound = 'true'; 353 | }); 354 | }, 50); 355 | } 356 | 357 | async function loadUserProfile() { 358 | const uid = getCurrentUID(); // pastikan fungsi ini tersedia dan benar 359 | if (!uid) { 360 | respondWithTyping({ voiceOnly: true, voice: 'Kamu belum login 😅 yuk login dulu supaya bisa berbelanja.' }); 361 | return; 362 | } 363 | 364 | try { 365 | const db = getFirestore(); 366 | const docRef = doc(db, 'users', uid); 367 | const docSnap = await getDoc(docRef); 368 | 369 | if (!docSnap.exists()) { 370 | respondWithTyping({ voiceOnly: true, voice: 'Profil belum tersedia 😔' }); 371 | return; 372 | } 373 | 374 | const data = docSnap.data(); // ✅ gunakan 'data' bukan 'user' 375 | 376 | const html = ` 377 |
378 |
379 | Avatar 380 |
381 |
${data.nama || 'Tanpa Nama'}
382 |
${data.email || '-'}
383 |
384 |
385 | 386 |
387 |
388 | 🛒 Total Order 389 | ${data.totalOrder || 0} 390 |
391 |
392 | 🏅 Level 393 | ${data.level || 'Pengguna'} 394 |
395 |
396 | 397 |
398 | PREMIUM 399 |
400 |
401 | `; 402 | 403 | respondWithTyping({ html }); 404 | 405 | } catch (err) { 406 | console.error('loadUserProfile error:', err); 407 | respondWithTyping({ text: 'Ups! Gagal menampilkan profil 😓' }); 408 | } 409 | } 410 | 411 | document.getElementById('openProfileBtn')?.addEventListener('click', () => { 412 | loadUserProfile(); 413 | }); 414 | 415 | export default function ChatTelegram() { 416 | setTimeout(async () => { 417 | const sendBtn = document.getElementById('sendBtn'); 418 | const input = document.getElementById('chatInput'); 419 | const loginBtn = document.getElementById('loginBtn'); 420 | const logoutBtn = document.getElementById('logoutUserBtn'); 421 | const modalLoginBtn = document.getElementById('modalLoginBtn'); 422 | const sidebarBtn = document.getElementById('sidebarBtn'); 423 | const sidebar = document.getElementById('sidebar'); 424 | const sidebarOverlay = document.getElementById('sidebarOverlay'); 425 | const sidebarProduct = document.getElementById('sidebarProduct'); 426 | 427 | initAuth(); 428 | await loadProductList(); 429 | 430 | sendBtn?.addEventListener('click', () => { 431 | const text = input.value.trim(); 432 | if (text) handleUserInput(text); 433 | }); 434 | 435 | input?.addEventListener('keydown', (e) => { 436 | if (e.key === 'Enter' && !e.shiftKey) { 437 | e.preventDefault(); 438 | const text = input.value.trim(); 439 | if (text) handleUserInput(text); 440 | } 441 | }); 442 | 443 | onLoginStateChanged(async (user) => { 444 | const db = getFirestore(); 445 | 446 | if (user && loginBtn) { 447 | const name = user.displayName?.split(' ')[0] ?? user.email?.split('@')[0] ?? 'User'; 448 | loginBtn.textContent = `Halo, ${name}`; 449 | loginBtn.disabled = true; 450 | logoutBtn?.classList.remove('hidden'); 451 | hideLimitModal(); 452 | 453 | // ⛳ Cek & Simpan user ke Firestore kalau belum ada 454 | const userRef = doc(db, 'users', user.uid); 455 | const snap = await getDoc(userRef); 456 | if (!snap.exists()) { 457 | await setDoc(userRef, { 458 | uid: user.uid, 459 | nama: user.displayName ?? name, 460 | email: user.email ?? '-', 461 | totalOrder: 0, 462 | level: 'Member', 463 | createdAt: new Date() 464 | }); 465 | console.log('✅ User baru dibuat di Firestore'); 466 | } 467 | 468 | sendWelcomeMessage(user); 469 | } else { 470 | sendWelcomeMessage(null); 471 | logoutBtn?.classList.add('hidden'); 472 | } 473 | }); 474 | 475 | async function handleUserInput(text) { 476 | appendMessage({ sender: 'user', text }); 477 | input.value = ''; 478 | 479 | const uid = getCurrentUID(); 480 | const isGuest = !uid; 481 | 482 | const customIntents = await getCustomIntents(); 483 | const match = customIntents.find(intent => 484 | intent.keywords.some(k => text.toLowerCase().includes(k.toLowerCase())) 485 | ); 486 | 487 | if (match) { 488 | respondWithTyping({ sender: 'lyra', text: match.response }); 489 | return; 490 | } 491 | 492 | const handled = await handleCheckoutInput(text, cartManager.items); 493 | if (handled) return; 494 | 495 | if (isGuest && chatCount >= LIMIT) return showLimitModal(); 496 | if (isGuest) chatCount++; 497 | 498 | const lower = text.toLowerCase(); 499 | const isTechQuery = /bisa\s(apa|ngapain)|kendali|iot|smarthome|otomatis|sistem|terhubung/.test(lower); 500 | 501 | if (isTechQuery) { 502 | setTimeout(() => { 503 | respondWithTyping({ 504 | sender: 'lyra', 505 | voice: 'Saya bisa segalanya... Jika kamu punya IoT, kamu tinggal sambungkan saja saya ke sistem IoT kamu, perintahkan saya untuk nyalain mesin mobil, matikan lampu, atau sebaliknya, saya selalu siap, tapi saat ini saya sedang dalam proses pengembangan oleh kedua atasan saya. 🔌🤖', 506 | voiceOnly: true 507 | }); 508 | }, 1200); 509 | return; 510 | } 511 | 512 | // 📦 Keranjang 513 | if (/keranjang|lihat keranjang|cart/i.test(text)) { 514 | cartBtn?.click(); 515 | return; 516 | } 517 | 518 | // 🗑️ Hapus dari keranjang 519 | if (/hapus/i.test(text)) { 520 | const keyword = text.replace(/hapus/i, '').trim().toLowerCase(); 521 | const indexMatch = text.match(/ke-?(\d+)/i); 522 | if (indexMatch) { 523 | const index = parseInt(indexMatch[1], 10) - 1; 524 | const allItems = cartManager.items; 525 | if (allItems[index]) { 526 | const removed = allItems[index]; 527 | // Buat array baru tanpa item di index tersebut 528 | cartManager.items = allItems.filter((_, i) => i !== index); 529 | cartManager.notifyListeners(); 530 | respondWithTyping({ sender: 'lyra', text: `Oke, aku hapus ${removed.name} dari keranjang.` }); 531 | } else { 532 | respondWithTyping({ sender: 'lyra', text: `Item nomor ${index + 1} nggak ditemukan di keranjang.` }); 533 | } 534 | } else { 535 | const match = PRODUCT_LIST.find(p => p.name.toLowerCase().includes(keyword)); 536 | if (match) { 537 | cartManager.removeBySlug(match.slug); 538 | respondWithTyping({ sender: 'lyra', text: `Item "${match.name}" sudah dihapus dari keranjang.` }); 539 | } else { 540 | respondWithTyping({ sender: 'lyra', text: `Item "${keyword}" nggak aku temuin di keranjang.` }); 541 | } 542 | } 543 | return; 544 | } 545 | if (/profil|siapa aku|akun saya|user profile/i.test(text)) { 546 | respondWithTyping({ sender: 'lyra', text: 'ini, aku tampilkan profil kamu 😊' }); 547 | loadUserProfile(); 548 | return; 549 | } 550 | // 💰 Checkout 551 | const { isEmpty } = cartManager.getCartSummary(); 552 | if (/checkout|bayar/i.test(text)) { 553 | if (isEmpty) { 554 | respondWithTyping({ 555 | sender: 'lyra', 556 | voiceOnly: true, 557 | voice: `Keranjang nya kosong, nggak bisa checkout dulu. 😅 Silahkan ketik katalog untuk menampilkan semua produk di toko kami.`, 558 | }); 559 | showGlobalAlert('Keranjang kosong, nggak bisa checkout dulu 😅', 'error'); 560 | return; 561 | } 562 | return startCheckout(cartManager.items); 563 | } 564 | 565 | // 📦 Semua produk 566 | if (/produk apa|punya apa|katalog|jual apa|semua produk|lihat semua|katalog lengkap|catalog/i.test(text)) { 567 | setTimeout(() => { 568 | renderProductGridInChat(PRODUCT_LIST); 569 | }, 400 + Math.random() * 200); 570 | 571 | showTypingBubble(); 572 | showVoiceNoteHeader(); 573 | setTimeout(() => { 574 | appendMessage({ sender: 'lyra', voiceOnly: true, voice: 'Ini kak, semua produk yang ada di toko aku, klik tombol tambah keranjang ya untuk berbelanja. dan ketik checkout jika sudah siap untuk membayar. Jangan lupa login dulu yaaa...', replyTo: text }); 575 | removeTypingBubble(); 576 | hideVoiceNoteHeader(); 577 | }, 1200 + Math.random() * 600); 578 | return; 579 | } 580 | 581 | // 🤖 AI response (intent + gaya bicara) 582 | const result = await detectIntentAndRespond(text); 583 | 584 | const personaReply = generatePersonaResponse(text); 585 | if (personaReply) { 586 | respondWithTyping({ text: personaReply }); 587 | return; 588 | } 589 | 590 | if (result.intent === 'all') { 591 | respondWithTyping({ text: result.label }); 592 | PRODUCT_LIST.forEach(p => respondWithTyping({ product: p })); 593 | } else if (result.intent === 'best') { 594 | const top = [...PRODUCT_LIST].sort((a, b) => b.sold - a.sold)[0]; 595 | respondWithTyping({ text: result.label, product: top }); 596 | } else if (result.intent === 'rating') { 597 | const best = [...PRODUCT_LIST].sort((a, b) => b.rating - a.rating)[0]; 598 | respondWithTyping({ text: result.label, product: best }); 599 | } else if (result.intent === 'match') { 600 | respondWithTyping({ text: result.label, product: result.product }); 601 | } else { 602 | const matchedProduct = PRODUCT_LIST.find(p => text.toLowerCase().includes(p.name.toLowerCase())); 603 | if (matchedProduct) { 604 | const catIntent = detectCategoryIntent(text); 605 | const rawResponse = generateCategoryResponse(catIntent, matchedProduct); 606 | const styled = generateTone(rawResponse, modeLYRA); 607 | respondWithTyping({ text: styled, product: matchedProduct }); 608 | } else { 609 | handleRequest(text, safeRenderHTML); // fallback ke GPT 610 | } 611 | } 612 | } 613 | 614 | logoutBtn?.addEventListener('click', async () => { 615 | await logout(); 616 | window.location.href = '/'; 617 | }); 618 | 619 | const cartBtn = document.getElementById('cartBtn'); 620 | const typingStatus = document.getElementById('typingStatus'); 621 | 622 | 623 | function setLyraStatus(text = '') { 624 | const statusEl = document.getElementById('lyraClock'); 625 | if (statusEl) { 626 | const now = new Date(); 627 | const h = now.getHours().toString().padStart(2, '0'); 628 | const m = now.getMinutes().toString().padStart(2, '0'); 629 | statusEl.innerHTML = ` ${h}:${m} ${text}`; 630 | statusEl.classList.remove('hidden'); 631 | } 632 | } 633 | setLyraStatus('sedang aktif...'); 634 | 635 | cartBtn?.addEventListener('click', () => { 636 | const { isEmpty, cartList, total } = cartManager.getCartSummary(); 637 | 638 | if (isEmpty) { 639 | globalAlert('Keranjang masih kosong'); 640 | 641 | setTimeout(() => { 642 | showTypingBubble(); 643 | showTypingHeader(); 644 | showVoiceNoteHeader(); 645 | 646 | setTimeout(() => { 647 | appendMessage({ 648 | sender: 'lyra', 649 | voiceOnly: true, 650 | voice: 'Keranjangmu masih kosong nih. Yuk pilih produk dulu!' 651 | }); 652 | 653 | removeTypingBubble(); 654 | hideTypingHeader(); 655 | hideVoiceNoteHeader(); 656 | }, 800 + Math.random() * 400); 657 | }, 10); 658 | 659 | return; 660 | } 661 | 662 | setTimeout(() => { 663 | showTypingBubble(); 664 | showTypingHeader(); 665 | showVoiceNoteHeader(); 666 | 667 | setTimeout(() => { 668 | appendMessage({ 669 | sender: 'lyra', 670 | text: `Isi keranjang kamu:\n${cartList}\n\nTotal: Rp ${total.toLocaleString('id-ID')}` 671 | }); 672 | 673 | removeTypingBubble(); 674 | hideTypingHeader(); 675 | hideVoiceNoteHeader(); 676 | }, 800 + Math.random() * 400); 677 | }, 10); 678 | }); 679 | 680 | const cheatsheetModal = document.getElementById('cheatsheet-modal'); 681 | const cheatsheetContent = document.getElementById('cheatsheet-content'); 682 | 683 | document.getElementById('openCheatsheet')?.addEventListener('click', () => { 684 | cheatsheetModal.classList.remove('hidden'); 685 | cheatsheetModal.classList.add('flex'); 686 | setTimeout(() => { 687 | cheatsheetContent.classList.remove('opacity-0', 'scale-95'); 688 | cheatsheetContent.classList.add('opacity-100', 'scale-100'); 689 | }, 10); 690 | }); 691 | document.getElementById('closeCheatsheet')?.addEventListener('click', () => { 692 | cheatsheetContent.classList.remove('opacity-100', 'scale-100'); 693 | cheatsheetContent.classList.add('opacity-0', 'scale-95'); 694 | setTimeout(() => { 695 | cheatsheetModal.classList.add('hidden'); 696 | cheatsheetModal.classList.remove('flex'); 697 | }, 200); 698 | }); 699 | 700 | document.getElementById('openFaq')?.addEventListener('click', () => { 701 | const modal = document.getElementById('faqModal'); 702 | modal.classList.remove('hide'); 703 | setTimeout(() => { 704 | modal.querySelector('div').classList.remove('opacity-0', 'scale-95'); 705 | }, 50); 706 | }); 707 | 708 | document.getElementById('closeFaq')?.addEventListener('click', () => { 709 | const modal = document.getElementById('faqModal'); 710 | modal.querySelector('div').classList.add('opacity-0', 'scale-95'); 711 | setTimeout(() => { 712 | modal.classList.add('hide'); 713 | }, 300); 714 | }); 715 | 716 | document.getElementById('modal-close')?.addEventListener('click', () => { 717 | const modal = document.getElementById('product-modal'); 718 | modal.querySelector('div').classList.add('opacity-0', 'scale-95'); 719 | setTimeout(() => { 720 | modal.classList.add('hide'); 721 | }, 300); 722 | }); 723 | loginBtn?.addEventListener('click', login); 724 | modalLoginBtn?.addEventListener('click', login); 725 | 726 | sidebarBtn?.addEventListener('click', () => { 727 | sidebar.classList.remove('-translate-x-full'); 728 | sidebarOverlay.classList.remove('hidden'); 729 | }); 730 | sidebarOverlay?.addEventListener('click', () => { 731 | sidebar.classList.add('-translate-x-full'); 732 | sidebarOverlay.classList.add('hidden'); 733 | }); 734 | 735 | if (sidebarProduct) { 736 | const items = PRODUCT_LIST.map(p => ` 737 |
738 | ${p.name} 739 |
740 |
741 | ${p.name} 742 | ⭐ ${p.rating ?? '0'} 743 |
744 |
Rp.${p.price}
745 |
746 |
747 | `); 748 | sidebarProduct.innerHTML = items.join(''); 749 | 750 | 751 | sidebarProduct.addEventListener('click', (e) => { 752 | const target = e.target.closest('.product-item'); 753 | if (!target) return; 754 | const slug = target.dataset.slug; 755 | const product = PRODUCT_LIST.find(p => p.slug === slug); 756 | if (!product) return console.warn('Produk tidak ditemukan:', slug); 757 | 758 | // Random user message 759 | const msg = getRandomUserPrompt(product.name); 760 | appendMessage({ sender: 'user', text: msg }); 761 | 762 | // Tampilkan efek ngetik 763 | showTypingBubble(); 764 | showTypingHeader(); 765 | 766 | setTimeout(() => { 767 | const lyraReply = getRandomResponse(product.name); 768 | 769 | // LYRA bales sekaligus: text + reference ke product 770 | appendMessage({ 771 | sender: 'lyra', 772 | text: lyraReply, 773 | product, 774 | replyTo: msg 775 | }); 776 | 777 | removeTypingBubble(); 778 | hideTypingHeader(); 779 | 780 | // Optional: lanjut teaser deskripsi 781 | setTimeout(() => { 782 | const teaser = generateProductTeaser(product); 783 | const linkHTML = `Lihat detail produk`; 784 | const fullTeaser = `${teaser} ${linkHTML}`; 785 | appendMessage({ sender: 'lyra', html: fullTeaser }); 786 | 787 | setTimeout(() => { 788 | document.querySelectorAll('.open-product-link').forEach(link => { 789 | link.onclick = (e) => { 790 | e.preventDefault(); 791 | const slug = link.dataset.slug; 792 | const product = PRODUCT_LIST.find(p => p.slug === slug); 793 | if (product) openProductModal(product); 794 | }; 795 | }); 796 | }, 50); 797 | }, 800 + Math.random() * 400); 798 | }, 1000 + Math.random() * 500); 799 | 800 | // Tutup sidebar di mobile 801 | sidebar.classList.add('-translate-x-full'); 802 | sidebarOverlay.classList.add('hidden'); 803 | }); 804 | } 805 | }, 50); 806 | 807 | return ` 808 |
809 | 810 | 811 | 812 | 865 | 866 |
867 |
868 |
869 | 870 | 873 | 874 |
875 |
L Y Я A
876 |
--:--
877 | 878 |
879 |
880 | 884 |
885 | 886 |
887 |
888 | L Y Я A is still learning. Verify any important information you receive. 889 |
890 | 891 | 915 | 916 |
917 | 918 | 923 |
924 |
925 |
926 |
927 |
928 |

❓ Pertanyaan Umum (FAQ)

929 | 934 |
935 |
936 |
937 | Apa itu L Y Я A? 938 |

L Y Я A adalah asisten AI interaktif yang bisa bantu kamu cari produk, melakukan checkout, dan tanya-tanya seputar toko online ini.

939 |
940 |
941 | Bagaimana cara belanja? 942 |

Kamu bisa klik produk di sidebar atau ketik nama produk di chat. Lalu tambahkan ke keranjang dan ketik checkout.

943 |
944 |
945 | Apa bisa bayar langsung? 946 |

Ya! Setelah checkout, kamu bisa bayar lewat Midtrans atau Xendit melalui tautan pembayaran yang muncul di chat.

947 |
948 |
949 | Batasan user gratis? 950 |

User anonim bisa chat maksimal 10x. Login untuk akses lebih banyak fitur!

951 |
952 |
953 | Data saya aman? 954 |

Tentu! Data kamu tidak akan disalahgunakan. Kami hanya menyimpan informasi yang diperlukan untuk proses transaksi.

955 |
956 |
957 |
958 |
959 |
960 | 980 |
981 | 982 | 1049 | 1050 |
1051 |
1052 |

Maaf, kamu sudah mencapai batas chat gratis.

1053 |

Yuk login untuk akses lebih lanjut!

1054 | 1055 |
1056 |
1057 | 1058 |
1059 | `; 1060 | } 1061 | 1062 | async function handleRequest(prompt) { 1063 | showTypingBubble(); 1064 | showTypingHeader(); 1065 | try { 1066 | const res = await fetch(`${groqKey}`, { 1067 | method: 'POST', 1068 | headers: { 'Content-Type': 'application/json' }, 1069 | body: JSON.stringify({ prompt, uid: getCurrentUID() }), 1070 | }); 1071 | const data = await res.json(); 1072 | const chatBox = document.getElementById('chatBox'); 1073 | chatBox.lastChild?.remove(); 1074 | if (res.status === 429 || data.limitReached) { 1075 | showLimitModal(); 1076 | return; 1077 | } 1078 | removeTypingBubble(); 1079 | hideTypingHeader(); 1080 | appendMessage({ sender: 'lyra', text: data.reply, replyTo: prompt }); 1081 | } catch (err) { 1082 | console.error('❌ Gagal minta balasan:', err); 1083 | const chatBox = document.getElementById('chatBox'); 1084 | chatBox.lastChild?.remove(); 1085 | appendMessage({ sender: 'lyra', text: '😵 LYRA lagi error. Coba lagi nanti ya.' }); 1086 | } 1087 | } --------------------------------------------------------------------------------