/** * Fridger Frontend Application * Route / : Current Stock Overview * Route /manage : Tablet Scanner & Manager Station */ const state = { currentRoute: '/', // '/' or '/manage' items: [], meta: { categories: [], locations: [], units: [] }, filters: { search: '', category: 'All', status: 'all' }, manage: { selectedItem: null, action: null, // 'add' or 'remove' qtyMode: 'packet', // 'packet' or 'amount' packetCount: 1, amountValue: 0 }, html5QrCode: null, isScanning: false }; // Initialize Application & Router document.addEventListener('DOMContentLoaded', async () => { await loadMetadata(); await loadStats(); await loadItems(); // Handle initial browser route handleInitialRoute(); window.addEventListener('popstate', () => { handleInitialRoute(); }); initLucide(); }); function initLucide() { if (window.lucide) { window.lucide.createIcons(); } } // ---------------------------------------------------- // Client-Side Router // ---------------------------------------------------- function handleInitialRoute() { const path = window.location.pathname; if (path === '/manage' || path.startsWith('/manage')) { setRoute('/manage'); } else { setRoute('/'); } } function navigateTo(path) { window.history.pushState({}, '', path); setRoute(path); } function setRoute(path) { state.currentRoute = path === '/manage' ? '/manage' : '/'; const stockBtn = document.getElementById('nav-stock-btn'); const manageBtn = document.getElementById('nav-manage-btn'); const stockPage = document.getElementById('stock-view-page'); const managePage = document.getElementById('manage-view-page'); const subTitle = document.getElementById('page-subtitle'); const activeClass = 'flex items-center justify-center gap-2 px-3.5 sm:px-6 py-2.5 sm:py-3 rounded-xl text-xs sm:text-sm font-bold transition-all bg-teal-500 text-white shadow-md active:scale-95 touch-manipulation min-h-[44px]'; const inactiveClass = 'flex items-center justify-center gap-2 px-3.5 sm:px-6 py-2.5 sm:py-3 rounded-xl text-xs sm:text-sm font-bold transition-all text-slate-300 hover:text-white hover:bg-slate-800/80 active:scale-95 touch-manipulation min-h-[44px]'; if (state.currentRoute === '/') { stockBtn.className = activeClass; manageBtn.className = inactiveClass; stockPage.classList.remove('hidden'); managePage.classList.add('hidden'); subTitle.textContent = 'Current Stock'; renderStockView(); } else { manageBtn.className = activeClass; stockBtn.className = inactiveClass; managePage.classList.remove('hidden'); stockPage.classList.add('hidden'); subTitle.textContent = 'Manager'; resetTabletManageStation(); renderTabletProductPicker(''); } initLucide(); } // ---------------------------------------------------- // API Calls // ---------------------------------------------------- async function loadMetadata() { try { const res = await fetch('/api/meta'); if (res.ok) { state.meta = await res.json(); renderCategoryFilters(); populateModalSelects(); } } catch (err) { console.error('Failed to load metadata:', err); } } async function loadStats() { try { const res = await fetch('/api/stats'); if (res.ok) { const stats = await res.json(); document.getElementById('stat-total').textContent = stats.total || 0; document.getElementById('stat-full').textContent = stats.full || 0; document.getElementById('stat-opened').textContent = stats.opened || 0; document.getElementById('stat-low').textContent = stats.low || 0; } } catch (err) { console.error('Failed to load stats:', err); } } async function loadItems() { try { const params = new URLSearchParams(); if (state.filters.search) params.append('search', state.filters.search); if (state.filters.category && state.filters.category !== 'All') params.append('category', state.filters.category); if (state.filters.status && state.filters.status !== 'all') params.append('status', state.filters.status); const res = await fetch(`/api/items?${params.toString()}`); if (res.ok) { state.items = await res.json(); if (state.currentRoute === '/') { renderStockView(); } else { const query = document.getElementById('tablet-search-input')?.value || ''; renderTabletProductPicker(query); } } } catch (err) { console.error('Failed to load inventory:', err); showToast('Failed to load inventory', 'error'); } } // ---------------------------------------------------- // ROUTE 1: / (CURRENT STOCK DISPLAY) // ---------------------------------------------------- let searchDebounceTimer = null; function handleSearch(val) { state.filters.search = val.trim(); const clearBtn = document.getElementById('clear-search'); if (state.filters.search) { clearBtn.classList.remove('hidden'); } else { clearBtn.classList.add('hidden'); } clearTimeout(searchDebounceTimer); searchDebounceTimer = setTimeout(() => { loadItems(); }, 200); } function clearSearch() { document.getElementById('search-input').value = ''; document.getElementById('clear-search').classList.add('hidden'); state.filters.search = ''; loadItems(); } function setStatusFilter(status) { state.filters.status = status; document.querySelectorAll('[data-status-btn]').forEach(btn => { if (btn.getAttribute('data-status-btn') === status) { btn.className = 'status-chip active px-3.5 py-2 rounded-xl bg-slate-800 border border-slate-700 text-white font-bold min-h-[38px] touch-manipulation'; } else { btn.className = 'status-chip px-3.5 py-2 rounded-xl bg-slate-900 border border-slate-800 text-slate-400 hover:text-slate-200 min-h-[38px] touch-manipulation'; } }); loadItems(); } function setCategoryFilter(category) { state.filters.category = category; renderCategoryFilters(); loadItems(); } function renderCategoryFilters() { const container = document.getElementById('category-filter-bar'); if (!container) return; let html = ` `; state.meta.categories.forEach(cat => { const isSelected = state.filters.category === cat.id; html += ` `; }); container.innerHTML = html; } function renderStockView() { const container = document.getElementById('stock-grid'); const emptyState = document.getElementById('empty-stock'); if (!container) return; if (state.items.length === 0) { emptyState.classList.remove('hidden'); container.innerHTML = ''; return; } emptyState.classList.add('hidden'); container.innerHTML = state.items.map(item => { const isOpened = item.is_opened || item.quantity < item.initial_quantity; const percent = item.initial_quantity > 0 ? Math.min(100, Math.max(0, Math.round((item.quantity / item.initial_quantity) * 100))) : 100; let progressBarColor = 'bg-teal-500'; if (percent <= 20) progressBarColor = 'bg-rose-500'; else if (percent <= 50) progressBarColor = 'bg-amber-500'; return `
${escapeHtml(item.brand || item.category)} ${isOpened ? 'Opened' : 'Full Pack'}
${item.image_url ? ` ${escapeHtml(item.name)} ` : `
`}

${escapeHtml(item.name)}

${item.notes ? `

${escapeHtml(item.notes)}

` : ''}
${item.quantity} ${item.unit} Pack: ${item.initial_quantity} ${item.unit} (${percent}%)
`; }).join(''); initLucide(); } // ---------------------------------------------------- // ---------------------------------------------------- // ROUTE 2: /manage (MANAGER WORKFLOW) // ---------------------------------------------------- function resetTabletManageStation() { state.manage.selectedItem = null; state.manage.action = null; document.getElementById('tablet-step-select').classList.remove('hidden'); document.getElementById('tablet-step-action').classList.add('hidden'); const searchInput = document.getElementById('tablet-search-input'); if (searchInput) searchInput.value = ''; const resultsContainer = document.getElementById('tablet-search-results-container'); if (resultsContainer) resultsContainer.classList.add('hidden'); const picker = document.getElementById('tablet-product-picker'); if (picker) picker.innerHTML = ''; } function handleTabletSearch(val) { renderTabletProductPicker(val); const clean = val.trim(); if (/^\d{8,14}$/.test(clean)) { const found = state.items.find(i => i.barcode === clean); if (found) { forwardToProductAction(found); } } } function renderTabletProductPicker(filterQuery = '') { const picker = document.getElementById('tablet-product-picker'); const resultsContainer = document.getElementById('tablet-search-results-container'); if (!picker || !resultsContainer) return; const query = filterQuery.toLowerCase().trim(); // Only display search results if user has entered a search query if (!query) { resultsContainer.classList.add('hidden'); picker.innerHTML = ''; return; } resultsContainer.classList.remove('hidden'); const matched = state.items.filter(i => { return (i.name && i.name.toLowerCase().includes(query)) || (i.brand && i.brand.toLowerCase().includes(query)) || (i.barcode && i.barcode.includes(query)); }); if (matched.length === 0) { picker.innerHTML = `
No matching product found. Use "Scan Barcode" or tap "Add Manually".
`; return; } picker.innerHTML = matched.map(item => ` `).join(''); initLucide(); } function forwardToProductActionById(id) { const item = state.items.find(i => i.id === id); if (item) forwardToProductAction(item); } // Forwards to the action screen on /manage function forwardToProductAction(item) { state.manage.selectedItem = item; state.manage.action = null; // reset action state.manage.packetCount = 1; state.manage.amountValue = item.quantity; // Populate header banner document.getElementById('action-product-name').textContent = item.name; document.getElementById('action-product-sub').textContent = `Current Stock: ${item.quantity} ${item.unit} • 1 Full Pack = ${item.initial_quantity} ${item.unit}`; document.getElementById('action-pack-size-label').textContent = `${item.initial_quantity} ${item.unit}`; document.getElementById('action-amount-unit').textContent = item.unit; document.getElementById('action-amount-val').textContent = item.quantity; const slider = document.getElementById('action-amount-slider'); slider.max = Math.max(item.initial_quantity * 2, item.quantity + item.initial_quantity, 100); slider.value = item.quantity; slider.step = (item.unit === 'g' || item.unit === 'ml') ? '5' : '1'; const imgBox = document.getElementById('action-product-img'); if (item.image_url) { imgBox.innerHTML = ``; } else { imgBox.innerHTML = ``; } // Switch view state document.getElementById('tablet-step-select').classList.add('hidden'); document.getElementById('tablet-step-action').classList.remove('hidden'); document.getElementById('action-qty-section').classList.add('hidden'); resetActionButtonsStyle(); initLucide(); document.getElementById('tablet-step-action').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } function cancelActionAndReturnToManage() { resetTabletManageStation(); } function setActionType(action) { state.manage.action = action; // 'add' or 'remove' const btnAdd = document.getElementById('action-btn-add'); const btnRemove = document.getElementById('action-btn-remove'); const qtySection = document.getElementById('action-qty-section'); const confirmBtn = document.getElementById('action-confirm-btn'); const confirmLabel = document.getElementById('action-confirm-label'); if (action === 'add') { btnAdd.className = 'p-4 sm:p-5 rounded-2xl border-2 text-center font-extrabold transition-all active:scale-95 touch-manipulation flex flex-col items-center justify-center gap-2 min-h-[84px] bg-emerald-500/20 border-emerald-500 text-white shadow-lg shadow-emerald-500/20 ring-2 ring-emerald-500/40'; btnRemove.className = 'p-4 sm:p-5 rounded-2xl border-2 text-center font-extrabold transition-all active:scale-95 touch-manipulation flex flex-col items-center justify-center gap-2 min-h-[84px] bg-slate-950 border-slate-800 text-slate-400 hover:border-slate-700'; confirmBtn.className = 'flex-1 py-4 rounded-2xl bg-emerald-500 hover:bg-emerald-400 text-white font-black text-base sm:text-lg shadow-xl shadow-emerald-500/25 active:scale-95 transition-all touch-manipulation flex items-center justify-center gap-2'; confirmLabel.textContent = `Confirm Add & Done`; } else { btnRemove.className = 'p-4 sm:p-5 rounded-2xl border-2 text-center font-extrabold transition-all active:scale-95 touch-manipulation flex flex-col items-center justify-center gap-2 min-h-[84px] bg-rose-500/20 border-rose-500 text-white shadow-lg shadow-rose-500/20 ring-2 ring-rose-500/40'; btnAdd.className = 'p-4 sm:p-5 rounded-2xl border-2 text-center font-extrabold transition-all active:scale-95 touch-manipulation flex flex-col items-center justify-center gap-2 min-h-[84px] bg-slate-950 border-slate-800 text-slate-400 hover:border-slate-700'; confirmBtn.className = 'flex-1 py-4 rounded-2xl bg-rose-500 hover:bg-rose-400 text-white font-black text-base sm:text-lg shadow-xl shadow-rose-500/25 active:scale-95 transition-all touch-manipulation flex items-center justify-center gap-2'; confirmLabel.textContent = `Confirm Remove & Done`; } qtySection.classList.remove('hidden'); initLucide(); } function resetActionButtonsStyle() { const btnAdd = document.getElementById('action-btn-add'); const btnRemove = document.getElementById('action-btn-remove'); const def = 'p-4 sm:p-5 rounded-2xl border-2 text-center font-extrabold transition-all active:scale-95 touch-manipulation flex flex-col items-center justify-center gap-2 min-h-[84px] bg-slate-950 border-slate-800 text-slate-300 hover:border-slate-700'; btnAdd.className = def; btnRemove.className = def; } function setActionQtyMode(mode) { state.manage.qtyMode = mode; const btnPack = document.getElementById('btn-mode-packet'); const btnAmount = document.getElementById('btn-mode-amount'); const packControls = document.getElementById('controls-packet'); const amountControls = document.getElementById('controls-amount'); const active = 'py-3 px-3 rounded-xl font-bold text-xs sm:text-sm transition bg-teal-500 text-white shadow-md touch-manipulation flex items-center justify-center gap-2'; const inactive = 'py-3 px-3 rounded-xl font-bold text-xs sm:text-sm transition text-slate-300 hover:text-white touch-manipulation flex items-center justify-center gap-2'; if (mode === 'packet') { btnPack.className = active; btnAmount.className = inactive; packControls.classList.remove('hidden'); amountControls.classList.add('hidden'); } else { btnAmount.className = active; btnPack.className = inactive; amountControls.classList.remove('hidden'); packControls.classList.add('hidden'); } initLucide(); } function changeActionPackCount(delta) { state.manage.packetCount = Math.max(1, state.manage.packetCount + delta); document.getElementById('action-pack-count-val').textContent = state.manage.packetCount; } function setActionPackCount(num) { state.manage.packetCount = num; document.getElementById('action-pack-count-val').textContent = num; } function onActionAmountSlider(val) { state.manage.amountValue = parseFloat(val); document.getElementById('action-amount-val').textContent = state.manage.amountValue; } function applyActionDelta(delta) { state.manage.amountValue = Math.max(0, state.manage.amountValue + delta); document.getElementById('action-amount-slider').value = state.manage.amountValue; document.getElementById('action-amount-val').textContent = state.manage.amountValue; } function setActionAmountRatio(ratio) { if (!state.manage.selectedItem) return; state.manage.amountValue = Math.round(state.manage.selectedItem.initial_quantity * ratio); document.getElementById('action-amount-slider').value = state.manage.amountValue; document.getElementById('action-amount-val').textContent = state.manage.amountValue; } // Confirm & finish execution -> Returns right back to /manage ready for next scan! async function confirmAndFinishAction() { const item = state.manage.selectedItem; const action = state.manage.action; const mode = state.manage.qtyMode; if (!item || !action) { showToast('Please select Add or Remove action first', 'error'); return; } try { if (mode === 'packet') { const endpoint = action === 'add' ? `/api/items/${item.id}/add-packet` : `/api/items/${item.id}/remove-packet`; const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ count: state.manage.packetCount }) }); if (res.ok) { const updated = await res.json(); playBeepSound(); showToast(`✓ ${action === 'add' ? 'Added' : 'Removed'} ${state.manage.packetCount} pack(s) of ${updated.name}`, 'success'); // Return to manage station resetTabletManageStation(); await loadStats(); await loadItems(); } else { showToast('Failed to update packet', 'error'); } } else { // Amount / Grams mode const newQty = state.manage.amountValue; const res = await fetch(`/api/items/${item.id}/quantity`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ newQuantity: newQty, markOpened: true }) }); if (res.ok) { const updated = await res.json(); playBeepSound(); showToast(`✓ Set ${updated.name} to ${updated.quantity} ${updated.unit}`, 'success'); // Return to manage station resetTabletManageStation(); await loadStats(); await loadItems(); } else { showToast('Failed to update amount', 'error'); } } } catch (err) { console.error('Action error:', err); showToast('Network error while updating item', 'error'); } } // ---------------------------------------------------- // Add Product Manually Modal // ---------------------------------------------------- function openItemModal(itemId = null) { const form = document.getElementById('item-form'); form.reset(); document.getElementById('modal-image-container').classList.add('hidden'); document.getElementById('modal-image-url').value = ''; if (itemId) { const item = state.items.find(i => i.id === itemId); if (!item) return; document.getElementById('modal-item-id').value = item.id; document.getElementById('item-modal-title').textContent = 'Edit Product'; document.getElementById('modal-barcode').value = item.barcode || ''; document.getElementById('modal-name').value = item.name || ''; document.getElementById('modal-brand').value = item.brand || ''; document.getElementById('modal-quantity').value = item.quantity; document.getElementById('modal-initial-quantity').value = item.initial_quantity || item.quantity; document.getElementById('modal-unit').value = item.unit || 'g'; document.getElementById('modal-category').value = item.category || 'General'; document.getElementById('modal-notes').value = item.notes || ''; if (item.image_url) { document.getElementById('modal-image-url').value = item.image_url; document.getElementById('modal-image-preview').src = item.image_url; document.getElementById('modal-image-label').textContent = item.name; document.getElementById('modal-image-container').classList.remove('hidden'); } } else { document.getElementById('modal-item-id').value = ''; document.getElementById('item-modal-title').textContent = 'Add Product Manually'; document.getElementById('modal-unit').value = 'g'; document.getElementById('modal-quantity').value = ''; document.getElementById('modal-initial-quantity').value = ''; } document.getElementById('item-modal').classList.remove('hidden'); initLucide(); } function closeItemModal() { document.getElementById('item-modal').classList.add('hidden'); } async function lookupCurrentBarcode() { const code = document.getElementById('modal-barcode').value.trim(); if (!code) { showToast('Please enter a barcode number first', 'error'); return; } const btn = document.getElementById('modal-barcode-lookup-btn'); btn.textContent = 'Searching...'; btn.disabled = true; try { const res = await fetch(`/api/barcode/${encodeURIComponent(code)}`); if (res.ok) { const data = await res.json(); applyProductDataToForm(data); showToast(`Found: ${data.name}`, 'success'); } else { showToast('Product not found in Open Food Facts', 'info'); } } catch (err) { console.error(err); showToast('Barcode lookup failed', 'error'); } finally { btn.textContent = 'Lookup'; btn.disabled = false; } } function applyProductDataToForm(data) { if (data.name) document.getElementById('modal-name').value = data.name; if (data.brand) document.getElementById('modal-brand').value = data.brand; if (data.quantity) { document.getElementById('modal-quantity').value = data.quantity; document.getElementById('modal-initial-quantity').value = data.quantity; } if (data.unit) document.getElementById('modal-unit').value = data.unit; if (data.category) document.getElementById('modal-category').value = data.category; if (data.barcode) document.getElementById('modal-barcode').value = data.barcode; if (data.image_url) { document.getElementById('modal-image-url').value = data.image_url; document.getElementById('modal-image-preview').src = data.image_url; document.getElementById('modal-image-label').textContent = data.name; document.getElementById('modal-image-container').classList.remove('hidden'); } } async function handleItemFormSubmit(event) { event.preventDefault(); const itemId = document.getElementById('modal-item-id').value; const qty = parseFloat(document.getElementById('modal-quantity').value); const initQty = parseFloat(document.getElementById('modal-initial-quantity').value) || qty; const payload = { name: document.getElementById('modal-name').value.trim(), brand: document.getElementById('modal-brand').value.trim(), barcode: document.getElementById('modal-barcode').value.trim(), quantity: qty, initial_quantity: initQty, unit: document.getElementById('modal-unit').value, category: document.getElementById('modal-category').value, notes: document.getElementById('modal-notes').value.trim(), image_url: document.getElementById('modal-image-url').value || null }; try { const url = itemId ? `/api/items/${itemId}` : '/api/items'; const method = itemId ? 'PUT' : 'POST'; const res = await fetch(url, { method: method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { const saved = await res.json(); showToast(itemId ? 'Product updated' : 'Product added', 'success'); closeItemModal(); await loadStats(); await loadItems(); // Forward directly to action screen for this new product on /manage! setRoute('/manage'); forwardToProductAction(saved); } else { const err = await res.json(); showToast(err.error || 'Failed to save product', 'error'); } } catch (err) { console.error(err); showToast('Network error while saving product', 'error'); } } function populateModalSelects() { const catSelect = document.getElementById('modal-category'); if (catSelect && state.meta.categories) { catSelect.innerHTML = state.meta.categories.map(c => ` `).join(''); } } // ---------------------------------------------------- // Tablet Live Camera Barcode Scanner // ---------------------------------------------------- function openScannerModal() { document.getElementById('scanner-modal').classList.remove('hidden'); document.getElementById('scanner-manual-input').value = ''; document.getElementById('scanner-status').textContent = 'Initializing camera...'; startCameraScanner(); initLucide(); } function closeScannerModal() { stopCameraScanner(); document.getElementById('scanner-modal').classList.add('hidden'); } async function startCameraScanner() { if (!window.Html5Qrcode) { document.getElementById('scanner-status').textContent = 'Scanner library loading...'; return; } try { if (state.html5QrCode) { await state.html5QrCode.stop().catch(() => {}); } state.html5QrCode = new Html5Qrcode("barcode-reader"); const config = { fps: 10, qrbox: { width: 260, height: 160 }, aspectRatio: 1.333 }; await state.html5QrCode.start( { facingMode: "environment" }, config, onBarcodeScannedSuccess, onBarcodeScanError ); state.isScanning = true; document.getElementById('scanner-status').textContent = 'Point tablet camera at barcode.'; } catch (err) { console.warn('Camera start error:', err); document.getElementById('scanner-status').textContent = 'Camera unavailable. Please enter barcode number below.'; } } async function stopCameraScanner() { if (state.html5QrCode && state.isScanning) { try { await state.html5QrCode.stop(); state.isScanning = false; } catch (e) { console.warn(e); } } } async function onBarcodeScannedSuccess(decodedText) { playBeepSound(); stopCameraScanner(); closeScannerModal(); // If item exists in stock -> forward directly to product action screen! const existing = state.items.find(i => i.barcode === decodedText); if (existing) { setRoute('/manage'); forwardToProductAction(existing); showToast(`Scanned: ${existing.name}`, 'success'); return; } // Not yet in stock -> lookup in Open Food Facts and open modal to confirm try { const res = await fetch(`/api/barcode/${encodeURIComponent(decodedText)}`); if (res.ok) { const data = await res.json(); openItemModal(); applyProductDataToForm(data); showToast(`Scanned: ${data.name}. Click Save to add to stock!`, 'success'); } else { openItemModal(); document.getElementById('modal-barcode').value = decodedText; showToast(`Scanned barcode: ${decodedText}. Enter product name.`, 'info'); } } catch (err) { console.error(err); openItemModal(); document.getElementById('modal-barcode').value = decodedText; } } function onBarcodeScanError() {} function handleManualBarcodeSubmit() { const code = document.getElementById('scanner-manual-input').value.trim(); if (!code) return; onBarcodeScannedSuccess(code); } function playBeepSound() { try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = 'sine'; osc.frequency.setValueAtTime(880, ctx.currentTime); gain.gain.setValueAtTime(0.15, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15); osc.connect(gain); gain.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + 0.15); } catch (e) {} } function getCategoryIcon(category) { const cat = state.meta.categories.find(c => c.id === category); return cat ? cat.icon : 'package'; } function escapeHtml(str) { if (!str) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function showToast(message, type = 'info') { const container = document.getElementById('toast-container'); if (!container) return; const toast = document.createElement('div'); const colors = { success: 'bg-teal-600 text-white border-teal-500', error: 'bg-rose-600 text-white border-rose-500', info: 'bg-slate-800 text-slate-100 border-slate-700' }; toast.className = `p-3.5 rounded-2xl border shadow-2xl text-xs sm:text-sm font-semibold flex items-center gap-2 transform transition-all duration-300 translate-y-2 opacity-0 pointer-events-auto ${colors[type] || colors.info}`; toast.innerHTML = `${escapeHtml(message)}`; container.appendChild(toast); requestAnimationFrame(() => { toast.classList.remove('translate-y-2', 'opacity-0'); }); setTimeout(() => { toast.classList.add('translate-y-2', 'opacity-0'); setTimeout(() => toast.remove(), 300); }, 3000); }