Skip to main content

WhatsApp Business API Integration

PixelTech uses Firebase Cloud Functions to integrate with WhatsApp Business API for customer service.

Chat Architecture

// Chat document
collection(db, "chats") {
  [phoneNumber]: {
    clientName: "María Rodríguez",
    lastMessage: "Gracias por la información!",
    lastMessageAt: Timestamp,
    lastCustomerInteraction: Timestamp,
    status: "open",        // or "resolved"
    unread: true,
    createdAt: Timestamp
  }
}

// Messages subcollection
collection(db, "chats/573001234567/messages") {
  [messageId]: {
    type: "incoming" | "outgoing",
    messageType: "text" | "image" | "audio",
    content: "Message text",
    mediaUrl: "https://...",  // for images/audio
    timestamp: Timestamp
  }
}

Security Rules

match /chats/{chatId} {
  allow read, write: if isAdmin();
  
  match /messages/{messageId} {
    allow read, write: if isAdmin();
  }
}
Only admins can access WhatsApp conversations to protect customer privacy.

Chat Management

Real-Time Chat List

Optimized with docChanges() for surgical updates:
function initChatList() {
    const ref = collection(db, "chats");
    const q = query(
        ref, 
        where("status", "==", currentTab), 
        orderBy("lastMessageAt", "desc"), 
        limit(50)
    );
    
    unsubscribeChats = onSnapshot(q, (snapshot) => {
        snapshot.docChanges().forEach(change => {
            const data = change.doc.data();
            const id = change.doc.id;
            
            if (change.type === "added") {
                const card = createChatCard(id, data);
                els.chatList.appendChild(card);
            }
            
            if (change.type === "modified") {
                const existingCard = document.getElementById(`chat-card-${id}`);
                if (existingCard) {
                    updateChatCardContent(existingCard, data);
                    
                    // Move to top if new message
                    if (change.newIndex === 0) {
                        els.chatList.prepend(existingCard);
                    }
                }
            }
        });
    });
}

New Message Notifications

Play sound and show visual indicator:
if ((change.type === "added" || change.type === "modified") && source === "Server") {
    if (data.unread && data.lastMessageAt) {
        const messageAge = Date.now() - data.lastMessageAt.toDate();
        
        if (messageAge < 10000) { // Less than 10 seconds old
            if (document.hidden || activeChatId !== id) {
                playSound();
                document.title = "🔔 Nuevo Mensaje!";
                setTimeout(() => document.title = "WhatsApp CRM", 4000);
            }
        }
    }
}
Sound only plays for new messages when the admin is on a different chat or has the tab inactive.
Search by phone number or customer name:
els.chatSearchInput.oninput = async (e) => {
    const term = e.target.value.toLowerCase().trim();
    
    if (!term) {
        initChatList(); // Return to real-time mode
        return;
    }
    
    // Pause real-time updates during search
    if (chatSearchTimeout) clearTimeout(chatSearchTimeout);
    
    chatSearchTimeout = setTimeout(async () => {
        // Direct phone lookup
        if (!isNaN(term) && term.length > 5) {
            const docSnap = await getDoc(doc(db, "chats", term));
            const docSnap57 = await getDoc(doc(db, "chats", "57" + term));
            
            els.chatList.innerHTML = "";
            if (docSnap.exists()) els.chatList.appendChild(createChatCard(docSnap.id, docSnap.data()));
            if (docSnap57.exists()) els.chatList.appendChild(createChatCard(docSnap57.id, docSnap57.data()));
        } else {
            // Name search
            const termCap = term.charAt(0).toUpperCase() + term.slice(1);
            const q = query(
                collection(db, "chats"),
                orderBy('clientName'),
                startAt(termCap),
                endAt(termCap + '\uf8ff'),
                limit(10)
            );
            
            const snap = await getDocs(q);
            els.chatList.innerHTML = "";
            snap.forEach(d => {
                els.chatList.appendChild(createChatCard(d.id, d.data()));
            });
        }
    }, 600);
};

Message Area

Conversation Loading

Paginated message history with real-time updates:
function loadMessages(chatId) {
    // Query: Last 20 messages
    const q = query(
        collection(db, "chats", chatId, "messages"),
        orderBy("timestamp", "asc"),
        limitToLast(20)
    );
    
    unsubscribeMessages = onSnapshot(q, (snapshot) => {
        const liveContainer = document.getElementById('live-messages-container');
        
        // Save scroll position
        const isAtBottom = els.msgArea.scrollHeight - els.msgArea.scrollTop - els.msgArea.clientHeight < 100;
        
        snapshot.docChanges().forEach(change => {
            const data = change.doc.data();
            const msgId = change.doc.id;
            
            if (change.type === "added") {
                const node = createMessageNode(data);
                node.id = `msg-${msgId}`;
                liveContainer.appendChild(node);
            }
            
            if (change.type === "modified") {
                const existing = document.getElementById(`msg-${msgId}`);
                if (existing) {
                    const newNode = createMessageNode(data);
                    newNode.id = `msg-${msgId}`;
                    liveContainer.replaceChild(newNode, existing);
                }
            }
        });
        
        // Auto-scroll if user was at bottom
        if (isAtBottom || snapshot.metadata.fromCache) {
            setTimeout(() => {
                els.msgArea.scrollTo({ top: els.msgArea.scrollHeight, behavior: 'smooth' });
            }, 100);
        }
    });
}

Load Older Messages

Infinite scroll pagination:
async function loadOlderMessages() {
    const previousHeight = els.msgArea.scrollHeight;
    const previousScroll = els.msgArea.scrollTop;
    
    const q = query(
        collection(db, "chats", activeChatId, "messages"),
        orderBy("timestamp", "desc"),
        startAfter(oldestMessageDoc),
        limit(20)
    );
    
    const snap = await getDocs(q);
    if (snap.empty) {
        btnWrapper.innerHTML = `<span class="text-gray-400">Inicio de la conversación</span>`;
        return;
    }
    
    oldestMessageDoc = snap.docs[snap.docs.length - 1];
    
    // Insert at top
    const fragment = document.createDocumentFragment();
    snap.docs.reverse().forEach(doc => {
        fragment.appendChild(createMessageNode(doc.data()));
    });
    historyContainer.prepend(fragment);
    
    // Maintain scroll position
    const newHeight = els.msgArea.scrollHeight;
    els.msgArea.scrollTop = newHeight - previousHeight + previousScroll;
}

Quick Replies and Templates

Pre-configured responses for common questions:

Template System

const QUICK_REPLIES = [
    { 
        title: "👋 Saludo", 
        text: "¡Hola! Gracias por escribir a PixelTech. ¿En qué podemos ayudarte hoy?" 
    },
    { 
        title: "🛵 Envío Bogotá", 
        text: "Para Bogotá el envío llega el mismo día (Lunes a Sábado) si confirmas antes de las 2:30 PM.\n\n💰 Costo: $10.000\n🤝 Pago: Contra entrega." 
    },
    { 
        title: "🚚 Envío Nacional", 
        text: "Realizamos envíos a toda Colombia 🇨🇴. Si confirmas antes de las 2:30 PM sale hoy mismo.\n\n📸 Te enviamos foto del paquete y la guía de rastreo.\n💰 Costo promedio: $18.000 (varía según ubicación)." 
    },
    {
        title: "📝 Pedir Datos",
        text: "Para procesar tu pedido, regálame por favor estos datos:\n\n🧑🏻 Nombre:\n🎫 C.C:\n📲 Cel:\n🏠 Dirección:\n🏭 Barrio:\n🌆 Ciudad:"
    },
    {
        title: "🛡️ Garantía",
        text: "Todos nuestros productos tienen *1 mes de garantía* por defectos de fábrica.\n\nNota: Los defectos de fábrica usualmente se muestran inmediatamente o durante la primera semana."
    }
];

Slash Command Activation

els.txtInput.addEventListener('input', (e) => {
    const val = e.target.value;
    
    if (val.startsWith('/')) {
        const filter = val.substring(1).toLowerCase();
        renderQuickReplies(filter);
        els.quickReplyMenu.classList.remove('hidden');
    } else {
        els.quickReplyMenu.classList.add('hidden');
    }
});

function renderQuickReplies(filter) {
    const filtered = QUICK_REPLIES.filter(r => 
        r.title.toLowerCase().includes(filter) || 
        r.text.toLowerCase().includes(filter)
    );
    
    filtered.forEach(r => {
        const div = document.createElement('div');
        div.className = "p-3 hover:bg-slate-50 cursor-pointer";
        div.innerHTML = `
            <p class="text-[10px] font-black uppercase text-brand-cyan">${r.title}</p>
            <p class="text-xs text-gray-600">${r.text}</p>
        `;
        div.onclick = () => {
            els.txtInput.value = r.text;
            els.quickReplyMenu.classList.add('hidden');
            els.txtInput.focus();
        };
        els.quickReplyList.appendChild(div);
    });
}
Type / to open quick replies. Start typing to filter (e.g., /envio shows shipping templates).

Product Sharing

Smart Product Cache

Products are cached in localStorage for instant search:
function initSmartProductList() {
    const STORAGE_KEY = 'pixeltech_admin_quick_catalog';
    
    // 1. Load from cache instantly
    const cachedRaw = localStorage.getItem(STORAGE_KEY);
    if (cachedRaw) {
        const parsed = JSON.parse(cachedRaw);
        chatProductsCache = Object.values(parsed.map);
        renderProductList(chatProductsCache.slice(0, 20));
    }
    
    // 2. Listen for real-time updates
    const q = lastSyncTime === 0 
        ? query(collection(db, "products"))
        : query(collection(db, "products"), where("updatedAt", ">", new Date(lastSyncTime)));
    
    unsubscribeChatProducts = onSnapshot(q, (snapshot) => {
        snapshot.docChanges().forEach(change => {
            const data = change.doc.data();
            runtimeMap[change.doc.id] = { id: change.doc.id, ...data };
        });
        
        localStorage.setItem(STORAGE_KEY, JSON.stringify({
            map: runtimeMap,
            lastSync: Date.now()
        }));
        
        chatProductsCache = Object.values(runtimeMap);
        renderProductList(chatProductsCache.slice(0, 20));
    });
}
els.prodSearch.oninput = (e) => {
    const term = normalizeText(e.target.value);
    
    if (term.length === 0) {
        renderProductList(chatProductsCache.slice(0, 20));
        return;
    }
    
    // Search in RAM (zero Firebase reads)
    const results = chatProductsCache.filter(p => {
        const nameMatch = normalizeText(p.name).includes(term);
        const catMatch = normalizeText(p.category).includes(term);
        const subMatch = normalizeText(p.subcategory).includes(term);
        return nameMatch || catMatch || subMatch;
    });
    
    renderProductList(results.slice(0, 20));
};

Send Product via WhatsApp

async function sendProduct(product) {
    const price = (product.price || 0).toLocaleString('es-CO');
    const isVariable = !product.isSimple;
    const priceText = isVariable ? `Desde $${price}` : `$${price}`;
    
    let featuresText = "";
    if (product.definedColors?.length > 0) {
        featuresText += `\n🎨 *Colores:* ${product.definedColors.join(', ')}`;
    }
    if (product.definedCapacities?.length > 0) {
        featuresText += `\n💾 *Capacidad:* ${product.definedCapacities.join(', ')}`;
    }
    
    let warrantyText = "";
    if (product.warranty?.time > 0) {
        const unit = TIME_UNITS[product.warranty.unit] || product.warranty.unit;
        warrantyText = `\n🛡️ *Garantía:* ${product.warranty.time} ${unit} (Directa)`;
    }
    
    const caption = `*${product.name}*\n💲 *Precio:* ${priceText}${featuresText}${warrantyText}`.trim();
    const imgUrl = product.mainImage || product.image || product.images?.[0];
    
    const sendFn = httpsCallable(functions, 'sendWhatsappMessage');
    await sendFn({ 
        phoneNumber: activeChatId, 
        message: caption, 
        type: 'image',
        mediaUrl: imgUrl
    });
}
Product cards automatically show warranty, colors, capacities, and stock status.

Order Creation from Chat

Quick Order Flow

els.btnActSale.onclick = async () => {
    if (!activeChatId) return;
    
    const cleanPhone = activeChatId.replace(/^57/, '');
    
    // Check if customer exists
    const q = query(
        collection(db, "users"),
        where("phone", "==", cleanPhone),
        limit(1)
    );
    const snap = await getDocs(q);
    
    // Open manual sale modal
    await openManualSaleModal();
    
    // Pre-fill customer data
    if (!snap.empty) {
        const customer = snap.docs[0].data();
        document.getElementById('m-cust-search').value = customer.name;
        document.getElementById('m-cust-phone').value = customer.phone;
    } else {
        document.getElementById('m-cust-phone').value = cleanPhone;
    }
};

Customer Service Workflow

24-Hour Session Window

WhatsApp Business API has a 24-hour messaging window:
function startSessionTimer(lastCustomerInteraction) {
    if (timerInterval) clearInterval(timerInterval);
    
    const check = () => {
        if (!lastCustomerInteraction) {
            updateTimer(0, false, "Esperando...");
            return;
        }
        
        const elapsed = new Date() - lastCustomerInteraction.toDate();
        const remaining = (24 * 60 * 60 * 1000) - elapsed;
        
        if (remaining <= 0) {
            updateTimer(0, false, "Expirado");
            clearInterval(timerInterval);
        } else {
            updateTimer(remaining, true);
        }
    };
    
    check();
    timerInterval = setInterval(check, 1000);
}

function updateTimer(ms, open, txt) {
    els.timerBadge.className = open 
        ? 'bg-emerald-100 text-emerald-700' 
        : 'bg-red-100 text-red-700';
    
    els.timerText.textContent = open 
        ? `${Math.floor(ms/3600000)}h ${Math.floor((ms%3600000)/60000)}m` 
        : txt;
    
    // Disable input when session expires
    els.txtInput.disabled = !open;
    els.btnAttach.disabled = !open;
    els.btnProducts.disabled = !open;
    els.btnSend.disabled = !open;
}
After 24 hours without customer interaction, you can only send template messages (not supported in this UI).

Resolve Chat

Move conversation to resolved tab:
els.btnResolve.onclick = async () => {
    if (!activeChatId) return;
    
    const isResolved = els.btnResolve.innerText.includes('Reabrir');
    const newStatus = isResolved ? 'open' : 'resolved';
    
    await updateDoc(doc(db, "chats", activeChatId), { 
        status: newStatus,
        resolvedAt: newStatus === 'resolved' ? new Date() : null
    });
    
    if (currentTab === 'open' && newStatus === 'resolved') {
        activeChatId = null;
        els.conversationPanel.classList.add('translate-x-full');
    }
};

Media Support

Send Images

els.btnAttach.onclick = () => els.fileInput.click();

els.fileInput.onchange = async (e) => {
    const file = e.target.files[0];
    if (!file || !activeChatId) return;
    
    const storageRef = ref(
        storage, 
        `chats/${activeChatId}/uploads/${Date.now()}_${file.name}`
    );
    
    await uploadBytes(storageRef, file);
    const url = await getDownloadURL(storageRef);
    
    const sendFn = httpsCallable(functions, 'sendWhatsappMessage');
    await sendFn({ 
        phoneNumber: activeChatId, 
        message: "", 
        type: 'image', 
        mediaUrl: url 
    });
};

Display Media

function createMessageNode(message) {
    let contentHtml = "";
    
    if (message.messageType === 'text') {
        contentHtml = `<p class="whitespace-pre-wrap">${message.content}</p>`;
    } else if (message.messageType === 'image' && message.mediaUrl) {
        contentHtml = `
            <a href="${message.mediaUrl}" target="_blank">
                <img src="${message.mediaUrl}" class="rounded-lg max-w-xs max-h-64 object-cover">
            </a>
            ${message.content ? `<p class="mt-1">${message.content}</p>` : ''}
        `;
    } else if (message.messageType === 'audio' && message.mediaUrl) {
        contentHtml = `<audio controls><source src="${message.mediaUrl}"></audio>`;
    }
    
    const div = document.createElement('div');
    div.className = `flex ${message.type === 'incoming' ? 'justify-start' : 'justify-end'}`;
    div.innerHTML = `
        <div class="max-w-[75%] p-3 ${message.type === 'incoming' ? 'chat-bubble-in' : 'chat-bubble-out'}">
            ${contentHtml}
        </div>
    `;
    return div;
}

Best Practices

Response Time

  • Reply within 5 minutes during business hours
  • Use quick replies for common questions
  • Monitor unread badge count

Personalization

  • Address customers by name
  • Reference their order history
  • Remember preferences from past chats

Product Sharing

  • Send product cards instead of manual typing
  • Include warranty information
  • Mention available colors/capacities

Order Conversion

  • Ask for shipping details during chat
  • Create orders directly from conversation
  • Send payment instructions immediately

Build docs developers (and LLMs) love