Apply .gitignore — stop tracking ignored files

This commit is contained in:
admin 2025-08-25 16:59:33 -06:00
parent 6c8d0ca442
commit 8b5bae04e0
24 changed files with 675 additions and 15861 deletions

17
.env
View File

@ -1,17 +0,0 @@
# Phone Configuration
PHONE_IP=10.0.0.193
ADB_PORT=5555
TERMUX_API_PORT=5001
# Flask Configuration
FLASK_ENV=development
SECRET_KEY=your-secret-key-here
DEFAULT_DELAY_SECONDS=3
# SMS Campaign coordinates
SEND_BUTTON_X=1300
SEND_BUTTON_Y=2900
# Termux API Configuration
TERMUX_API_SECRET=termux-sms-campaign-2025
PREFER_TERMUX_API=true

View File

@ -1,73 +0,0 @@
from flask import Flask, jsonify, render_template_string
import subprocess
import json
app = Flask(__name__)
@app.route('/')
def index():
return render_template_string('''
<!DOCTYPE html>
<html>
<head>
<title>SMS Campaign Manager - Termux</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; background: #f5f5f5; }
.container { background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.status { background: #e8f5e8; padding: 20px; border-radius: 8px; margin: 20px 0; }
.api-test { background: #f0f8ff; padding: 15px; border-radius: 5px; margin: 10px 0; }
a { color: #0066cc; text-decoration: none; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="container">
<h1>🚀 SMS Campaign Manager</h1>
<h2>Running on Termux!</h2>
<div class="status">
<h3>✅ Flask Server Status: Active</h3>
<p><strong>Server IP:</strong> 10.0.0.193:5000</p>
<p><strong>Environment:</strong> Termux on Android</p>
</div>
<div class="api-test">
<h3>🔋 Termux API Tests</h3>
<p><a href="/battery">📱 Battery Status</a></p>
<p><a href="/notification">🔔 Send Test Notification</a></p>
</div>
</div>
</body>
</html>
''')
@app.route('/battery')
def battery():
try:
result = subprocess.run(['termux-battery-status'], capture_output=True, text=True)
battery_data = json.loads(result.stdout)
return f"""
<h2>🔋 Battery Status</h2>
<pre>{json.dumps(battery_data, indent=2)}</pre>
<p><a href='/'>← Back</a></p>
"""
except Exception as e:
return f"<h2>Error</h2><pre>{str(e)}</pre><p><a href='/'>← Back</a></p>"
@app.route('/notification')
def notification():
try:
subprocess.run(['termux-notification', '--title', 'Flask Test', '--content', 'Hello from SMS Campaign Manager!'], capture_output=True, text=True)
return f"""
<h2>🔔 Notification Sent!</h2>
<p>Check your Android notifications.</p>
<p><a href='/'>← Back</a></p>
"""
except Exception as e:
return f"<h2>Error</h2><pre>{str(e)}</pre><p><a href='/'>← Back</a></p>"
if __name__ == '__main__':
print("🚀 Starting SMS Campaign Manager on Termux...")
print("📱 Device IP: 10.0.0.193")
print("🌐 Access from Ubuntu: http://10.0.0.193:5000")
app.run(host='0.0.0.0', port=5000, debug=True)

View File

@ -1,437 +0,0 @@
#!/usr/bin/env python3
"""
Termux SMS API Server
Bridges SMS Campaign Manager (Ubuntu) with Termux API (Android)
This server runs on the Android device in Termux and provides REST API
endpoints for the main SMS Campaign Manager to send messages using
native Android SMS capabilities instead of ADB automation.
"""
from flask import Flask, request, jsonify
import subprocess
import json
import time
import logging
import hmac
import hashlib
import os
from datetime import datetime
from typing import Dict, List, Optional, Any
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('/data/data/com.termux/files/home/logs/sms-api.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
app = Flask(__name__)
# Configuration
CONFIG = {
'SECRET_KEY': os.environ.get('SMS_API_SECRET', 'termux-sms-campaign-2025'),
'MAX_MESSAGE_LENGTH': 160,
'RATE_LIMIT_DELAY': 2.0, # Seconds between messages
'ALLOWED_COMMANDS': [
'termux-sms-send',
'termux-sms-list',
'termux-battery-status',
'termux-location',
'termux-notification'
]
}
class SMSApiServer:
"""Main SMS API server class"""
def __init__(self):
self.last_send_time = 0
self.message_count = 0
self.start_time = time.time()
def authenticate_request(self, request_data: str, signature: str) -> bool:
"""Verify HMAC signature for request authentication"""
try:
expected_signature = hmac.new(
CONFIG['SECRET_KEY'].encode(),
request_data.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected_signature)
except Exception as e:
logger.error(f"Authentication error: {e}")
return False
def execute_termux_command(self, command: List[str]) -> Dict[str, Any]:
"""Execute Termux API command with error handling"""
if not command or command[0] not in CONFIG['ALLOWED_COMMANDS']:
return {'success': False, 'error': 'Command not allowed'}
try:
logger.info(f"Executing: {' '.join(command)}")
result = subprocess.run(
command,
capture_output=True,
text=True,
timeout=30
)
return {
'success': result.returncode == 0,
'stdout': result.stdout.strip(),
'stderr': result.stderr.strip(),
'return_code': result.returncode
}
except subprocess.TimeoutExpired:
return {'success': False, 'error': 'Command timeout'}
except Exception as e:
return {'success': False, 'error': str(e)}
def rate_limit_check(self) -> bool:
"""Check if enough time has passed since last message"""
current_time = time.time()
if current_time - self.last_send_time < CONFIG['RATE_LIMIT_DELAY']:
return False
self.last_send_time = current_time
return True
def send_sms(self, phone: str, message: str) -> Dict[str, Any]:
"""Send SMS using Termux API"""
# Input validation
if not phone or not message:
return {'success': False, 'error': 'Phone and message required'}
if len(message) > CONFIG['MAX_MESSAGE_LENGTH']:
return {'success': False, 'error': f'Message too long (max {CONFIG["MAX_MESSAGE_LENGTH"]} chars)'}
# Rate limiting
if not self.rate_limit_check():
return {'success': False, 'error': 'Rate limit exceeded, please wait'}
# Execute SMS send command
command = ['termux-sms-send', '-n', phone, message]
result = self.execute_termux_command(command)
if result['success']:
self.message_count += 1
logger.info(f"SMS sent to {phone}: {message[:50]}...")
# Send confirmation notification
self.execute_termux_command([
'termux-notification',
'--title', 'SMS Sent',
'--content', f'Message sent to {phone}'
])
return {
'success': result['success'],
'error': result.get('error') or result.get('stderr'),
'timestamp': datetime.now().isoformat(),
'phone': phone,
'message_length': len(message),
'total_sent': self.message_count
}
# Global server instance
sms_server = SMSApiServer()
# API Endpoints
# Web interface route
@app.route("/")
def index():
"""Web interface for SMS API Server"""
from flask import render_template_string
return render_template_string("""
<html>
<head>
<title>SMS API Server - Android</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0; padding: 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh; color: white; }
.container { max-width: 800px; margin: 0 auto; background: rgba(255,255,255,0.1);
padding: 30px; border-radius: 15px; backdrop-filter: blur(10px); box-shadow: 0 8px 32px rgba(0,0,0,0.1); }
h1 { text-align: center; margin-bottom: 30px; font-size: 2.5em; text-shadow: 2px 2px 4px rgba(0,0,0,0.3); }
.status { background: rgba(0,255,0,0.2); padding: 20px; border-radius: 10px; margin: 20px 0;
border-left: 5px solid #00ff00; }
.endpoint { background: rgba(255,255,255,0.1); padding: 15px; margin: 10px 0; border-radius: 8px;
border-left: 3px solid #fff; }
.endpoint h3 { margin: 0 0 10px 0; color: #fff; }
.endpoint code { background: rgba(0,0,0,0.3); padding: 5px 10px; border-radius: 5px;
font-family: "Courier New", monospace; }
.endpoint p { margin: 5px 0; opacity: 0.9; }
.test-links { text-align: center; margin: 20px 0; }
.test-links a { display: inline-block; margin: 5px 10px; padding: 10px 20px;
background: rgba(255,255,255,0.2); color: white; text-decoration: none;
border-radius: 25px; transition: all 0.3s ease; }
.test-links a:hover { background: rgba(255,255,255,0.3); transform: translateY(-2px); }
.footer { text-align: center; margin-top: 40px; opacity: 0.7; font-size: 0.9em; }
</style>
</head>
<body>
<div class="container">
<div class="header-nav" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; padding-bottom: 20px; border-bottom: 1px solid rgba(255,255,255,0.2);"><div><h1 style="margin: 0; font-size: 2.5em;">📱 SMS API Server</h1><p style="margin: 0; opacity: 0.8; font-size: 1.1em;">Android Termux Interface</p></div><div class="nav-links" style="display: flex; gap: 15px;"><a href="http://10.0.0.190:8080" style="background: rgba(255,255,255,0.2); padding: 8px 16px; border-radius: 20px; color: white; text-decoration: none; font-size: 0.9em; transition: all 0.3s ease;">🏠 Homelab</a><a href="http://10.0.0.193:5000" style="background: rgba(255,255,255,0.2); padding: 8px 16px; border-radius: 20px; color: white; text-decoration: none; font-size: 0.9em; transition: all 0.3s ease;">📊 Monitor</a></div></div>
<h2>🚀 Running on Android (Termux)</h2>
<div class="status">
<h3>✅ Server Status: Operational</h3>
<p><strong>Device IP:</strong> {{ device_ip }}</p>
<p><strong>Port:</strong> 5001</p>
<p><strong>Environment:</strong> Termux on Android</p>
</div>
<h3>🔗 API Endpoints</h3>
<div class="endpoint">
<h3>📊 Health Check</h3>
<p><code>GET /health</code></p>
<p>Returns server status, uptime, and message statistics</p>
</div>
<div class="endpoint">
<h3>📱 Send SMS</h3>
<p><code>POST /api/sms/send</code></p>
<p>Send SMS messages with name substitution support</p>
</div>
<div class="endpoint">
<h3>🔋 Battery Status</h3>
<p><code>GET /api/device/battery</code></p>
<p>Get real-time Android device battery information</p>
</div>
<div class="endpoint">
<h3>📍 Location</h3>
<p><code>GET /api/device/location</code></p>
<p>Get GPS coordinates (with permissions)</p>
</div>
<div class="endpoint">
<h3> Device Info</h3>
<p><code>GET /api/device/info</code></p>
<p>System information and device details</p>
</div>
<div class="test-links">
<h3>🧪 Quick Tests</h3>
<a href="/health">📊 Health Check</a>
<a href="/api/device/battery">🔋 Battery</a>
<a href="/api/device/info"> Device Info</a>
</div>
<div class="footer">
<p>🏠 Part of SMS Campaign Manager System</p>
<p>🖥️ Main Interface: <a href="http://{{ homelab_ip }}:5000" style="color: #fff;">http://{{ homelab_ip }}:5000</a></p>
</div>
</div>
</body>
</html>
""", device_ip="10.0.0.193", homelab_ip="10.0.0.190")
@app.route('/health', methods=['GET'])
def health_check():
"""Health check endpoint"""
uptime = time.time() - sms_server.start_time
return jsonify({
'status': 'healthy',
'timestamp': datetime.now().isoformat(),
'uptime_seconds': int(uptime),
'messages_sent': sms_server.message_count,
'version': '1.0.0'
})
@app.route('/api/sms/send', methods=['POST'])
def send_sms():
"""Send SMS message via Termux API"""
try:
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'JSON data required'}), 400
# Extract parameters
phone = data.get('phone', '').strip()
message = data.get('message', '').strip()
name = data.get('name', '')
# Message template substitution (like existing ui.sh)
if name and '{name}' in message:
message = message.replace('{name}', name)
# Send SMS
result = sms_server.send_sms(phone, message)
status_code = 200 if result['success'] else 400
return jsonify(result), status_code
except Exception as e:
logger.error(f"SMS send error: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/sms/list', methods=['GET'])
def list_sms():
"""List SMS messages"""
try:
limit = request.args.get('limit', '10')
offset = request.args.get('offset', '0')
command = ['termux-sms-list', '-l', limit, '-o', offset]
result = sms_server.execute_termux_command(command)
if result['success']:
try:
sms_data = json.loads(result['stdout']) if result['stdout'] else []
return jsonify({
'success': True,
'messages': sms_data,
'count': len(sms_data)
})
except json.JSONDecodeError:
return jsonify({
'success': True,
'messages': result['stdout'],
'raw_output': True
})
else:
return jsonify({'success': False, 'error': result['error']}), 400
except Exception as e:
logger.error(f"SMS list error: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/device/battery', methods=['GET'])
def get_battery_status():
"""Get device battery status"""
try:
result = sms_server.execute_termux_command(['termux-battery-status'])
if result['success']:
battery_data = json.loads(result['stdout'])
return jsonify({
'success': True,
'battery': battery_data,
'timestamp': datetime.now().isoformat()
})
else:
return jsonify({'success': False, 'error': result['error']}), 400
except Exception as e:
logger.error(f"Battery status error: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/device/location', methods=['GET'])
def get_location():
"""Get device GPS location"""
try:
result = sms_server.execute_termux_command(['termux-location'])
if result['success'] and result['stdout']:
try:
location_data = json.loads(result['stdout'])
return jsonify({
'success': True,
'location': location_data,
'timestamp': datetime.now().isoformat()
})
except json.JSONDecodeError:
return jsonify({
'success': True,
'location': result['stdout'],
'raw_output': True
})
else:
return jsonify({'success': False, 'error': 'Location unavailable'}), 400
except Exception as e:
logger.error(f"Location error: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/device/info', methods=['GET'])
def get_device_info():
"""Get general device information"""
try:
# Get multiple device stats
battery_result = sms_server.execute_termux_command(['termux-battery-status'])
info = {
'timestamp': datetime.now().isoformat(),
'uptime': time.time() - sms_server.start_time,
'messages_sent': sms_server.message_count,
'api_version': '1.0.0',
'termux_api_available': True
}
if battery_result['success']:
try:
battery_data = json.loads(battery_result['stdout'])
info['battery'] = battery_data
except json.JSONDecodeError:
pass
return jsonify({'success': True, 'device_info': info})
except Exception as e:
logger.error(f"Device info error: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/campaign/notify', methods=['POST'])
def campaign_notification():
"""Send notification about campaign status"""
try:
data = request.get_json()
title = data.get('title', 'SMS Campaign')
message = data.get('message', 'Campaign update')
result = sms_server.execute_termux_command([
'termux-notification',
'--title', title,
'--content', message
])
return jsonify({
'success': result['success'],
'error': result.get('error')
})
except Exception as e:
logger.error(f"Notification error: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
if __name__ == '__main__':
# Create logs directory
os.makedirs('/data/data/com.termux/files/home/logs', exist_ok=True)
logger.info("Starting Termux SMS API Server...")
logger.info(f"Available commands: {CONFIG['ALLOWED_COMMANDS']}")
# Get local IP for display
try:
ip_result = subprocess.run([
'ifconfig', '2>/dev/null', '|', 'grep', '-A1', 'wlan0', '|',
'grep', 'inet', '|', 'awk', '{print $2}', '|', 'cut', '-d:', '-f2'
], shell=True, capture_output=True, text=True)
local_ip = ip_result.stdout.strip() or '10.0.0.193'
except:
local_ip = '10.0.0.193'
print(f"""
🚀 Termux SMS API Server Starting
📱 Device IP: {local_ip}
🌐 API Base URL: http://{local_ip}:5001
🔗 Health Check: http://{local_ip}:5001/health
📞 SMS Endpoint: http://{local_ip}:5001/api/sms/send
🔋 Battery API: http://{local_ip}:5001/api/device/battery
Access from Ubuntu homelab:
curl http://{local_ip}:5001/health
""")
app.run(host='0.0.0.0', port=5001, debug=False)

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +0,0 @@
phone,message,name
7802921731,hi {name} just testing,Reed
,,
,,
1 phone message name
2 7802921731 hi {name} just testing Reed
3
4

View File

@ -1,8 +0,0 @@
name,phone,message
Quin,+1-825-461-6974,
Brad,780 975 4537,
Robert,7802936842,
Ken,7809101334,
Nasif,+15875240402,
Rebecca,+15873360926,
Haley,(438) 938-0733,
1 name phone message
2 Quin +1-825-461-6974
3 Brad 780 975 4537
4 Robert 7802936842
5 Ken 7809101334
6 Nasif +15875240402
7 Rebecca +15873360926
8 Haley (438) 938-0733

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,653 +0,0 @@
/**
* Enhanced Conversation Manager - WhatsApp-style messaging interface
* Integrates WebSocket for real-time updates and handles bidirectional SMS
*/
class EnhancedConversationManager {
constructor() {
// State management
this.conversations = [];
this.selectedConversation = null;
this.messages = [];
this.conversationFilter = 'all';
this.conversationSearch = '';
this.newMessage = '';
this.sendingMessage = false;
this.hasMoreMessages = false;
this.currentPage = 1;
this.socket = null;
this.isConnected = false;
// UI elements (will be set by Alpine.js)
this.messagesContainer = null;
// Initialize
this.init();
}
async init() {
console.log('🚀 Initializing Enhanced Conversation Manager...');
try {
// Setup WebSocket connection
await this.setupWebSocket();
// Load conversations
await this.loadConversations();
// Setup periodic refresh as fallback
this.startPeriodicRefresh();
console.log('✅ Enhanced Conversation Manager initialized successfully');
} catch (error) {
console.error('❌ Failed to initialize conversation manager:', error);
}
}
async setupWebSocket() {
// Setup WebSocket connection for real-time updates
try {
// Connect to WebSocket server
this.socket = io({
transports: ['websocket', 'polling'],
upgrade: true,
rememberUpgrade: true
});
// Connection events
this.socket.on('connect', () => {
console.log('🔌 Connected to conversation service');
this.isConnected = true;
this.showNotification('Connected to real-time messaging', 'success');
});
this.socket.on('disconnect', (reason) => {
console.log('🔌 Disconnected from conversation service:', reason);
this.isConnected = false;
this.showNotification('Disconnected from real-time messaging', 'warning');
});
this.socket.on('connect_error', (error) => {
console.error('🔌 Connection error:', error);
this.isConnected = false;
});
// Message events
this.socket.on('new_message', (data) => {
this.handleNewMessage(data);
});
this.socket.on('message_status_update', (data) => {
this.updateMessageStatus(data.message_id, data.status);
});
this.socket.on('conversation_update', (data) => {
this.handleConversationUpdate(data);
});
// Sync events
this.socket.on('sync_status', (data) => {
console.log('📡 Sync status:', data.status, data.details);
if (data.status === 'completed') {
this.loadConversations();
}
});
} catch (error) {
console.error('Failed to setup WebSocket:', error);
}
}
async loadConversations() {
//Load conversation list from server//
try {
const params = new URLSearchParams({
limit: '50',
search: this.conversationSearch
});
if (this.conversationFilter === 'starred') {
params.append('starred', 'true');
} else if (this.conversationFilter !== 'all') {
params.append('status', this.conversationFilter);
}
const response = await fetch(`/api/conversations/enhanced?${params}`);
const data = await response.json();
if (data.success) {
this.conversations = data.conversations;
this.renderConversationList();
} else {
console.error('Failed to load conversations:', data.error);
this.showNotification('Failed to load conversations', 'error');
}
} catch (error) {
console.error('Error loading conversations:', error);
this.showNotification('Network error loading conversations', 'error');
}
}
async selectConversation(conversationId) {
//Select and load a conversation//
try {
// Leave previous conversation room
if (this.selectedConversation && this.socket) {
this.socket.emit('leave_conversation', {
conversation_id: this.selectedConversation.id
});
}
// Find conversation
const conversation = this.conversations.find(c => c.id === conversationId);
if (!conversation) {
this.showNotification('Conversation not found', 'error');
return;
}
this.selectedConversation = conversation;
this.messages = [];
this.currentPage = 1;
this.hasMoreMessages = false;
// Join new conversation room
if (this.socket) {
this.socket.emit('join_conversation', {
conversation_id: conversationId
});
}
// Load messages
await this.loadMessages();
// Mark as read
await this.markAsRead(conversationId);
// Update UI
this.renderConversationView();
this.scrollToBottom();
} catch (error) {
console.error('Error selecting conversation:', error);
this.showNotification('Failed to load conversation', 'error');
}
}
async loadMessages() {
//Load messages for current conversation//
if (!this.selectedConversation) return;
try {
const response = await fetch(
`/api/conversations/${this.selectedConversation.id}/messages?page=${this.currentPage}&per_page=50`
);
const data = await response.json();
if (data.success) {
if (this.currentPage === 1) {
this.messages = data.messages;
} else {
// Prepend older messages for pagination
this.messages = [...data.messages, ...this.messages];
}
this.hasMoreMessages = data.has_more;
this.renderMessages();
// Scroll to bottom only on first load
if (this.currentPage === 1) {
setTimeout(() => this.scrollToBottom(), 100);
}
} else {
console.error('Failed to load messages:', data.error);
}
} catch (error) {
console.error('Error loading messages:', error);
}
}
async loadMoreMessages() {
//Load more historical messages//
if (!this.hasMoreMessages || !this.selectedConversation) return;
const scrollHeight = this.getMessagesContainer().scrollHeight;
this.currentPage++;
await this.loadMessages();
// Maintain scroll position after loading older messages
setTimeout(() => {
const container = this.getMessagesContainer();
const newScrollHeight = container.scrollHeight;
container.scrollTop = newScrollHeight - scrollHeight;
}, 50);
}
async sendMessage() {
//Send a new message//
if (!this.newMessage.trim() || this.sendingMessage || !this.selectedConversation) {
return;
}
this.sendingMessage = true;
const messageText = this.newMessage.trim();
this.newMessage = ''; // Clear input immediately
try {
// Send via WebSocket for real-time updates
if (this.socket && this.isConnected) {
this.socket.emit('send_message', {
conversation_id: this.selectedConversation.id,
phone: this.selectedConversation.phone,
message: messageText
});
} else {
// Fallback to HTTP API
const response = await fetch(
`/api/conversations/${this.selectedConversation.id}/send`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: messageText })
}
);
const data = await response.json();
if (data.success) {
// Add optimistic message
const optimisticMessage = {
id: `temp_${Date.now()}`,
message: messageText,
direction: 'outbound',
timestamp: Math.floor(Date.now() / 1000),
status: 'pending'
};
this.messages.push(optimisticMessage);
this.renderMessages();
this.scrollToBottom();
} else {
throw new Error(data.error);
}
}
} catch (error) {
console.error('Failed to send message:', error);
this.showNotification(`Failed to send message: ${error.message}`, 'error');
// Restore message to input on error
this.newMessage = messageText;
} finally {
this.sendingMessage = false;
}
}
handleNewMessage(data) {
//Handle incoming message from WebSocket//
try {
// Update current conversation if it matches
if (this.selectedConversation && this.selectedConversation.id === data.conversation_id) {
// Remove optimistic message if it exists
const tempIndex = this.messages.findIndex(m => m.id.toString().startsWith('temp_'));
if (tempIndex > -1 && data.direction === 'outbound') {
this.messages.splice(tempIndex, 1);
}
// Add the real message
this.messages.push(data);
this.renderMessages();
this.scrollToBottom();
// Mark as read if window is focused
if (document.hasFocus()) {
this.markAsRead(data.conversation_id);
}
}
// Update conversation list
this.updateConversationInList(data);
// Show notification for new messages
if (data.direction === 'inbound') {
const conversation = this.conversations.find(c => c.id === data.conversation_id);
const senderName = conversation?.display_name || data.phone;
this.showNotification(`New message from ${senderName}`, 'info');
// Play notification sound if available
this.playNotificationSound();
}
} catch (error) {
console.error('Error handling new message:', error);
}
}
updateMessageStatus(messageId, status) {
//Update message status in current view//
const message = this.messages.find(m => m.id == messageId);
if (message) {
message.status = status;
this.renderMessages();
}
}
handleConversationUpdate(data) {
//Handle conversation-level updates//
const conversation = this.conversations.find(c => c.id === data.conversation_id);
if (conversation) {
Object.assign(conversation, data);
this.renderConversationList();
}
}
updateConversationInList(messageData) {
//Update conversation in the list with new message info//
const conversation = this.conversations.find(c => c.id === messageData.conversation_id);
if (conversation) {
conversation.last_message = messageData.message;
conversation.last_message_time = messageData.timestamp;
conversation.last_message_direction = messageData.direction;
if (messageData.direction === 'inbound') {
conversation.unread_count = (conversation.unread_count || 0) + 1;
}
// Move to top of list
const index = this.conversations.indexOf(conversation);
if (index > 0) {
this.conversations.splice(index, 1);
this.conversations.unshift(conversation);
}
this.renderConversationList();
}
}
async toggleStar(conversationId) {
//Toggle conversation starred status//
try {
const response = await fetch(
`/api/conversations/${conversationId}/star`,
{ method: 'PUT' }
);
const data = await response.json();
if (data.success) {
// Update local state
const conversation = this.conversations.find(c => c.id === conversationId);
if (conversation) {
conversation.is_starred = data.is_starred;
}
if (this.selectedConversation && this.selectedConversation.id === conversationId) {
this.selectedConversation.is_starred = data.is_starred;
}
this.renderConversationList();
this.renderConversationView();
} else {
throw new Error(data.error);
}
} catch (error) {
console.error('Error toggling star:', error);
this.showNotification('Failed to update star status', 'error');
}
}
async syncConversation(conversationId) {
//Manually sync conversation history//
try {
if (this.socket && this.isConnected) {
this.socket.emit('sync_conversation', {
conversation_id: conversationId
});
} else {
const response = await fetch(
`/api/conversations/${conversationId}/sync`,
{ method: 'POST' }
);
const data = await response.json();
if (data.success) {
this.showNotification('Syncing conversation history...', 'info');
} else {
throw new Error(data.error);
}
}
// Reload messages after a delay
setTimeout(() => {
if (this.selectedConversation && this.selectedConversation.id === conversationId) {
this.currentPage = 1;
this.loadMessages();
}
}, 3000);
} catch (error) {
console.error('Error syncing conversation:', error);
this.showNotification('Failed to sync conversation', 'error');
}
}
async syncAllConversations() {
//Sync all conversations with phone//
try {
const response = await fetch('/api/conversations/sync-all', { method: 'POST' });
const data = await response.json();
if (data.success) {
this.showNotification('Syncing all conversations...', 'info');
// Reload conversation list after delay
setTimeout(() => {
this.loadConversations();
}, 5000);
} else {
throw new Error(data.error);
}
} catch (error) {
console.error('Error syncing all conversations:', error);
this.showNotification('Failed to sync conversations', 'error');
}
}
async markAsRead(conversationId) {
//Mark conversation as read//
try {
await fetch(`/api/conversations/${conversationId}/mark-read`, { method: 'PUT' });
// Update local unread count
const conversation = this.conversations.find(c => c.id === conversationId);
if (conversation) {
conversation.unread_count = 0;
this.renderConversationList();
}
} catch (error) {
console.error('Error marking as read:', error);
}
}
// Computed properties and filters
get filteredConversations() {
//Get filtered conversation list based on current filters//
let filtered = [...this.conversations];
// Apply status filter
if (this.conversationFilter === 'starred') {
filtered = filtered.filter(c => c.is_starred);
} else if (this.conversationFilter === 'unread') {
filtered = filtered.filter(c => (c.unread_count || 0) > 0);
} else if (this.conversationFilter !== 'all') {
filtered = filtered.filter(c => c.status === this.conversationFilter);
}
// Apply search filter
if (this.conversationSearch.trim()) {
const search = this.conversationSearch.toLowerCase().trim();
filtered = filtered.filter(c =>
c.phone.includes(search) ||
(c.contact_name && c.contact_name.toLowerCase().includes(search)) ||
(c.display_name && c.display_name.toLowerCase().includes(search)) ||
(c.last_message && c.last_message.toLowerCase().includes(search))
);
}
return filtered;
}
// Utility methods
formatPhone(phone) {
//Format phone number for display//
if (!phone) return '';
const cleaned = phone.replace(/\D/g, '');
if (cleaned.length === 10) {
return `(${cleaned.slice(0,3)}) ${cleaned.slice(3,6)}-${cleaned.slice(6)}`;
}
return phone;
}
formatTime(timestamp) {
//Format timestamp for conversation list//
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
const now = new Date();
const diff = (now - date) / 1000;
if (diff < 60) return 'now';
if (diff < 3600) return `${Math.floor(diff/60)}m`;
if (diff < 86400) return `${Math.floor(diff/3600)}h`;
if (diff < 604800) return `${Math.floor(diff/86400)}d`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
formatMessageTime(timestamp) {
//Format timestamp for message display//
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
getMessagesContainer() {
//Get messages container element//
if (!this.messagesContainer) {
this.messagesContainer = document.getElementById('messages-container');
}
return this.messagesContainer;
}
scrollToBottom() {
//Scroll messages to bottom//
const container = this.getMessagesContainer();
if (container) {
container.scrollTop = container.scrollHeight;
}
}
showNotification(message, type = 'info') {
//Show toast notification//
const colors = {
success: 'bg-green-500',
error: 'bg-red-500',
warning: 'bg-yellow-500',
info: 'bg-blue-500'
};
const toast = document.createElement('div');
toast.className = `fixed bottom-4 right-4 ${colors[type]} text-white px-4 py-2 rounded-lg shadow-lg z-50 max-w-sm`;
toast.textContent = message;
document.body.appendChild(toast);
// Auto-remove after 3 seconds
setTimeout(() => {
if (toast.parentNode) {
toast.remove();
}
}, 3000);
}
playNotificationSound() {
//Play notification sound if available//
try {
// Simple beep using Web Audio API
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 800;
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
oscillator.start();
oscillator.stop(audioContext.currentTime + 0.2);
} catch (error) {
// Silently fail if audio is not supported
}
}
startPeriodicRefresh() {
//Start periodic refresh as fallback//
setInterval(() => {
if (!this.isConnected) {
console.log('🔄 Periodic refresh (WebSocket disconnected)');
this.loadConversations();
if (this.selectedConversation) {
this.loadMessages();
}
}
}, 30000); // Every 30 seconds
}
// Rendering methods (to be called by Alpine.js)
renderConversationList() {
// This will trigger Alpine.js reactivity
// The actual rendering is handled by the HTML template
}
renderMessages() {
// This will trigger Alpine.js reactivity
// The actual rendering is handled by the HTML template
}
renderConversationView() {
// This will trigger Alpine.js reactivity
// The actual rendering is handled by the HTML template
}
// Cleanup
destroy() {
//Cleanup resources//
if (this.socket) {
this.socket.disconnect();
}
}
}
// Export for use in HTML
window.EnhancedConversationManager = EnhancedConversationManager;

View File

@ -822,3 +822,431 @@ function campaignApp() {
}
};
}
// Enhanced Conversations Data Function
function conversationData() {
return {
// Initialize enhanced conversation manager
manager: null,
// State properties
conversations: [],
selectedConversation: null,
messages: [],
conversationFilter: 'all',
conversationSearch: '',
newMessage: '',
sendingMessage: false,
hasMoreMessages: false,
loadingMessages: false,
syncing: false,
// Computed properties
get filteredConversations() {
let filtered = this.conversations;
// Apply search filter
if (this.conversationSearch) {
const search = this.conversationSearch.toLowerCase();
filtered = filtered.filter(conv =>
(conv.contact_name && conv.contact_name.toLowerCase().includes(search)) ||
conv.phone.includes(search)
);
}
// Apply status filter
switch (this.conversationFilter) {
case 'unread':
filtered = filtered.filter(conv => conv.unread_count > 0);
break;
case 'starred':
filtered = filtered.filter(conv => conv.is_starred);
break;
default:
// 'all' - no additional filtering
break;
}
return filtered;
},
get unreadCount() {
return this.conversations.reduce((total, conv) => total + (conv.unread_count || 0), 0);
},
// Initialize
async init() {
await this.loadConversations();
this.setupWebSocket();
},
// Load conversations from API
async loadConversations() {
try {
const response = await fetch('/api/conversations/enhanced/');
const data = await response.json();
if (data.success) {
this.conversations = data.conversations || [];
console.log('Loaded conversations:', this.conversations.length);
}
} catch (error) {
console.error('Error loading conversations:', error);
}
},
// Select and load conversation messages
async selectConversation(conversationId) {
try {
this.loadingMessages = true;
// Find and set selected conversation
this.selectedConversation = this.conversations.find(conv => conv.phone === conversationId);
if (this.selectedConversation) {
// Load messages for this conversation
await this.loadMessages();
}
} catch (error) {
console.error('Error selecting conversation:', error);
} finally {
this.loadingMessages = false;
}
},
// Load messages for selected conversation
async loadMessages() {
if (!this.selectedConversation) return;
try {
const response = await fetch(`/api/conversations/enhanced/${this.selectedConversation.phone}/messages`);
const data = await response.json();
if (data.success) {
this.messages = data.messages || [];
this.hasMoreMessages = data.has_more || false;
// Scroll to bottom after messages load
this.$nextTick(() => {
if (this.$refs.messagesContainer) {
this.$refs.messagesContainer.scrollTop = this.$refs.messagesContainer.scrollHeight;
}
});
}
} catch (error) {
console.error('Error loading messages:', error);
}
},
// Send a new message
async sendMessage() {
if (!this.newMessage.trim() || !this.selectedConversation || this.sendingMessage) return;
try {
this.sendingMessage = true;
const response = await fetch(`/api/conversations/enhanced/${this.selectedConversation.phone}/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
message: this.newMessage.trim()
})
});
const data = await response.json();
if (data.success) {
// Add optimistic message to UI
const newMsg = {
id: Date.now(), // Temporary ID
message: this.newMessage.trim(),
direction: 'outbound',
status: 'pending',
sent_at: new Date().toISOString(),
phone: this.selectedConversation.phone
};
this.messages.push(newMsg);
this.newMessage = '';
// Scroll to bottom
this.$nextTick(() => {
if (this.$refs.messagesContainer) {
this.$refs.messagesContainer.scrollTop = this.$refs.messagesContainer.scrollHeight;
}
});
// Update conversation in list
this.selectedConversation.message_count = (this.selectedConversation.message_count || 0) + 1;
this.selectedConversation.last_message_time = Date.now() / 1000;
}
} catch (error) {
console.error('Error sending message:', error);
} finally {
this.sendingMessage = false;
}
},
// Filter and search functions
setFilter(filter) {
this.conversationFilter = filter;
},
searchConversations() {
// Reactive filtering happens automatically via computed property
},
// Star/unstar conversation
async toggleStar(conversationId) {
try {
const response = await fetch(`/api/conversations/enhanced/${conversationId}/star`, {
method: 'PUT'
});
if (response.ok) {
// Update local state
const conv = this.conversations.find(c => c.phone === conversationId);
if (conv) {
conv.is_starred = !conv.is_starred;
}
if (this.selectedConversation && this.selectedConversation.phone === conversationId) {
this.selectedConversation.is_starred = !this.selectedConversation.is_starred;
}
}
} catch (error) {
console.error('Error toggling star:', error);
}
},
// Sync functions
async syncConversation(conversationId) {
if (this.syncing) return;
try {
this.syncing = true;
// Trigger sync for specific conversation
await fetch(`/api/conversations/enhanced/${conversationId}/sync`, {
method: 'POST'
});
// Reload messages after sync
if (this.selectedConversation && this.selectedConversation.phone === conversationId) {
await this.loadMessages();
}
} catch (error) {
console.error('Error syncing conversation:', error);
} finally {
this.syncing = false;
}
},
async syncAllConversations() {
if (this.syncing) return;
try {
this.syncing = true;
// Trigger full sync
await fetch('/api/responses/sync', {
method: 'POST'
});
// Reload conversations
await this.loadConversations();
// Reload current conversation messages if any selected
if (this.selectedConversation) {
await this.loadMessages();
}
} catch (error) {
console.error('Error syncing all conversations:', error);
} finally {
this.syncing = false;
}
},
// Setup WebSocket for real-time updates
setupWebSocket() {
if (typeof io === 'undefined') {
console.warn('Socket.IO not available, real-time updates disabled');
return;
}
try {
const socket = io();
socket.on('connect', () => {
console.log('Connected to conversation WebSocket');
});
socket.on('new_message', (data) => {
this.handleNewMessage(data);
});
socket.on('message_status_update', (data) => {
this.updateMessageStatus(data.message_id, data.status);
});
socket.on('conversation_update', (data) => {
this.handleConversationUpdate(data);
});
} catch (error) {
console.error('Error setting up WebSocket:', error);
}
},
// Handle real-time message updates
handleNewMessage(messageData) {
// Add to messages if it's for the current conversation
if (this.selectedConversation && messageData.phone === this.selectedConversation.phone) {
this.messages.push(messageData);
// Scroll to bottom
this.$nextTick(() => {
if (this.$refs.messagesContainer) {
this.$refs.messagesContainer.scrollTop = this.$refs.messagesContainer.scrollHeight;
}
});
}
// Update conversation in list
this.updateConversationInList(messageData);
},
updateMessageStatus(messageId, status) {
// Update message status in current conversation
const message = this.messages.find(m => m.id === messageId);
if (message) {
message.status = status;
}
},
handleConversationUpdate(data) {
const conv = this.conversations.find(c => c.phone === data.phone);
if (conv) {
Object.assign(conv, data);
}
},
updateConversationInList(messageData) {
let conv = this.conversations.find(c => c.phone === messageData.phone);
if (!conv) {
// Create new conversation if it doesn't exist
conv = {
phone: messageData.phone,
contact_name: messageData.name || messageData.phone,
message_count: 1,
unread_count: messageData.direction === 'inbound' ? 1 : 0,
is_starred: false,
last_message_time: messageData.timestamp || Date.now() / 1000
};
this.conversations.unshift(conv);
} else {
// Update existing conversation
conv.message_count = (conv.message_count || 0) + 1;
if (messageData.direction === 'inbound') {
conv.unread_count = (conv.unread_count || 0) + 1;
}
conv.last_message_time = messageData.timestamp || Date.now() / 1000;
// Move to top of list
const index = this.conversations.indexOf(conv);
if (index > 0) {
this.conversations.splice(index, 1);
this.conversations.unshift(conv);
}
}
},
// Utility functions
getInitials(conversation) {
if (!conversation) return '';
const name = conversation.contact_name || conversation.phone;
if (name === conversation.phone) {
// For phone numbers, just use the first two digits
return name.slice(-4, -2) || name.slice(0, 2) || '??';
}
const words = name.split(' ').filter(word => word.length > 0);
if (words.length >= 2) {
return (words[0][0] + words[1][0]).toUpperCase();
} else if (words.length === 1) {
return words[0].slice(0, 2).toUpperCase();
}
return '??';
},
formatPhone(phone) {
if (!phone) return '';
// Remove any non-digit characters
const cleaned = phone.replace(/\D/g, '');
// Format as (xxx) xxx-xxxx for 10 digit numbers
if (cleaned.length === 10) {
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`;
}
return phone;
},
formatTime(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m`;
if (diffHours < 24) return `${diffHours}h`;
if (diffDays < 7) return `${diffDays}d`;
return date.toLocaleDateString();
},
formatMessageTime(timestamp) {
if (!timestamp) return '';
let date;
if (typeof timestamp === 'string') {
date = new Date(timestamp);
} else {
date = new Date(timestamp * 1000);
}
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
},
getStatusIcon(status) {
switch (status) {
case 'pending': return '⏳';
case 'sent': return '✓';
case 'delivered': return '✓✓';
case 'failed': return '❌';
default: return '';
}
},
getStatusColor(status) {
switch (status) {
case 'pending': return 'text-yellow-400';
case 'sent': return 'text-blue-300';
case 'delivered': return 'text-blue-200';
case 'failed': return 'text-red-400';
default: return 'text-gray-400';
}
}
};
}

View File

@ -250,10 +250,252 @@
</div>
<!-- Conversations Tab -->
<div x-show="activeTab === 'conversations'" class="p-6">
<div id="conversations-container">
<!-- Enhanced conversations component will be loaded here -->
<include src="conversations_enhanced_component.html"></include>
<div x-show="activeTab === 'conversations'" x-data="conversationData()" class="p-6">
<div class="flex h-[calc(100vh-200px)]">
<!-- Left Panel: Conversation List -->
<div class="w-1/3 border-r border-gray-200 overflow-hidden flex flex-col">
<!-- Search and Controls Header -->
<div class="p-4 border-b border-gray-200 bg-white">
<div class="flex items-center gap-2 mb-3">
<div class="flex-1 relative">
<input
type="text"
x-model="conversationSearch"
@input="searchConversations()"
placeholder="Search conversations..."
class="w-full pl-10 pr-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
>
<svg class="absolute left-3 top-2.5 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
<button
@click="syncAllConversations()"
class="p-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
title="Sync All Conversations"
:disabled="syncing"
>
<svg class="w-4 h-4" :class="{'animate-spin': syncing}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</button>
</div>
<!-- Filter Tabs -->
<div class="flex gap-1">
<button
@click="setFilter('all')"
:class="conversationFilter === 'all' ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 hover:bg-gray-200'"
class="px-3 py-1.5 rounded-full text-xs font-medium transition-colors"
>
All
</button>
<button
@click="setFilter('unread')"
:class="conversationFilter === 'unread' ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 hover:bg-gray-200'"
class="px-3 py-1.5 rounded-full text-xs font-medium transition-colors"
>
Unread
<span x-show="unreadCount > 0" x-text="unreadCount"
class="ml-1 bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5"></span>
</button>
<button
@click="setFilter('starred')"
:class="conversationFilter === 'starred' ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 hover:bg-gray-200'"
class="px-3 py-1.5 rounded-full text-xs font-medium transition-colors"
>
Starred
</button>
</div>
</div>
<!-- Conversation List -->
<div class="flex-1 overflow-y-auto">
<div class="divide-y divide-gray-100">
<template x-for="conversation in filteredConversations" :key="conversation.phone">
<div
@click="selectConversation(conversation.phone)"
:class="selectedConversation?.phone === conversation.phone ? 'bg-blue-50 border-r-2 border-blue-500' : 'hover:bg-gray-50'"
class="p-4 cursor-pointer transition-colors"
>
<div class="flex items-start gap-3">
<!-- Avatar -->
<div class="w-12 h-12 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center flex-shrink-0 text-white font-semibold">
<span x-text="getInitials(conversation)"></span>
</div>
<!-- Conversation Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-1">
<h4 class="font-semibold text-gray-900 truncate text-sm"
x-text="conversation.contact_name || formatPhone(conversation.phone)">
</h4>
<span class="text-xs text-gray-500" x-text="formatTime(conversation.last_message_time)"></span>
</div>
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<p class="text-sm text-gray-600 truncate"
x-text="conversation.message_count + ' messages'"></p>
</div>
<div class="flex items-center gap-1 ml-2">
<!-- Star Icon -->
<svg x-show="conversation.is_starred" class="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
<!-- Unread Badge -->
<span x-show="conversation.unread_count > 0"
x-text="conversation.unread_count"
class="bg-red-500 text-white text-xs rounded-full px-2 py-0.5 min-w-[18px] text-center">
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<!-- Empty State -->
<div x-show="filteredConversations.length === 0" class="p-8 text-center">
<div class="text-gray-400 mb-2">
<svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
</svg>
</div>
<p class="text-gray-500">No conversations found</p>
</div>
</div>
</div>
</div>
<!-- Right Panel: Message View -->
<div class="flex-1 flex flex-col">
<!-- Chat Header -->
<div x-show="selectedConversation" class="border-b border-gray-200 p-4 bg-white">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<!-- Avatar -->
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white font-semibold text-sm">
<span x-text="selectedConversation ? getInitials(selectedConversation) : ''"></span>
</div>
<!-- Contact Info -->
<div>
<h3 class="font-semibold text-gray-900"
x-text="selectedConversation?.contact_name || formatPhone(selectedConversation?.phone)">
</h3>
<p class="text-sm text-gray-500" x-text="formatPhone(selectedConversation?.phone)"></p>
</div>
</div>
<!-- Action Buttons -->
<div class="flex items-center gap-2">
<button
@click="toggleStar(selectedConversation?.phone)"
class="p-2 rounded-full hover:bg-gray-100 transition-colors"
:class="selectedConversation?.is_starred ? 'text-yellow-500' : 'text-gray-400'"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
</button>
<button
@click="syncConversation(selectedConversation?.phone)"
class="p-2 rounded-full hover:bg-gray-100 text-gray-400 transition-colors"
:disabled="syncing"
>
<svg class="w-5 h-5" :class="{'animate-spin': syncing}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</button>
</div>
</div>
</div>
<!-- Messages Area -->
<div class="flex-1 overflow-y-auto p-4 bg-gray-50" x-ref="messagesContainer">
<div x-show="!selectedConversation" class="flex items-center justify-center h-full">
<div class="text-center">
<div class="text-gray-400 mb-4">
<svg class="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Select a conversation</h3>
<p class="text-gray-500">Choose a conversation from the left to start messaging</p>
</div>
</div>
<!-- Message List -->
<div x-show="selectedConversation" class="space-y-4">
<template x-for="message in messages" :key="message.id">
<div :class="message.direction === 'outbound' ? 'flex justify-end' : 'flex justify-start'">
<div :class="message.direction === 'outbound'
? 'bg-blue-500 text-white max-w-xs lg:max-w-md rounded-l-2xl rounded-br-2xl'
: 'bg-white border max-w-xs lg:max-w-md rounded-r-2xl rounded-bl-2xl'"
class="px-4 py-2 shadow-sm">
<p class="text-sm" x-text="message.message"></p>
<div class="flex items-center justify-between mt-1">
<span :class="message.direction === 'outbound' ? 'text-blue-100' : 'text-gray-500'"
class="text-xs" x-text="formatMessageTime(message.sent_at || message.timestamp)">
</span>
<span x-show="message.direction === 'outbound'"
:class="getStatusColor(message.status)"
class="text-xs ml-2" x-text="getStatusIcon(message.status)">
</span>
</div>
</div>
</div>
</template>
<!-- Load More Messages Button -->
<div x-show="hasMoreMessages && !loadingMessages" class="text-center py-2">
<button
@click="loadMoreMessages()"
class="text-blue-500 hover:text-blue-600 text-sm font-medium"
>
Load older messages
</button>
</div>
<!-- Loading Messages -->
<div x-show="loadingMessages" class="text-center py-2">
<span class="text-gray-500 text-sm">Loading messages...</span>
</div>
</div>
</div>
<!-- Message Input -->
<div x-show="selectedConversation" class="border-t border-gray-200 p-4 bg-white">
<div class="flex items-end gap-2">
<div class="flex-1">
<textarea
x-model="newMessage"
@keydown.enter.prevent="sendMessage()"
@keydown.shift.enter="newMessage += '\n'"
placeholder="Type a message..."
class="w-full resize-none border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
rows="1"
x-ref="messageInput"
></textarea>
</div>
<button
@click="sendMessage()"
:disabled="!newMessage.trim() || sendingMessage"
class="p-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
@ -603,6 +845,7 @@
<script src="/static/js/dashboard.js?v=2025082505"></script>
<script src="/static/js/lists.js?v=2025082505"></script>
<script src="/static/js/conversations_enhanced.js?v=2025082505"></script>
<script src="/static/js/rcs-gap-detector.js?v=2025082505"></script>
<script>
// Initialize phone IP from template
document.addEventListener('alpine:init', () => {

View File

@ -1,4 +0,0 @@
phone,message,name
7802921731,hi {name} just testing,Reed
,,
,,
1 phone message name
2 7802921731 hi {name} just testing Reed
3
4