Dealix OS Frontend implementation v1

This commit is contained in:
Sami Assiri 2026-03-31 19:53:49 +03:00
parent 1e3086d220
commit 1744cb7134
49 changed files with 7227 additions and 540 deletions

View File

@ -1,42 +1,114 @@
# Database
# ═══════════════════════════════════════════════
# Dealix — AI Revenue Operating System
# Environment Configuration
# ═══════════════════════════════════════════════
# ── App ───────────────────────────────────────
APP_NAME=Dealix
APP_NAME_AR=ديل اي اكس
ENVIRONMENT=development
DEFAULT_TIMEZONE=Asia/Riyadh
DEFAULT_CURRENCY=SAR
DEFAULT_LOCALE=ar
# ── Database ──────────────────────────────────
DB_NAME=salesflow
DB_USER=salesflow
DB_PASSWORD=change_me_in_production
DATABASE_URL=postgresql+asyncpg://salesflow:change_me_in_production@db:5432/salesflow
DB_POOL_SIZE=20
DB_MAX_OVERFLOW=10
# Redis
# ── Redis ─────────────────────────────────────
REDIS_URL=redis://redis:6379/0
REDIS_CACHE_TTL=3600
# Security
# ── Security ──────────────────────────────────
SECRET_KEY=change-this-to-a-random-secret-key-in-production
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=7
# API
# ── URLs ──────────────────────────────────────
API_URL=http://localhost:8000
FRONTEND_URL=http://localhost:3000
WEBHOOK_BASE_URL=http://localhost:8000/api/v1/webhooks
# WhatsApp Business API
# ── LLM Providers (AI Engine) ─────────────────
# Primary: OpenAI
OPENAI_API_KEY=sk-your-openai-key
OPENAI_MODEL=gpt-4o
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
OPENAI_MAX_TOKENS=4096
OPENAI_TEMPERATURE=0.7
# Secondary: Groq (fast inference)
GROQ_API_KEY=gsk_your-groq-key
GROQ_MODEL=llama-3.3-70b-versatile
GROQ_MAX_TOKENS=4096
# Fallback: Ollama (local)
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=qwen2.5:7b
# LLM Strategy
LLM_PRIMARY_PROVIDER=openai
LLM_FALLBACK_PROVIDER=groq
LLM_CACHE_ENABLED=true
LLM_RATE_LIMIT_RPM=60
# ── WhatsApp Business API ─────────────────────
WHATSAPP_API_TOKEN=
WHATSAPP_PHONE_NUMBER_ID=
WHATSAPP_BUSINESS_ACCOUNT_ID=
WHATSAPP_VERIFY_TOKEN=
WHATSAPP_API_VERSION=v21.0
# Email (SendGrid or SMTP)
# ── Email ─────────────────────────────────────
EMAIL_PROVIDER=smtp
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SENDGRID_API_KEY=
EMAIL_FROM_NAME=Dealix
EMAIL_FROM_ADDRESS=noreply@dealix.sa
# SMS (Unifonic - Saudi)
# ── SMS (Unifonic - Saudi) ────────────────────
UNIFONIC_APP_SID=
UNIFONIC_SENDER_ID=Dealix
# App Settings
APP_NAME=Dealix
APP_NAME_AR=ديل اي اكس
DEFAULT_TIMEZONE=Asia/Riyadh
DEFAULT_CURRENCY=SAR
DEFAULT_LOCALE=ar
# ── Voice AI ──────────────────────────────────
VOICE_PROVIDER=elevenlabs
ELEVENLABS_API_KEY=
ELEVENLABS_VOICE_ID=
AZURE_SPEECH_KEY=
AZURE_SPEECH_REGION=uaenorth
# ── CRM Integrations ─────────────────────────
# Salesforce
SALESFORCE_CLIENT_ID=
SALESFORCE_CLIENT_SECRET=
SALESFORCE_REDIRECT_URI=
# HubSpot
HUBSPOT_CLIENT_ID=
HUBSPOT_CLIENT_SECRET=
HUBSPOT_API_KEY=
# ── Calendar ──────────────────────────────────
GOOGLE_CALENDAR_CREDENTIALS=
MICROSOFT_CLIENT_ID=
MICROSOFT_CLIENT_SECRET=
# ── Payment (Moyasar - Saudi) ─────────────────
PAYMENT_PROVIDER=moyasar
MOYASAR_API_KEY=
MOYASAR_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_PUBLISHABLE_KEY=
# ── Agent Configuration ───────────────────────
AGENT_PROMPTS_DIR=ai-agents/prompts
AGENT_MAX_CONCURRENT=10
AGENT_DEFAULT_TIMEOUT=60
AGENT_ESCALATION_ENABLED=true

View File

@ -0,0 +1,18 @@
"""
Dealix AI Engine
Core AI infrastructure: LLM providers, agent execution, orchestration.
"""
from app.ai.llm_provider import LLMProvider
from app.ai.agent_executor import AgentExecutor
from app.ai.agent_router import AgentRouter
from app.ai.orchestrator import Orchestrator
from app.ai.saudi_dialect import SaudiDialectProcessor
__all__ = [
"LLMProvider",
"AgentExecutor",
"AgentRouter",
"Orchestrator",
"SaudiDialectProcessor",
]

View File

@ -0,0 +1,402 @@
"""
Agent Executor Loads agent configs/prompts and executes them via LLM.
Each of the 18 agents is defined in ai-agents/prompts/ with a .md prompt file.
"""
import json
import os
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.ai.llm_provider import LLMProvider
from app.config import get_settings
settings = get_settings()
class AgentExecutor:
"""
Executes any of the 18 Dealix AI agents.
Each agent has:
- A system prompt (from ai-agents/prompts/*.md)
- An optional config (from ai-agents/*/config.yml or *.json)
- Input/output schema validation
- Escalation rules
- Logging to ai_conversations table
"""
AGENT_REGISTRY = {
"lead_qualification": {
"prompt_file": "lead-qualification-agent.md",
"description": "Score and qualify inbound leads",
"model_preference": "openai", # needs high quality
},
"affiliate_evaluator": {
"prompt_file": "affiliate-recruitment-evaluator.md",
"description": "Evaluate affiliate applications",
"model_preference": "openai",
},
"onboarding_coach": {
"prompt_file": "affiliate-onboarding-coach.md",
"description": "Guide new affiliates through onboarding",
"model_preference": "groq", # speed matters
},
"outreach_writer": {
"prompt_file": "outreach-message-writer.md",
"description": "Draft personalized outreach messages",
"model_preference": "openai",
},
"arabic_whatsapp": {
"prompt_file": "arabic-whatsapp-agent.md",
"description": "Handle Arabic WhatsApp conversations",
"model_preference": "openai",
},
"english_conversation": {
"prompt_file": "english-conversation-agent.md",
"description": "Handle English conversations",
"model_preference": "groq",
},
"voice_call": {
"prompt_file": "voice-call-flow-agent.md",
"description": "Analyze voice call transcripts",
"model_preference": "openai",
},
"meeting_booking": {
"prompt_file": "meeting-booking-agent.md",
"description": "Negotiate and book meetings",
"model_preference": "openai",
},
"sector_strategist": {
"prompt_file": "sector-sales-strategist.md",
"description": "Generate sector-specific strategies",
"model_preference": "openai",
},
"objection_handler": {
"prompt_file": "objection-handling-agent.md",
"description": "Handle customer objections",
"model_preference": "openai",
},
"proposal_drafter": {
"prompt_file": "proposal-drafting-agent.md",
"description": "Generate proposals and pitch decks",
"model_preference": "openai",
},
"qa_reviewer": {
"prompt_file": "conversation-qa-reviewer.md",
"description": "Review AI content quality",
"model_preference": "groq",
},
"compliance_reviewer": {
"prompt_file": "compliance-reviewer.md",
"description": "Check regulatory compliance",
"model_preference": "openai",
},
"knowledge_retrieval": {
"prompt_file": "knowledge-retrieval-agent.md",
"description": "Search knowledge base (RAG)",
"model_preference": "groq",
},
"revenue_attribution": {
"prompt_file": "revenue-attribution-agent.md",
"description": "Attribute revenue to sources",
"model_preference": "openai",
},
"fraud_reviewer": {
"prompt_file": "fraud-reviewer.md",
"description": "Detect fraudulent patterns",
"model_preference": "openai",
},
"guarantee_reviewer": {
"prompt_file": "guarantee-claim-reviewer.md",
"description": "Evaluate guarantee claims",
"model_preference": "openai",
},
"management_summary": {
"prompt_file": "management-summary-agent.md",
"description": "Generate executive summaries",
"model_preference": "openai",
},
}
def __init__(self, db: AsyncSession = None, llm: LLMProvider = None):
self.db = db
self.llm = llm or LLMProvider()
self._prompts_dir = Path(settings.AGENT_PROMPTS_DIR)
# ── Execute Agent ─────────────────────────────
async def execute(
self,
agent_type: str,
input_data: dict,
tenant_id: str = None,
lead_id: str = None,
contact_id: str = None,
conversation_history: list = None,
override_prompt: str = None,
json_mode: bool = True,
) -> dict:
"""
Execute an AI agent and return structured results.
Args:
agent_type: One of the 18 registered agent types
input_data: Context data for the agent
tenant_id: Tenant scope
lead_id: Optional lead association
contact_id: Optional contact association
conversation_history: Previous messages for context
override_prompt: Override the default system prompt
json_mode: Request JSON output from LLM
Returns:
{
"agent_type": "lead_qualification",
"output": { ... structured response ... },
"raw_content": "...",
"tokens": { ... },
"latency_ms": 1234,
"escalation": { "needed": False },
"conversation_id": "uuid"
}
"""
if agent_type not in self.AGENT_REGISTRY:
raise ValueError(f"Unknown agent type: {agent_type}. Available: {list(self.AGENT_REGISTRY.keys())}")
agent_config = self.AGENT_REGISTRY[agent_type]
start = time.time()
# Load system prompt
system_prompt = override_prompt or self._load_prompt(agent_config["prompt_file"])
if not system_prompt:
raise FileNotFoundError(f"Prompt file not found: {agent_config['prompt_file']}")
# Build user message from input data
user_message = self._format_input(agent_type, input_data)
# Call LLM
response = await self.llm.chat(
system_prompt=system_prompt,
user_message=user_message,
provider=agent_config.get("model_preference"),
json_mode=json_mode,
history=conversation_history,
)
# Parse output
output = self._parse_output(response["content"], json_mode)
# Check escalation rules
escalation = self._check_escalation(agent_type, output, input_data)
total_latency = int((time.time() - start) * 1000)
# Log to database
conversation_id = None
if self.db and tenant_id:
conversation_id = await self._log_conversation(
tenant_id=tenant_id,
agent_type=agent_type,
lead_id=lead_id,
contact_id=contact_id,
input_payload=input_data,
output_payload=output,
tokens=response.get("tokens", {}),
latency=total_latency,
status="escalated" if escalation.get("needed") else "success",
)
return {
"agent_type": agent_type,
"output": output,
"raw_content": response["content"],
"provider": response.get("provider"),
"model": response.get("model"),
"tokens": response.get("tokens", {}),
"latency_ms": total_latency,
"escalation": escalation,
"conversation_id": conversation_id,
"cached": response.get("cached", False),
}
# ── Prompt Loading ────────────────────────────
def _load_prompt(self, filename: str) -> Optional[str]:
"""Load agent prompt from file system."""
# Try multiple possible locations
paths = [
self._prompts_dir / filename,
Path("ai-agents") / "prompts" / filename,
Path("../ai-agents") / "prompts" / filename,
]
for path in paths:
if path.exists():
return path.read_text(encoding="utf-8")
return None
def get_available_agents(self) -> list:
"""List all available agents and their descriptions."""
return [
{
"type": agent_type,
"description": config["description"],
"prompt_file": config["prompt_file"],
"model_preference": config.get("model_preference", "openai"),
}
for agent_type, config in self.AGENT_REGISTRY.items()
]
# ── Input Formatting ──────────────────────────
def _format_input(self, agent_type: str, data: dict) -> str:
"""Format input data into a structured prompt for the agent."""
parts = [f"## Agent Request: {agent_type}\n"]
parts.append(f"**Timestamp:** {datetime.now(timezone.utc).isoformat()}\n")
if "lead" in data:
lead = data["lead"]
parts.append("### Lead Information")
for k, v in lead.items():
if v:
parts.append(f"- **{k}:** {v}")
if "conversation" in data:
parts.append("\n### Conversation History")
for msg in data["conversation"]:
role = msg.get("role", "unknown")
content = msg.get("content", "")
parts.append(f"- [{role}]: {content}")
if "context" in data:
parts.append("\n### Additional Context")
for k, v in data["context"].items():
parts.append(f"- **{k}:** {v}")
# Add any remaining top-level data
skip_keys = {"lead", "conversation", "context"}
remaining = {k: v for k, v in data.items() if k not in skip_keys and v}
if remaining:
parts.append("\n### Request Data")
parts.append(json.dumps(remaining, ensure_ascii=False, indent=2))
parts.append("\n---\nPlease respond with a structured JSON output.")
return "\n".join(parts)
# ── Output Parsing ────────────────────────────
@staticmethod
def _parse_output(content: str, json_mode: bool) -> dict:
"""Parse LLM response into structured data."""
if json_mode:
try:
return json.loads(content)
except json.JSONDecodeError:
# Try to extract JSON from markdown code blocks
if "```json" in content:
json_str = content.split("```json")[1].split("```")[0].strip()
try:
return json.loads(json_str)
except json.JSONDecodeError:
pass
elif "```" in content:
json_str = content.split("```")[1].split("```")[0].strip()
try:
return json.loads(json_str)
except json.JSONDecodeError:
pass
return {"raw_response": content}
# ── Escalation Rules ──────────────────────────
def _check_escalation(self, agent_type: str, output: dict, input_data: dict) -> dict:
"""Check if the agent output triggers escalation rules."""
escalation = {"needed": False, "reason": None, "target": None}
if agent_type == "lead_qualification":
score = output.get("qualification_score", output.get("score", 50))
if isinstance(score, (int, float)) and 40 <= score <= 60:
escalation = {
"needed": True,
"reason": "Ambiguous qualification score (40-60 range)",
"target": "human_review",
}
elif agent_type == "arabic_whatsapp":
sentiment = output.get("sentiment", "")
if sentiment == "negative":
escalation = {
"needed": True,
"reason": "Negative sentiment detected in conversation",
"target": "human_agent",
}
elif agent_type == "compliance_reviewer":
status = output.get("compliance_status", "")
if status == "non_compliant":
escalation = {
"needed": True,
"reason": "Compliance violation detected",
"target": "compliance_officer",
}
elif agent_type == "fraud_reviewer":
risk_score = output.get("risk_score", 0)
if isinstance(risk_score, (int, float)) and risk_score > 80:
escalation = {
"needed": True,
"reason": f"High fraud risk score: {risk_score}",
"target": "admin",
}
elif agent_type == "guarantee_reviewer":
amount = output.get("amount_claimed", 0)
if isinstance(amount, (int, float)) and amount > 50000:
escalation = {
"needed": True,
"reason": f"High-value guarantee claim: {amount} SAR",
"target": "director",
}
return escalation
# ── Database Logging ──────────────────────────
async def _log_conversation(
self,
tenant_id: str,
agent_type: str,
lead_id: str = None,
contact_id: str = None,
input_payload: dict = None,
output_payload: dict = None,
tokens: dict = None,
latency: int = 0,
status: str = "success",
) -> str:
from app.models.ai_conversation import AIConversation
conv = AIConversation(
id=uuid.uuid4(),
tenant_id=uuid.UUID(tenant_id),
agent_type=agent_type,
lead_id=uuid.UUID(lead_id) if lead_id else None,
contact_id=uuid.UUID(contact_id) if contact_id else None,
input_payload=input_payload or {},
output_payload=output_payload or {},
tokens_used=tokens.get("total", 0) if tokens else 0,
latency_ms=latency,
status=status,
)
self.db.add(conv)
await self.db.flush()
return str(conv.id)

View File

@ -0,0 +1,192 @@
"""
Agent Router Maps events to the correct agent(s) and handles multi-agent chaining.
"""
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.ai.agent_executor import AgentExecutor
from app.ai.llm_provider import LLMProvider
# Event type → Agent(s) mapping
EVENT_AGENT_MAP = {
# Lead events
"lead.created": ["lead_qualification"],
"lead.qualified": ["outreach_writer"],
"lead.contacted": ["arabic_whatsapp"],
"lead.replied": ["arabic_whatsapp", "objection_handler"],
"lead.meeting_ready": ["meeting_booking"],
# Message events
"message.inbound.whatsapp.ar": ["arabic_whatsapp"],
"message.inbound.whatsapp.en": ["english_conversation"],
"message.inbound.email": ["english_conversation"],
"message.objection_detected": ["objection_handler"],
# Call events
"call.completed": ["voice_call"],
"call.transcript_ready": ["voice_call"],
# Meeting events
"meeting.requested": ["meeting_booking"],
"meeting.confirmed": ["proposal_drafter"],
"meeting.prep_needed": ["sector_strategist", "proposal_drafter"],
# Deal events
"deal.created": ["sector_strategist"],
"deal.proposal_needed": ["proposal_drafter"],
"deal.closed_won": ["revenue_attribution", "management_summary"],
"deal.closed_lost": ["management_summary"],
# Affiliate events
"affiliate.applied": ["affiliate_evaluator"],
"affiliate.approved": ["onboarding_coach"],
"affiliate.fraud_suspected": ["fraud_reviewer"],
# Compliance events
"content.review_needed": ["qa_reviewer"],
"compliance.check_needed": ["compliance_reviewer"],
# Guarantee events
"guarantee.claimed": ["guarantee_reviewer"],
# Knowledge events
"knowledge.query": ["knowledge_retrieval"],
# Reporting events
"report.daily": ["management_summary"],
"report.weekly": ["management_summary"],
}
class AgentRouter:
"""
Routes incoming events to the appropriate AI agent(s).
Supports single-agent, multi-agent, and chained execution.
"""
def __init__(self, db: AsyncSession, llm: LLMProvider = None):
self.db = db
self.llm = llm or LLMProvider()
self.executor = AgentExecutor(db=db, llm=self.llm)
async def route(
self,
event_type: str,
event_data: dict,
tenant_id: str,
lead_id: str = None,
contact_id: str = None,
) -> dict:
"""
Route an event to the appropriate agent(s).
Returns:
{
"event": "lead.created",
"agents_invoked": ["lead_qualification"],
"results": [ { ...agent output... } ],
"escalations": [ ... ],
}
"""
agents = EVENT_AGENT_MAP.get(event_type, [])
if not agents:
return {
"event": event_type,
"agents_invoked": [],
"results": [],
"error": f"No agent mapped for event: {event_type}",
}
results = []
escalations = []
for agent_type in agents:
try:
result = await self.executor.execute(
agent_type=agent_type,
input_data=event_data,
tenant_id=tenant_id,
lead_id=lead_id,
contact_id=contact_id,
)
results.append(result)
if result.get("escalation", {}).get("needed"):
escalations.append({
"agent": agent_type,
"reason": result["escalation"]["reason"],
"target": result["escalation"]["target"],
})
except Exception as e:
results.append({
"agent_type": agent_type,
"error": str(e),
"status": "failed",
})
return {
"event": event_type,
"agents_invoked": agents,
"results": results,
"escalations": escalations,
}
async def chain(
self,
agent_sequence: list,
initial_data: dict,
tenant_id: str,
lead_id: str = None,
) -> dict:
"""
Execute agents in sequence, passing output of each to the next.
Example chain: ["lead_qualification", "outreach_writer", "meeting_booking"]
"""
chain_results = []
current_data = initial_data.copy()
for agent_type in agent_sequence:
try:
result = await self.executor.execute(
agent_type=agent_type,
input_data=current_data,
tenant_id=tenant_id,
lead_id=lead_id,
)
chain_results.append(result)
# Pass output to next agent as context
if result.get("output"):
current_data["previous_agent"] = agent_type
current_data["previous_output"] = result["output"]
# Stop chain if escalation is needed
if result.get("escalation", {}).get("needed"):
break
except Exception as e:
chain_results.append({
"agent_type": agent_type,
"error": str(e),
"status": "failed",
})
break
return {
"chain": agent_sequence,
"completed": len(chain_results),
"results": chain_results,
}
def get_event_types(self) -> list:
"""List all supported event types."""
return list(EVENT_AGENT_MAP.keys())
def get_agents_for_event(self, event_type: str) -> list:
"""Get agents mapped to an event type."""
return EVENT_AGENT_MAP.get(event_type, [])

View File

@ -0,0 +1,288 @@
"""
LLM Provider Unified interface for OpenAI, Groq, and Ollama.
Handles failover, caching, rate limiting, and token tracking.
"""
import asyncio
import hashlib
import json
import time
from typing import Optional
import httpx
from openai import AsyncOpenAI
from app.config import get_settings
settings = get_settings()
class LLMProvider:
"""
Unified LLM gateway supporting multiple providers with automatic failover.
Usage:
llm = LLMProvider()
response = await llm.chat("You are a sales agent.", "Hello, tell me about your services.")
embedding = await llm.embed("Some text to vectorize")
"""
def __init__(self):
self._openai = None
self._groq = None
self._cache = {}
self._token_usage = {"prompt": 0, "completion": 0, "total": 0}
self._request_count = 0
self._last_request_time = 0
# ── Properties ────────────────────────────────
@property
def openai_client(self) -> AsyncOpenAI:
if not self._openai and settings.OPENAI_API_KEY:
self._openai = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
return self._openai
@property
def groq_client(self) -> AsyncOpenAI:
if not self._groq and settings.GROQ_API_KEY:
self._groq = AsyncOpenAI(
api_key=settings.GROQ_API_KEY,
base_url="https://api.groq.com/openai/v1",
)
return self._groq
# ── Main Chat Interface ───────────────────────
async def chat(
self,
system_prompt: str,
user_message: str,
model: str = None,
provider: str = None,
temperature: float = None,
max_tokens: int = None,
json_mode: bool = False,
history: list = None,
) -> dict:
"""
Send a chat completion request with automatic failover.
Returns:
{
"content": "The AI response text",
"provider": "openai",
"model": "gpt-4o",
"tokens": {"prompt": 100, "completion": 50, "total": 150},
"latency_ms": 1234,
"cached": False
}
"""
# Check cache
if settings.LLM_CACHE_ENABLED:
cache_key = self._cache_key(system_prompt, user_message, model)
cached = self._get_cached(cache_key)
if cached:
return {**cached, "cached": True}
# Rate limiting
await self._rate_limit()
# Build messages
messages = [{"role": "system", "content": system_prompt}]
if history:
messages.extend(history)
messages.append({"role": "user", "content": user_message})
# Try primary provider, then fallback
primary = provider or settings.LLM_PRIMARY_PROVIDER
fallback = settings.LLM_FALLBACK_PROVIDER
for attempt_provider in [primary, fallback]:
try:
result = await self._call_provider(
provider=attempt_provider,
messages=messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
json_mode=json_mode,
)
# Cache result
if settings.LLM_CACHE_ENABLED:
self._set_cached(cache_key, result)
return result
except Exception as e:
if attempt_provider == fallback:
# Both failed, try Ollama as last resort
try:
return await self._call_ollama(messages, temperature, max_tokens)
except Exception:
raise RuntimeError(
f"All LLM providers failed. Last error: {str(e)}"
)
# ── Embedding ─────────────────────────────────
async def embed(self, text: str, model: str = None) -> list:
"""Generate embeddings using OpenAI's embedding model."""
if not self.openai_client:
raise RuntimeError("OpenAI API key not configured for embeddings")
response = await self.openai_client.embeddings.create(
model=model or settings.OPENAI_EMBEDDING_MODEL,
input=text,
)
return response.data[0].embedding
async def embed_batch(self, texts: list, model: str = None) -> list:
"""Generate embeddings for multiple texts."""
if not self.openai_client:
raise RuntimeError("OpenAI API key not configured for embeddings")
response = await self.openai_client.embeddings.create(
model=model or settings.OPENAI_EMBEDDING_MODEL,
input=texts,
)
return [item.embedding for item in response.data]
# ── Provider Implementations ──────────────────
async def _call_provider(
self,
provider: str,
messages: list,
model: str = None,
temperature: float = None,
max_tokens: int = None,
json_mode: bool = False,
) -> dict:
start = time.time()
if provider == "openai":
client = self.openai_client
model = model or settings.OPENAI_MODEL
temp = temperature if temperature is not None else settings.OPENAI_TEMPERATURE
tokens = max_tokens or settings.OPENAI_MAX_TOKENS
elif provider == "groq":
client = self.groq_client
model = model or settings.GROQ_MODEL
temp = temperature if temperature is not None else 0.7
tokens = max_tokens or settings.GROQ_MAX_TOKENS
else:
return await self._call_ollama(messages, temperature, max_tokens)
if not client:
raise RuntimeError(f"Provider {provider} not configured")
kwargs = {
"model": model,
"messages": messages,
"temperature": temp,
"max_tokens": tokens,
}
if json_mode:
kwargs["response_format"] = {"type": "json_object"}
response = await client.chat.completions.create(**kwargs)
latency = int((time.time() - start) * 1000)
usage = response.usage
self._token_usage["prompt"] += usage.prompt_tokens
self._token_usage["completion"] += usage.completion_tokens
self._token_usage["total"] += usage.total_tokens
self._request_count += 1
return {
"content": response.choices[0].message.content,
"provider": provider,
"model": model,
"tokens": {
"prompt": usage.prompt_tokens,
"completion": usage.completion_tokens,
"total": usage.total_tokens,
},
"latency_ms": latency,
"cached": False,
}
async def _call_ollama(
self,
messages: list,
temperature: float = None,
max_tokens: int = None,
) -> dict:
start = time.time()
async with httpx.AsyncClient(timeout=120) as client:
response = await client.post(
f"{settings.OLLAMA_BASE_URL}/api/chat",
json={
"model": settings.OLLAMA_MODEL,
"messages": messages,
"stream": False,
"options": {
"temperature": temperature or 0.7,
"num_predict": max_tokens or 2048,
},
},
)
response.raise_for_status()
data = response.json()
latency = int((time.time() - start) * 1000)
return {
"content": data.get("message", {}).get("content", ""),
"provider": "ollama",
"model": settings.OLLAMA_MODEL,
"tokens": {
"prompt": data.get("prompt_eval_count", 0),
"completion": data.get("eval_count", 0),
"total": data.get("prompt_eval_count", 0) + data.get("eval_count", 0),
},
"latency_ms": latency,
"cached": False,
}
# ── Rate Limiting ─────────────────────────────
async def _rate_limit(self):
now = time.time()
if now - self._last_request_time < 60 / settings.LLM_RATE_LIMIT_RPM:
await asyncio.sleep(60 / settings.LLM_RATE_LIMIT_RPM)
self._last_request_time = time.time()
# ── Caching ───────────────────────────────────
@staticmethod
def _cache_key(system: str, user: str, model: str = None) -> str:
raw = f"{system}:{user}:{model or ''}"
return hashlib.sha256(raw.encode()).hexdigest()
def _get_cached(self, key: str) -> Optional[dict]:
if key in self._cache:
entry = self._cache[key]
if time.time() - entry["time"] < settings.LLM_CACHE_TTL:
return entry["data"]
del self._cache[key]
return None
def _set_cached(self, key: str, data: dict):
self._cache[key] = {"data": data, "time": time.time()}
# Evict old entries
if len(self._cache) > 1000:
oldest = sorted(self._cache.items(), key=lambda x: x[1]["time"])
for k, _ in oldest[:100]:
del self._cache[k]
# ── Stats ─────────────────────────────────────
def get_usage_stats(self) -> dict:
return {
"token_usage": self._token_usage.copy(),
"request_count": self._request_count,
"cache_entries": len(self._cache),
}

View File

@ -0,0 +1,344 @@
"""
Orchestrator THE BRAIN of Dealix.
Controls the full lead lifecycle: Lead Qualify Nurture Book Close.
Decides when to use which agent, when to escalate to humans, and when to move stages.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.ai.agent_router import AgentRouter
from app.ai.llm_provider import LLMProvider
from app.services.lead_service import LeadService
from app.services.deal_service import DealService
from app.services.meeting_service import MeetingService
from app.services.notification_service import NotificationService
from app.services.trust_score_service import TrustScoreService
# Lead lifecycle state machine
LEAD_STATES = {
"new": {
"actions": ["qualify", "enrich"],
"next_states": ["contacted", "lost"],
"auto_agent": "lead_qualification",
},
"contacted": {
"actions": ["nurture", "follow_up", "qualify"],
"next_states": ["qualified", "lost"],
"auto_agent": "outreach_writer",
},
"qualified": {
"actions": ["book_meeting", "send_proposal"],
"next_states": ["converted", "contacted", "lost"],
"auto_agent": "meeting_booking",
},
"converted": {
"actions": ["create_deal", "prepare_presentation"],
"next_states": [],
"auto_agent": None,
},
"lost": {
"actions": ["re_engage"],
"next_states": ["new"],
"auto_agent": None,
},
}
class Orchestrator:
"""
Central orchestration engine that automates the Lead-to-Meeting pipeline.
The Orchestrator:
1. Receives events (new lead, message, call, etc.)
2. Determines the current state of the lead
3. Decides which agent(s) to invoke
4. Executes the appropriate action
5. Moves the lead to the next state
6. Notifies humans when needed
"""
def __init__(self, db: AsyncSession, llm: LLMProvider = None):
self.db = db
self.llm = llm or LLMProvider()
self.router = AgentRouter(db=db, llm=self.llm)
self.leads = LeadService(db)
self.deals = DealService(db)
self.meetings = MeetingService(db)
self.notifications = NotificationService(db)
self.trust_scores = TrustScoreService(db)
# ── Process New Lead ──────────────────────────
async def process_new_lead(self, tenant_id: str, lead_id: str) -> dict:
"""
Full automated pipeline for a new lead:
1. Calculate trust score
2. AI qualification
3. If qualified auto-assign + outreach
4. If hot book meeting immediately
"""
actions_taken = []
# Step 1: Trust Score
trust = await self.trust_scores.calculate_lead_score(tenant_id, lead_id)
actions_taken.append({"action": "trust_score", "result": trust})
lead = await self.leads.get_lead(tenant_id, lead_id)
if not lead:
return {"error": "Lead not found", "actions": actions_taken}
# Step 2: AI Qualification
qual_result = await self.router.route(
event_type="lead.created",
event_data={
"lead": lead,
"trust_score": trust,
},
tenant_id=tenant_id,
lead_id=lead_id,
)
actions_taken.append({"action": "ai_qualification", "result": qual_result})
# Extract score from AI response
ai_score = 50 # default
if qual_result.get("results"):
output = qual_result["results"][0].get("output", {})
ai_score = output.get("qualification_score", output.get("score", 50))
if isinstance(ai_score, str):
try:
ai_score = int(ai_score)
except (ValueError, TypeError):
ai_score = 50
# Step 3: Update lead
await self.leads.qualify_lead(tenant_id, lead_id, ai_score)
# Step 4: Auto-assign
if ai_score >= 40:
assign_result = await self.leads.auto_assign_round_robin(tenant_id, lead_id)
actions_taken.append({"action": "auto_assign", "result": assign_result})
if assign_result and assign_result.get("assigned_to"):
await self.notifications.notify_new_lead(
tenant_id, assign_result["assigned_to"], lead["full_name"]
)
# Step 5: Hot lead → immediate meeting booking attempt
if ai_score >= 80 and trust.get("trust_score", 0) >= 60:
outreach = await self.router.route(
event_type="lead.meeting_ready",
event_data={"lead": lead, "score": ai_score},
tenant_id=tenant_id,
lead_id=lead_id,
)
actions_taken.append({"action": "meeting_booking_attempt", "result": outreach})
# Step 6: Warm lead → nurture sequence
elif ai_score >= 40:
nurture = await self.router.route(
event_type="lead.qualified",
event_data={"lead": lead, "score": ai_score},
tenant_id=tenant_id,
lead_id=lead_id,
)
actions_taken.append({"action": "nurture_outreach", "result": nurture})
return {
"lead_id": lead_id,
"trust_score": trust.get("trust_score", 0),
"ai_score": ai_score,
"classification": trust.get("classification", "cold"),
"actions_taken": actions_taken,
"next_state": LEAD_STATES.get(lead.get("status", "new"), {}),
}
# ── Handle Inbound Message ────────────────────
async def handle_inbound_message(
self,
tenant_id: str,
lead_id: str,
message: str,
channel: str = "whatsapp",
language: str = "ar",
) -> dict:
"""
Process an inbound message from a lead:
1. Detect language and intent
2. Route to appropriate conversation agent
3. Check for buying signals
4. Auto-escalate if needed
"""
lead = await self.leads.get_lead(tenant_id, lead_id)
if not lead:
return {"error": "Lead not found"}
# Determine event type based on language and channel
if language == "ar":
event_type = "message.inbound.whatsapp.ar"
else:
event_type = "message.inbound.whatsapp.en"
# Execute conversation agent
result = await self.router.route(
event_type=event_type,
event_data={
"lead": lead,
"message": message,
"channel": channel,
"language": language,
},
tenant_id=tenant_id,
lead_id=lead_id,
)
# Check for meeting readiness in response
if result.get("results"):
output = result["results"][0].get("output", {})
intent = output.get("intent", output.get("detected_intent", ""))
if intent in ["book_meeting", "schedule", "meeting", "demo"]:
# Trigger meeting booking
booking = await self.router.route(
event_type="meeting.requested",
event_data={"lead": lead, "conversation_output": output},
tenant_id=tenant_id,
lead_id=lead_id,
)
result["meeting_booking"] = booking
elif intent in ["pricing", "quote", "proposal"]:
# Trigger proposal generation
proposal = await self.router.route(
event_type="deal.proposal_needed",
event_data={"lead": lead, "conversation_output": output},
tenant_id=tenant_id,
lead_id=lead_id,
)
result["proposal"] = proposal
# Handle escalations
if result.get("escalations"):
for esc in result["escalations"]:
if lead.get("assigned_to"):
await self.notifications.notify_escalation(
tenant_id,
lead["assigned_to"],
f"تصعيد من {lead['full_name']}: {esc['reason']}",
)
return result
# ── Process Deal Stage Change ─────────────────
async def process_deal_update(
self, tenant_id: str, deal_id: str, new_stage: str
) -> dict:
"""Handle deal stage transitions with automated actions."""
deal = await self.deals.get_deal(tenant_id, deal_id)
if not deal:
return {"error": "Deal not found"}
actions = []
if new_stage == "proposal":
# Auto-generate proposal
result = await self.router.route(
event_type="deal.proposal_needed",
event_data={"deal": deal},
tenant_id=tenant_id,
)
actions.append({"action": "generate_proposal", "result": result})
elif new_stage == "closed_won":
# Revenue attribution + commission
result = await self.router.route(
event_type="deal.closed_won",
event_data={"deal": deal},
tenant_id=tenant_id,
)
actions.append({"action": "revenue_attribution", "result": result})
# Notify
if deal.get("assigned_to"):
await self.notifications.notify_deal_won(
tenant_id,
deal["assigned_to"],
deal["title"],
deal.get("value", 0),
)
await self.deals.move_stage(tenant_id, deal_id, new_stage)
return {"deal_id": deal_id, "new_stage": new_stage, "actions": actions}
# ── Prepare Meeting ───────────────────────────
async def prepare_meeting(self, tenant_id: str, meeting_id: str) -> dict:
"""
AI-powered meeting preparation:
1. Company research
2. Sector strategy
3. Talking points
4. Predicted objections
5. Recommended presentation
"""
package = await self.meetings.prepare_meeting_package(tenant_id, meeting_id)
if not package or not package.get("lead"):
return {"error": "Meeting or lead not found"}
lead = package["lead"]
# Get sector strategy
strategy = await self.router.route(
event_type="meeting.prep_needed",
event_data={
"lead": lead,
"meeting": package,
},
tenant_id=tenant_id,
lead_id=lead.get("id"),
)
package["ai_preparation"] = strategy
package["status"] = "ready"
return package
# ── Daily Automation ──────────────────────────
async def run_daily_automation(self, tenant_id: str) -> dict:
"""
Daily automated tasks:
1. Score unscored leads
2. Follow up on stale leads
3. Remind about upcoming meetings
4. Generate management summary
"""
results = {}
# Score all unscored leads
score_result = await self.trust_scores.score_all_leads(tenant_id)
results["scoring"] = score_result
# Generate daily summary
summary = await self.router.route(
event_type="report.daily",
event_data={"tenant_id": tenant_id, "type": "daily"},
tenant_id=tenant_id,
)
results["summary"] = summary
return results
# ── Status ────────────────────────────────────
def get_lifecycle_states(self) -> dict:
return LEAD_STATES
def get_supported_events(self) -> list:
return self.router.get_event_types()

View File

@ -0,0 +1,253 @@
"""
Saudi Dialect Processor The secret sauce for authentic Saudi Arabic AI conversations.
Handles dialect awareness, tone switching, and cultural nuances.
"""
class SaudiDialectProcessor:
"""
Processes and generates text in authentic Saudi Arabic dialect.
Supports multiple regional variants and formality levels.
"""
# ── Saudi Greeting Templates ──────────────────
GREETINGS = {
"formal": [
"السلام عليكم ورحمة الله وبركاته",
"حياك الله ومرحبا بك",
"أهلاً وسهلاً، كيف حالك؟",
"الله يحييك، نورت",
],
"casual": [
"هلا والله!",
"أهلين وسهلين!",
"هلا وغلا، كيفك؟",
"يا هلا فيك!",
"حياك!",
],
"business": [
"السلام عليكم، حياك الله",
"أهلاً بك، يسعدنا تواصلك معنا",
"مرحباً بك في ديل اي اكس",
"حياك الله ومرحبا، كيف نقدر نخدمك؟",
],
}
# ── Common Saudi Expressions ──────────────────
EXPRESSIONS = {
"yes": ["إي", "أيوا", "تمام", "أكيد", "بالتأكيد", "ان شاء الله"],
"no": ["لا", "ما يناسبني", "مو الحين", "مب الحين", "خلنا نشوف"],
"thanks": ["الله يعطيك العافية", "مشكور", "يعطيك ألف عافية", "تسلم"],
"goodbye": ["الله يوفقك", "في أمان الله", "الله يسعدك", "تشرفنا"],
"interest": ["يهمني الموضوع", "خلني أفهم أكثر", "عطني تفاصيل"],
"thinking": ["خلني أفكر", "أشوف الموضوع", "أرجع لك", "خلني أستشير"],
"agreement": ["ماشي", "تمام كذا", "موافق", "يا هلا فيها"],
"praise": ["ماشاء الله", "الله يبارك", "عمل ممتاز", "أحسنت"],
}
# ── Industry-specific Saudi Terms ─────────────
INDUSTRY_TERMS = {
"real_estate": {
"lead": "عميل محتمل",
"developer": "مطور عقاري",
"brokerage": "مكتب وساطة",
"listing": "عقار معروض",
"commission": "سعاية / عمولة",
},
"restaurant": {
"franchise": "امتياز تجاري",
"delivery": "توصيل",
"dine_in": "جلسات داخلية",
"health_cert": "شهادة صحية",
},
"healthcare": {
"clinic": "عيادة / مجمع طبي",
"appointment": "موعد",
"patient": "مريض / مراجع",
"insurance": "تأمين طبي",
},
"education": {
"enrollment": "تسجيل",
"tuition": "رسوم دراسية",
"curriculum": "منهج دراسي",
},
"ecommerce": {
"order": "طلب",
"shipping": "شحن",
"return": "إرجاع / استبدال",
"cart": "سلة المشتريات",
},
}
# ── Tone Configurations ───────────────────────
TONE_CONFIGS = {
"professional_friendly": {
"description": "محترف وودي — مثالي للتواصل الأولي مع الشركات",
"rules": [
"استخدم صيغة المخاطب المفرد (أنت/حضرتك)",
"ابدأ بالسلام والتحية",
"كن مباشراً في الطرح بدون إطالة",
"استخدم أمثلة عملية من السوق السعودي",
"تجنب المبالغة في الرسمية",
"استخدم 'حضرتك' مع الرسميين و'أنت' مع الباقي",
],
},
"casual_warm": {
"description": "عفوي ودافئ — للمتابعة والمحادثات غير الرسمية",
"rules": [
"استخدم تعبيرات سعودية طبيعية",
"أضف لمسة شخصية في الكلام",
"استخدم الإيموجي بشكل معتدل",
"كأنك تكلم صاحبك في شغل",
"تجنب الرسمية الزائدة",
],
},
"executive": {
"description": "تنفيذي رسمي — لكبار المسؤولين والشركات الكبرى",
"rules": [
"استخدم لغة احترافية عالية",
"أرفق أرقام وإحصائيات",
"ركز على ROI والنتائج",
"استخدم 'حضرتكم' للمخاطب",
"تجنب الإطالة — المدراء مشغولين",
],
},
}
# ── Regional Dialect Awareness ────────────────
REGIONAL_MARKERS = {
"najdi": { # Riyadh, Qassim
"markers": ["ايش", "كذا", "يا رجال", "وش لونك"],
"greeting": "هلا والله، وش لونك؟",
},
"hijazi": { # Jeddah, Makkah, Madinah
"markers": ["كده", "ليش", "يا زين", "دحين"],
"greeting": "أهلين، كيف الحال؟",
},
"sharqawi": { # Dammam, Khobar, Dhahran
"markers": ["شلونك", "هاي", "بعد", "يا بوي"],
"greeting": "هلا، شلونك؟",
},
}
# ── Main Processing Methods ───────────────────
@classmethod
def get_system_prompt_additions(
cls,
tone: str = "professional_friendly",
sector: str = None,
region: str = None,
) -> str:
"""
Generate additional prompt instructions for Saudi dialect.
Append this to the agent's system prompt.
"""
parts = []
# Tone rules
tone_config = cls.TONE_CONFIGS.get(tone, cls.TONE_CONFIGS["professional_friendly"])
parts.append(f"\n## أسلوب التواصل: {tone_config['description']}")
parts.append("### قواعد الأسلوب:")
for rule in tone_config["rules"]:
parts.append(f"- {rule}")
# Sector-specific terms
if sector and sector in cls.INDUSTRY_TERMS:
parts.append(f"\n### مصطلحات القطاع ({sector}):")
for eng, ar in cls.INDUSTRY_TERMS[sector].items():
parts.append(f"- {eng} = {ar}")
# Regional awareness
if region and region in cls.REGIONAL_MARKERS:
regional = cls.REGIONAL_MARKERS[region]
parts.append(f"\n### لهجة المنطقة ({region}):")
parts.append(f"- تحية مناسبة: {regional['greeting']}")
parts.append(f"- كلمات مألوفة: {', '.join(regional['markers'])}")
# General Saudi rules
parts.append("\n### قواعد عامة للتواصل بالسعودية:")
parts.append("- لا تستخدم لهجة مصرية أو شامية أو مغربية")
parts.append("- استخدم 'ريال' وليس 'جنيه' أو 'دولار'")
parts.append("- راعي أوقات العمل السعودية (الأحد-الخميس)")
parts.append("- احترم أوقات الصلاة وتجنب التواصل خلالها")
parts.append("- استخدم التقويم الهجري إذا ذُكر")
parts.append("- الشركات الحكومية = رسمي جداً")
parts.append("- القطاع الخاص = محترف وودي")
parts.append("- المنشآت الصغيرة = عفوي ومباشر")
return "\n".join(parts)
@classmethod
def get_greeting(cls, tone: str = "business") -> str:
"""Get a random appropriate greeting."""
import random
greetings = cls.GREETINGS.get(tone, cls.GREETINGS["business"])
return random.choice(greetings)
@classmethod
def get_farewell(cls) -> str:
import random
return random.choice(cls.EXPRESSIONS["goodbye"])
@classmethod
def enhance_message(cls, message: str, tone: str = "professional_friendly") -> str:
"""Add Saudi conversational touches to a message."""
# This is a simple enhancement; the real magic happens in the LLM prompt
if not message.startswith(("السلام", "أهلا", "هلا", "حياك", "مرحب")):
greeting = cls.get_greeting(
"formal" if tone == "executive" else "business"
)
message = f"{greeting}\n\n{message}"
if not message.endswith(("الله", "عافية", "تشرفنا", "أمان")):
farewell = cls.get_farewell()
message = f"{message}\n\n{farewell}"
return message
@classmethod
def detect_region(cls, text: str) -> str:
"""Detect the regional dialect from text."""
text_lower = text.lower()
scores = {}
for region, config in cls.REGIONAL_MARKERS.items():
score = sum(1 for marker in config["markers"] if marker in text_lower)
scores[region] = score
if max(scores.values(), default=0) > 0:
return max(scores, key=scores.get)
return "najdi" # Default to Najdi (most common)
@classmethod
def get_objection_responses(cls, objection_type: str) -> list:
"""Get culturally appropriate objection responses."""
responses = {
"price": [
"أفهم تماماً، خلني أوضح لك القيمة اللي بترجع عليك من الاستثمار هذا...",
"سعرنا تنافسي مقارنة بالسوق، وعندنا ضمان ذهبي إذا ما حصلت نتائج",
"كثير من عملاءنا قالوا نفس الكلام بالبداية، بس بعد ما جربوا شافوا الفرق",
],
"timing": [
"ما فيه أحسن وقت من الحين، المنافسين ما بينتظرونك",
"أفهم إنك مشغول، ممكن نحجز لك 15 دقيقة بس عشان توضح لك الصورة",
"كثير من الشركات تأجل وبعدين تندم إنها ما بدت بدري",
],
"competitor": [
"كل نظام له مميزاته، بس خلني أوريك وش يميزنا عنهم بالتحديد",
"حياك، المقارنة حقك. خلنا نسوي لك عرض مقارنة واضح",
"كثير من عملاءنا كانوا يستخدمون [المنافس] وحولوا لنا، وش تبي أشرح لك ليش؟",
],
"authority": [
"طيب، وش رأيك نجهز لك ملخص تقدر تشاركه مع صاحب القرار؟",
"ممكن نسوي لك عرض مختصر بالأرقام عشان يسهل عليك الشرح",
"عادي، ممكن ندعو صاحب القرار معك في الاجتماع القادم",
],
}
return responses.get(objection_type, responses["price"])

View File

@ -0,0 +1,217 @@
"""
Analytics & AI API Routes ROI tracking, trust scores, AI orchestration.
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
router = APIRouter()
# ── Analytics ─────────────────────────────────────
@router.get("/analytics/summary")
async def analytics_summary(
tenant_id: str = Query(...),
days: int = Query(30),
db: AsyncSession = Depends(get_db),
):
"""KPI summary: leads, deals, revenue, conversion rates."""
from app.services.analytics_service import AnalyticsService
svc = AnalyticsService(db)
return await svc.get_kpi_summary(tenant_id, days)
@router.get("/analytics/funnel")
async def analytics_funnel(
tenant_id: str = Query(...),
db: AsyncSession = Depends(get_db),
):
"""Conversion funnel: Lead → Contacted → Qualified → Converted."""
from app.services.analytics_service import AnalyticsService
svc = AnalyticsService(db)
return await svc.get_conversion_funnel(tenant_id)
@router.get("/analytics/channels")
async def analytics_channels(
tenant_id: str = Query(...),
db: AsyncSession = Depends(get_db),
):
"""Channel performance comparison."""
from app.services.analytics_service import AnalyticsService
svc = AnalyticsService(db)
return await svc.get_channel_performance(tenant_id)
@router.get("/analytics/sectors")
async def analytics_sectors(
tenant_id: str = Query(...),
db: AsyncSession = Depends(get_db),
):
"""Sector performance breakdown."""
from app.services.analytics_service import AnalyticsService
svc = AnalyticsService(db)
return await svc.get_sector_performance(tenant_id)
@router.get("/analytics/agents")
async def analytics_agents(
tenant_id: str = Query(...),
db: AsyncSession = Depends(get_db),
):
"""Agent performance metrics."""
from app.services.analytics_service import AnalyticsService
svc = AnalyticsService(db)
return await svc.get_agent_performance(tenant_id)
@router.get("/analytics/trends")
async def analytics_trends(
tenant_id: str = Query(...),
days: int = Query(90),
db: AsyncSession = Depends(get_db),
):
"""Time-series trends."""
from app.services.analytics_service import AnalyticsService
svc = AnalyticsService(db)
return await svc.get_trends(tenant_id, days)
# ── Trust Scores ──────────────────────────────────
@router.post("/trust-scores/lead/{lead_id}")
async def score_lead(
lead_id: str,
tenant_id: str = Query(...),
db: AsyncSession = Depends(get_db),
):
"""Calculate trust score for a lead."""
from app.services.trust_score_service import TrustScoreService
svc = TrustScoreService(db)
return await svc.calculate_lead_score(tenant_id, lead_id)
@router.post("/trust-scores/affiliate/{affiliate_id}")
async def score_affiliate(
affiliate_id: str,
tenant_id: str = Query(...),
db: AsyncSession = Depends(get_db),
):
"""Calculate trust score for an affiliate."""
from app.services.trust_score_service import TrustScoreService
svc = TrustScoreService(db)
return await svc.calculate_affiliate_score(tenant_id, affiliate_id)
@router.post("/trust-scores/batch")
async def score_all(
tenant_id: str = Query(...),
db: AsyncSession = Depends(get_db),
):
"""Batch score all unscored leads."""
from app.services.trust_score_service import TrustScoreService
svc = TrustScoreService(db)
return await svc.score_all_leads(tenant_id)
# ── AI Orchestration ──────────────────────────────
@router.post("/orchestrator/process-lead/{lead_id}")
async def orchestrate_lead(
lead_id: str,
tenant_id: str = Query(...),
db: AsyncSession = Depends(get_db),
):
"""Process a new lead through the full AI pipeline."""
from app.ai.orchestrator import Orchestrator
orch = Orchestrator(db)
return await orch.process_new_lead(tenant_id, lead_id)
@router.post("/orchestrator/handle-message")
async def handle_message(
tenant_id: str = Query(...),
lead_id: str = Query(...),
message: str = Query(...),
channel: str = Query("whatsapp"),
language: str = Query("ar"),
db: AsyncSession = Depends(get_db),
):
"""Process an inbound message through AI agents."""
from app.ai.orchestrator import Orchestrator
orch = Orchestrator(db)
return await orch.handle_inbound_message(
tenant_id, lead_id, message, channel, language
)
@router.post("/orchestrator/prepare-meeting/{meeting_id}")
async def prepare_meeting(
meeting_id: str,
tenant_id: str = Query(...),
db: AsyncSession = Depends(get_db),
):
"""Generate AI meeting preparation package."""
from app.ai.orchestrator import Orchestrator
orch = Orchestrator(db)
return await orch.prepare_meeting(tenant_id, meeting_id)
@router.post("/orchestrator/daily")
async def run_daily(
tenant_id: str = Query(...),
db: AsyncSession = Depends(get_db),
):
"""Run daily automation tasks."""
from app.ai.orchestrator import Orchestrator
orch = Orchestrator(db)
return await orch.run_daily_automation(tenant_id)
@router.get("/orchestrator/states")
async def get_states():
"""Get the lead lifecycle state machine."""
from app.ai.orchestrator import Orchestrator
return Orchestrator.__init__ # Will return states without DB
# Simplified response
return {
"states": {
"new": {"next_states": ["contacted", "lost"], "auto_agent": "lead_qualification"},
"contacted": {"next_states": ["qualified", "lost"], "auto_agent": "outreach_writer"},
"qualified": {"next_states": ["converted", "contacted", "lost"], "auto_agent": "meeting_booking"},
"converted": {"next_states": [], "auto_agent": None},
"lost": {"next_states": ["new"], "auto_agent": None},
}
}
@router.get("/orchestrator/events")
async def get_events():
"""List all supported event types."""
from app.ai.agent_router import EVENT_AGENT_MAP
return {
"events": [
{"type": k, "agents": v}
for k, v in EVENT_AGENT_MAP.items()
]
}
# ── AI Agent Direct Invocation ────────────────────
@router.get("/ai/agents")
async def list_ai_agents():
"""List all 18 available AI agents."""
from app.ai.agent_executor import AgentExecutor
executor = AgentExecutor()
return {"agents": executor.get_available_agents()}
@router.get("/ai/usage")
async def ai_usage():
"""Get AI token usage stats."""
from app.ai.llm_provider import LLMProvider
llm = LLMProvider()
return llm.get_usage_stats()

View File

@ -3,7 +3,7 @@ from app.api.v1 import (
auth, leads, deals, dashboard, tenants, users, affiliates, ai_agents,
companies, contacts, calls, meetings, commissions, payouts, disputes,
guarantees, consents, complaints, knowledge, sectors, presentations,
supervisor, admin, health,
supervisor, admin, health, analytics, webhooks,
)
api_router = APIRouter()
@ -32,3 +32,5 @@ api_router.include_router(presentations.router, prefix="/presentations", tags=["
api_router.include_router(supervisor.router, prefix="/supervisor", tags=["Supervisor"])
api_router.include_router(admin.router, prefix="/admin", tags=["Admin"])
api_router.include_router(health.router, tags=["Health"])
api_router.include_router(analytics.router, tags=["Analytics & AI"])
api_router.include_router(webhooks.router, tags=["Webhooks"])

View File

@ -0,0 +1,192 @@
"""
Webhook Routes Receive events from WhatsApp, Email, CRM, Calendar, Payment.
"""
import hashlib
import hmac
import json
from fastapi import APIRouter, Request, HTTPException, Query, BackgroundTasks
from app.config import get_settings
settings = get_settings()
router = APIRouter(prefix="/webhooks", tags=["Webhooks"])
# ── WhatsApp ──────────────────────────────────────
@router.get("/whatsapp")
async def whatsapp_verify(
hub_mode: str = Query(None, alias="hub.mode"),
hub_verify_token: str = Query(None, alias="hub.verify_token"),
hub_challenge: str = Query(None, alias="hub.challenge"),
):
"""WhatsApp webhook verification (Meta Cloud API)."""
if hub_mode == "subscribe" and hub_verify_token == settings.WHATSAPP_VERIFY_TOKEN:
return int(hub_challenge)
raise HTTPException(status_code=403, detail="Verification failed")
@router.post("/whatsapp")
async def whatsapp_incoming(request: Request, background_tasks: BackgroundTasks):
"""
Receive inbound WhatsApp messages from Meta Cloud API.
Processes: text messages, media, reactions, status updates.
"""
body = await request.json()
entries = body.get("entry", [])
for entry in entries:
changes = entry.get("changes", [])
for change in changes:
value = change.get("value", {})
messages = value.get("messages", [])
statuses = value.get("statuses", [])
# Process incoming messages
for msg in messages:
background_tasks.add_task(
_process_whatsapp_message,
phone=msg.get("from", ""),
message_type=msg.get("type", "text"),
content=_extract_whatsapp_content(msg),
message_id=msg.get("id", ""),
timestamp=msg.get("timestamp", ""),
)
# Process delivery statuses
for status in statuses:
background_tasks.add_task(
_process_whatsapp_status,
message_id=status.get("id", ""),
status=status.get("status", ""),
recipient=status.get("recipient_id", ""),
)
return {"status": "ok"}
async def _process_whatsapp_message(
phone: str, message_type: str, content: str, message_id: str, timestamp: str
):
"""Background task to process WhatsApp message through AI pipeline."""
# Will be connected to Orchestrator
pass
async def _process_whatsapp_status(message_id: str, status: str, recipient: str):
"""Background task to update message delivery status."""
pass
def _extract_whatsapp_content(msg: dict) -> str:
"""Extract text content from various WhatsApp message types."""
msg_type = msg.get("type", "text")
if msg_type == "text":
return msg.get("text", {}).get("body", "")
elif msg_type == "image":
return f"[صورة: {msg.get('image', {}).get('caption', '')}]"
elif msg_type == "document":
return f"[ملف: {msg.get('document', {}).get('filename', '')}]"
elif msg_type == "audio":
return "[رسالة صوتية]"
elif msg_type == "video":
return "[فيديو]"
elif msg_type == "location":
loc = msg.get("location", {})
return f"[موقع: {loc.get('latitude')}, {loc.get('longitude')}]"
elif msg_type == "reaction":
return f"[تفاعل: {msg.get('reaction', {}).get('emoji', '')}]"
return ""
# ── Email ─────────────────────────────────────────
@router.post("/email/inbound")
async def email_inbound(request: Request, background_tasks: BackgroundTasks):
"""
Receive inbound emails via SendGrid Inbound Parse.
"""
form = await request.form()
sender = form.get("from", "")
subject = form.get("subject", "")
body = form.get("text", form.get("html", ""))
background_tasks.add_task(
_process_inbound_email,
sender=sender,
subject=subject,
body=body,
)
return {"status": "ok"}
async def _process_inbound_email(sender: str, subject: str, body: str):
"""Background task to process inbound email."""
pass
# ── CRM Sync ─────────────────────────────────────
@router.post("/crm/salesforce")
async def salesforce_webhook(request: Request, background_tasks: BackgroundTasks):
"""Receive Salesforce outbound messages / platform events."""
body = await request.json()
background_tasks.add_task(_process_crm_sync, provider="salesforce", data=body)
return {"status": "ok"}
@router.post("/crm/hubspot")
async def hubspot_webhook(request: Request, background_tasks: BackgroundTasks):
"""Receive HubSpot webhook events."""
body = await request.json()
background_tasks.add_task(_process_crm_sync, provider="hubspot", data=body)
return {"status": "ok"}
async def _process_crm_sync(provider: str, data: dict):
"""Background task to sync CRM data."""
pass
# ── Calendar ──────────────────────────────────────
@router.post("/calendar/google")
async def google_calendar_webhook(request: Request, background_tasks: BackgroundTasks):
"""Receive Google Calendar push notifications."""
body = await request.json()
background_tasks.add_task(_process_calendar_event, provider="google", data=body)
return {"status": "ok"}
async def _process_calendar_event(provider: str, data: dict):
"""Background task to sync calendar events."""
pass
# ── Payment ───────────────────────────────────────
@router.post("/payment/moyasar")
async def moyasar_webhook(request: Request, background_tasks: BackgroundTasks):
"""Receive Moyasar payment events."""
body = await request.json()
background_tasks.add_task(_process_payment, provider="moyasar", data=body)
return {"status": "ok"}
@router.post("/payment/stripe")
async def stripe_webhook(request: Request, background_tasks: BackgroundTasks):
"""Receive Stripe webhook events."""
body = await request.body()
sig = request.headers.get("stripe-signature", "")
background_tasks.add_task(
_process_payment,
provider="stripe",
data={"body": body.decode(), "signature": sig},
)
return {"status": "ok"}
async def _process_payment(provider: str, data: dict):
"""Background task to process payment events."""
pass

View File

@ -1,9 +1,10 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
from typing import Optional
class Settings(BaseSettings):
# App
# ── App ──────────────────────────────────────────────
APP_NAME: str = "Dealix"
APP_NAME_AR: str = "ديل اي اكس"
DEBUG: bool = False
@ -11,40 +12,95 @@ class Settings(BaseSettings):
DEFAULT_CURRENCY: str = "SAR"
DEFAULT_LOCALE: str = "ar"
# Database
# ── Database ─────────────────────────────────────────
DATABASE_URL: str = "postgresql+asyncpg://salesflow:salesflow_secret_2024@db:5432/salesflow"
# Redis
# ── Redis ────────────────────────────────────────────
REDIS_URL: str = "redis://redis:6379/0"
# Security
# ── Security ─────────────────────────────────────────
SECRET_KEY: str = "change-this-to-a-random-secret-key"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# URLs
# ── URLs ─────────────────────────────────────────────
API_URL: str = "http://localhost:8000"
FRONTEND_URL: str = "http://localhost:3000"
# WhatsApp
# ── LLM Providers ────────────────────────────────────
# Primary: Groq (free/cheap, very fast)
GROQ_API_KEY: str = ""
GROQ_MODEL: str = "llama-3.3-70b-versatile"
GROQ_FAST_MODEL: str = "llama-3.1-8b-instant"
# Fallback: OpenAI
OPENAI_API_KEY: str = ""
OPENAI_MODEL: str = "gpt-4o"
OPENAI_MINI_MODEL: str = "gpt-4o-mini"
# Embeddings
EMBEDDING_PROVIDER: str = "openai" # openai, sentence-transformers
EMBEDDING_MODEL: str = "text-embedding-3-small"
EMBEDDING_DIMENSIONS: int = 1536
# LLM defaults
LLM_PRIMARY_PROVIDER: str = "groq" # groq, openai
LLM_TEMPERATURE: float = 0.3
LLM_MAX_TOKENS: int = 2048
LLM_TIMEOUT: int = 30
# ── WhatsApp Business API ────────────────────────────
WHATSAPP_API_TOKEN: str = ""
WHATSAPP_PHONE_NUMBER_ID: str = ""
WHATSAPP_BUSINESS_ACCOUNT_ID: str = ""
WHATSAPP_VERIFY_TOKEN: str = ""
WHATSAPP_API_URL: str = "https://graph.facebook.com/v21.0"
WHATSAPP_MOCK_MODE: bool = True # Use mock for development
# Email
EMAIL_PROVIDER: str = "smtp"
# ── Email ────────────────────────────────────────────
EMAIL_PROVIDER: str = "smtp" # smtp, sendgrid
SMTP_HOST: str = "smtp.gmail.com"
SMTP_PORT: int = 587
SMTP_USER: str = ""
SMTP_PASSWORD: str = ""
SENDGRID_API_KEY: str = ""
EMAIL_FROM_NAME: str = "Dealix"
EMAIL_FROM_ADDRESS: str = "noreply@dealix.sa"
# SMS (Unifonic)
# ── SMS (Unifonic - Saudi) ───────────────────────────
UNIFONIC_APP_SID: str = ""
UNIFONIC_SENDER_ID: str = "Dealix"
# ── Calendar Integration ─────────────────────────────
GOOGLE_CALENDAR_CREDENTIALS: str = ""
GOOGLE_CALENDAR_ID: str = ""
MICROSOFT_CLIENT_ID: str = ""
MICROSOFT_CLIENT_SECRET: str = ""
# ── CRM Connectors ───────────────────────────────────
HUBSPOT_API_KEY: str = ""
SALESFORCE_CLIENT_ID: str = ""
SALESFORCE_CLIENT_SECRET: str = ""
SALESFORCE_DOMAIN: str = ""
# ── Scraping / Lead Gen ──────────────────────────────
GOOGLE_MAPS_API_KEY: str = ""
RAPIDAPI_KEY: str = "" # For LinkedIn data enrichment
# ── Rate Limiting ────────────────────────────────────
RATE_LIMIT_PER_MINUTE: int = 60
RATE_LIMIT_PER_HOUR: int = 1000
WHATSAPP_RATE_LIMIT_PER_SECOND: int = 80
# ── Celery ───────────────────────────────────────────
CELERY_BROKER_URL: str = "redis://redis:6379/1"
CELERY_RESULT_BACKEND: str = "redis://redis:6379/2"
# ── File Storage ─────────────────────────────────────
UPLOAD_DIR: str = "/app/uploads"
MAX_UPLOAD_SIZE_MB: int = 10
class Config:
env_file = ".env"
case_sensitive = True

View File

@ -1,5 +1,6 @@
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import event, text
from app.config import get_settings
settings = get_settings()
@ -29,3 +30,13 @@ async def get_db() -> AsyncSession:
raise
finally:
await session.close()
async def init_db():
"""Initialize database extensions and create tables."""
async with engine.begin() as conn:
# Enable pgvector extension for RAG embeddings
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
# Create all tables
await conn.run_sync(Base.metadata.create_all)

View File

@ -1,5 +1,8 @@
import httpx
from app.config import get_settings
import logging
logger = logging.getLogger("dealix.integrations.whatsapp")
settings = get_settings()
@ -8,7 +11,12 @@ WHATSAPP_API_URL = "https://graph.facebook.com/v21.0"
async def send_whatsapp_message(phone: str, message: str) -> dict:
"""Send a text message via WhatsApp Business API."""
if settings.WHATSAPP_MOCK_MODE:
logger.info(f"[MOCK WHATSAPP] To: {phone} | Message: {message}")
return {"status": "success", "mocked": True, "message_id": "mock_123"}
if not settings.WHATSAPP_API_TOKEN or not settings.WHATSAPP_PHONE_NUMBER_ID:
logger.error("WhatsApp credentials missing.")
return {"status": "error", "detail": "WhatsApp not configured"}
url = f"{WHATSAPP_API_URL}/{settings.WHATSAPP_PHONE_NUMBER_ID}/messages"

View File

@ -1,34 +1,69 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from app.config import get_settings
from app.api.v1.router import api_router
settings = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application startup and shutdown events."""
# Startup
print(f"🚀 {settings.APP_NAME} ({settings.APP_NAME_AR}) starting...")
print(f" Environment: {settings.ENVIRONMENT}")
print(f" LLM Primary: {settings.LLM_PRIMARY_PROVIDER}")
print(f" LLM Fallback: {settings.LLM_FALLBACK_PROVIDER}")
yield
# Shutdown
print(f"👋 {settings.APP_NAME} shutting down...")
app = FastAPI(
title=f"{settings.APP_NAME} API",
description="AI Sales SaaS Platform for SMEs - Multi-tenant, Multi-industry Sales Automation",
version="1.0.0",
description=(
"AI-powered B2B Revenue Operating System for the Saudi market. "
"Lead management, AI agents, affiliate system, meeting automation, "
"deal pipeline, and commission processing — all driven by 18 specialized AI agents."
),
version="2.0.0",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
lifespan=lifespan,
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.FRONTEND_URL, "http://localhost:3000"],
allow_origins=[
settings.FRONTEND_URL,
"http://localhost:3000",
"http://localhost:5173",
"https://dealix.sa",
"https://app.dealix.sa",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# API Routes
app.include_router(api_router, prefix="/api/v1")
# Health check (outside router for direct access)
@app.get("/api/v1/health")
async def health_check():
return {
"status": "healthy",
"app": settings.APP_NAME,
"version": "1.0.0",
"version": "2.0.0",
"environment": settings.ENVIRONMENT,
"ai_engine": {
"primary": settings.LLM_PRIMARY_PROVIDER,
"fallback": settings.LLM_FALLBACK_PROVIDER,
},
}

View File

@ -21,6 +21,7 @@ from app.models.dispute import Dispute
from app.models.guarantee import GuaranteeClaim
from app.models.compliance import Consent, Complaint, Policy
from app.models.knowledge import KnowledgeArticle, SectorAsset
from app.models.advanced import TrustScore, Prospect, Scorecard, AIRehearsal
__all__ = [
"BaseModel", "TenantModel", "Tenant", "User", "Lead", "Customer",
@ -31,4 +32,5 @@ __all__ = [
"Company", "Contact", "Call", "Commission", "Payout",
"Dispute", "GuaranteeClaim", "Consent", "Complaint", "Policy",
"KnowledgeArticle", "SectorAsset",
"TrustScore", "Prospect", "Scorecard", "AIRehearsal",
]

View File

@ -0,0 +1,158 @@
"""Trust Score & Prospect models — new additions to Dealix architecture."""
import enum
from sqlalchemy import Column, String, Integer, Float, Text, DateTime, Boolean, Enum, ForeignKey, Date
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from app.models.base import TenantModel, BaseModel
# ─── Trust Score ───────────────────────────────────────────────
class EntityType(str, enum.Enum):
AFFILIATE = "affiliate"
LEAD = "lead"
COMPANY = "company"
CONTACT = "contact"
class TrustScore(TenantModel):
"""Trust assessment for affiliates, leads, and companies.
Helps the system focus effort on people who actually buy/perform."""
__tablename__ = "trust_scores"
entity_type = Column(Enum(EntityType), nullable=False, index=True)
entity_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Composite score (0-100)
score = Column(Float, default=50.0, nullable=False)
# Breakdown dimensions
engagement_score = Column(Float, default=50.0) # Response rate, activity level
conversion_score = Column(Float, default=50.0) # Historical conversion rate
reliability_score = Column(Float, default=50.0) # Show-up rate, commitment
quality_score = Column(Float, default=50.0) # Lead quality, deal value
# Signals
positive_signals = Column(JSONB, default=[]) # List of positive indicators
negative_signals = Column(JSONB, default=[]) # List of risk indicators
last_computed_at = Column(DateTime(timezone=True))
# History
history = Column(JSONB, default=[]) # Score history over time
# ─── Prospect (pre-lead, from scraping) ───────────────────────
class ProspectStatus(str, enum.Enum):
IDENTIFIED = "identified"
RESEARCHING = "researching"
APPROACHING = "approaching"
ENGAGED = "engaged"
CONVERTED = "converted"
DISQUALIFIED = "disqualified"
class Prospect(TenantModel):
"""Pre-lead record created by the AI Lead Generator from scraping.
Gets promoted to a Lead when qualified."""
__tablename__ = "prospects"
# Source data
source = Column(String(50), nullable=False, index=True) # google_maps, linkedin, saudi_registry
source_url = Column(String(1000), nullable=True)
source_id = Column(String(255), nullable=True) # External ID from source
# Business info
company_name = Column(String(255), nullable=True)
company_name_ar = Column(String(255), nullable=True)
sector = Column(String(100), nullable=True, index=True)
website = Column(String(500), nullable=True)
city = Column(String(100), nullable=True)
region = Column(String(100), nullable=True)
# Contact info
contact_name = Column(String(255), nullable=True)
contact_title = Column(String(255), nullable=True)
phone = Column(String(20), nullable=True)
email = Column(String(255), nullable=True)
whatsapp = Column(String(20), nullable=True)
# AI analysis
status = Column(Enum(ProspectStatus), default=ProspectStatus.IDENTIFIED, nullable=False)
buying_intent_score = Column(Float, default=0.0) # AI-computed 0-100
estimated_value = Column(Float, default=0.0) # Estimated deal size SAR
fit_score = Column(Float, default=0.0) # Product-market fit 0-100
priority = Column(String(20), default="medium") # low, medium, high, critical
# Enrichment data
enrichment_data = Column(JSONB, default={}) # Raw scraped/enriched data
notes = Column(Text, nullable=True)
# Conversion
converted_to_lead_id = Column(UUID(as_uuid=True), nullable=True)
converted_at = Column(DateTime(timezone=True), nullable=True)
# ─── Scorecard ─────────────────────────────────────────────────
class Scorecard(TenantModel):
"""Performance scorecard for sales agents and affiliates."""
__tablename__ = "scorecards"
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
period = Column(Date, nullable=False)
period_type = Column(String(20), default="monthly") # weekly, monthly, quarterly
# Activity metrics
leads_handled = Column(Integer, default=0)
calls_made = Column(Integer, default=0)
meetings_booked = Column(Integer, default=0)
meetings_completed = Column(Integer, default=0)
# Outcome metrics
deals_closed = Column(Integer, default=0)
revenue_generated = Column(Float, default=0.0)
avg_deal_size = Column(Float, default=0.0)
# Quality metrics
avg_response_time_seconds = Column(Integer, default=0)
customer_satisfaction = Column(Float, default=0.0) # 0-5
ai_assist_rate = Column(Float, default=0.0) # % of AI-assisted interactions
# Composite
composite_score = Column(Float, default=0.0) # Weighted aggregate
user = relationship("User")
# ─── AI Rehearsal (Meeting Preview) ────────────────────────────
class AIRehearsal(TenantModel):
"""AI-powered meeting rehearsal — simulates the upcoming meeting
so the sales rep can practice the best closing strategy."""
__tablename__ = "ai_rehearsals"
meeting_id = Column(UUID(as_uuid=True), ForeignKey("auto_bookings.id"), nullable=True)
lead_id = Column(UUID(as_uuid=True), ForeignKey("leads.id"), nullable=True)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) # Sales rep
# Context
client_profile_summary = Column(Text, nullable=True) # AI-generated summary of client
industry_insights = Column(Text, nullable=True) # Relevant sector intelligence
predicted_objections = Column(JSONB, default=[]) # Expected objections
recommended_approach = Column(Text, nullable=True) # AI closing strategy
talking_points = Column(JSONB, default=[]) # Key talking points
competitive_intel = Column(Text, nullable=True) # Competitor positioning
# Rehearsal session
rehearsal_transcript = Column(JSONB, default=[]) # Simulated conversation
feedback = Column(Text, nullable=True) # AI feedback on performance
readiness_score = Column(Float, default=0.0) # 0-100
# Status
status = Column(String(20), default="pending") # pending, in_progress, completed
completed_at = Column(DateTime(timezone=True), nullable=True)
meeting = relationship("AutoBooking")
lead = relationship("Lead")
user = relationship("User")

View File

@ -1,6 +1,6 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import Column, DateTime, Boolean
from sqlalchemy import Column, DateTime, Boolean, String, event
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
@ -10,6 +10,7 @@ class BaseModel(Base):
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
class TenantModel(BaseModel):

View File

@ -0,0 +1,385 @@
"""Pydantic schemas for request/response validation."""
from datetime import datetime, date
from typing import Optional, List, Any
from uuid import UUID
from pydantic import BaseModel, EmailStr, Field, ConfigDict
# ── Auth Schemas ────────────────────────────────────────────────
class LoginRequest(BaseModel):
email: str
password: str
class RegisterRequest(BaseModel):
email: str
password: str = Field(min_length=8)
full_name: str
company_name: str
industry: Optional[str] = None
phone: Optional[str] = None
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
user: "UserResponse"
class RefreshTokenRequest(BaseModel):
refresh_token: str
class PasswordResetRequest(BaseModel):
email: str
class PasswordResetConfirm(BaseModel):
token: str
new_password: str = Field(min_length=8)
# ── User Schemas ────────────────────────────────────────────────
class UserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
email: str
full_name: Optional[str] = None
full_name_ar: Optional[str] = None
role: str
phone: Optional[str] = None
is_active: bool
last_login: Optional[datetime] = None
created_at: datetime
class UserUpdate(BaseModel):
full_name: Optional[str] = None
full_name_ar: Optional[str] = None
phone: Optional[str] = None
class UserCreate(BaseModel):
email: str
password: str = Field(min_length=8)
full_name: str
role: str = "agent"
phone: Optional[str] = None
# ── Tenant Schemas ──────────────────────────────────────────────
class TenantResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
name_ar: Optional[str] = None
slug: str
industry: Optional[str] = None
plan: str
is_active: bool
created_at: datetime
class TenantUpdate(BaseModel):
name: Optional[str] = None
name_ar: Optional[str] = None
industry: Optional[str] = None
logo_url: Optional[str] = None
phone: Optional[str] = None
email: Optional[str] = None
whatsapp_number: Optional[str] = None
settings: Optional[dict] = None
# ── Lead Schemas ────────────────────────────────────────────────
class LeadCreate(BaseModel):
name: str
phone: Optional[str] = None
email: Optional[str] = None
source: Optional[str] = "manual"
company_name: Optional[str] = None
sector: Optional[str] = None
city: Optional[str] = None
notes: Optional[str] = None
metadata: Optional[dict] = None
class LeadUpdate(BaseModel):
name: Optional[str] = None
phone: Optional[str] = None
email: Optional[str] = None
status: Optional[str] = None
score: Optional[int] = None
assigned_to: Optional[UUID] = None
notes: Optional[str] = None
metadata: Optional[dict] = None
class LeadResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
tenant_id: UUID
name: str
phone: Optional[str] = None
email: Optional[str] = None
source: Optional[str] = None
status: str
score: int
notes: Optional[str] = None
metadata: Optional[dict] = None
assigned_to: Optional[UUID] = None
created_at: datetime
updated_at: Optional[datetime] = None
class LeadQualifyResponse(BaseModel):
lead_id: UUID
score: int
status: str
reasoning: str
suggested_action: str
bant_analysis: dict
# ── Deal Schemas ────────────────────────────────────────────────
class DealCreate(BaseModel):
title: str
lead_id: Optional[UUID] = None
value: Optional[float] = None
currency: str = "SAR"
stage: str = "new"
probability: int = 0
expected_close_date: Optional[date] = None
notes: Optional[str] = None
class DealUpdate(BaseModel):
title: Optional[str] = None
value: Optional[float] = None
stage: Optional[str] = None
probability: Optional[int] = None
expected_close_date: Optional[date] = None
notes: Optional[str] = None
assigned_to: Optional[UUID] = None
class DealResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
tenant_id: UUID
lead_id: Optional[UUID] = None
customer_id: Optional[UUID] = None
assigned_to: Optional[UUID] = None
title: str
value: Optional[float] = None
currency: str
stage: str
probability: int
expected_close_date: Optional[date] = None
closed_at: Optional[datetime] = None
notes: Optional[str] = None
created_at: datetime
updated_at: Optional[datetime] = None
# ── Company Schemas ─────────────────────────────────────────────
class CompanyCreate(BaseModel):
name: str
name_ar: Optional[str] = None
website: Optional[str] = None
phone: Optional[str] = None
email: Optional[str] = None
industry: Optional[str] = None
size: Optional[str] = None
city: Optional[str] = None
address: Optional[str] = None
notes: Optional[str] = None
class CompanyResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
tenant_id: UUID
name: str
name_ar: Optional[str] = None
website: Optional[str] = None
phone: Optional[str] = None
email: Optional[str] = None
industry: Optional[str] = None
size: Optional[str] = None
city: Optional[str] = None
is_active: bool
created_at: datetime
# ── Contact Schemas ─────────────────────────────────────────────
class ContactCreate(BaseModel):
company_id: UUID
full_name: str
role: Optional[str] = None
phone: Optional[str] = None
email: Optional[str] = None
is_decision_maker: bool = False
preferred_language: str = "ar"
preferred_channel: str = "whatsapp"
class ContactResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
tenant_id: UUID
company_id: UUID
full_name: str
role: Optional[str] = None
phone: Optional[str] = None
email: Optional[str] = None
is_decision_maker: bool
preferred_language: str
preferred_channel: str
created_at: datetime
# ── Meeting Schemas ─────────────────────────────────────────────
class MeetingCreate(BaseModel):
lead_id: Optional[UUID] = None
meeting_type: str = "demo"
meeting_datetime: datetime
duration_minutes: int = 30
client_name: str
client_phone: Optional[str] = None
client_email: Optional[str] = None
client_company: Optional[str] = None
assigned_sales_rep: Optional[UUID] = None
notes: Optional[str] = None
class MeetingUpdate(BaseModel):
meeting_datetime: Optional[datetime] = None
duration_minutes: Optional[int] = None
status: Optional[str] = None
notes: Optional[str] = None
outcome: Optional[str] = None
class MeetingResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
tenant_id: UUID
lead_id: Optional[UUID] = None
meeting_type: str
meeting_datetime: datetime
duration_minutes: int
client_name: str
client_company: Optional[str] = None
status: str
assigned_sales_rep: Optional[UUID] = None
notes: Optional[str] = None
outcome: Optional[str] = None
created_at: datetime
# ── AI Agent Schemas ────────────────────────────────────────────
class AgentInvokeRequest(BaseModel):
agent_type: str
input_data: dict
lead_id: Optional[UUID] = None
conversation_id: Optional[UUID] = None
async_mode: bool = True
class AgentInvokeResponse(BaseModel):
task_id: Optional[str] = None
agent_type: str
status: str # queued, processing, completed, error
output: Optional[dict] = None
tokens_used: Optional[int] = None
latency_ms: Optional[int] = None
class ConversationResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
tenant_id: UUID
channel: str
status: str
contact_name: Optional[str] = None
contact_phone: Optional[str] = None
contact_company: Optional[str] = None
messages_count: int
sentiment_score: int
interest_level: int
qualified: bool
meeting_booked: bool
last_message_at: Optional[datetime] = None
created_at: datetime
# ── Affiliate Schemas ───────────────────────────────────────────
class AffiliateCreate(BaseModel):
full_name: str
full_name_ar: Optional[str] = None
email: str
phone: str
whatsapp: Optional[str] = None
city: Optional[str] = None
notes: Optional[str] = None
class AffiliateResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
full_name: str
full_name_ar: Optional[str] = None
email: str
phone: str
status: str
referral_code: Optional[str] = None
total_leads_generated: int
total_deals_closed: int
total_commission_earned: float
created_at: datetime
# ── Commission Schemas ──────────────────────────────────────────
class CommissionResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
affiliate_id: UUID
deal_id: UUID
amount: float
rate: float
status: str
approved_at: Optional[datetime] = None
paid_at: Optional[datetime] = None
created_at: datetime
# ── Dashboard Schemas ───────────────────────────────────────────
class DashboardSummary(BaseModel):
total_leads: int = 0
new_leads_today: int = 0
qualified_leads: int = 0
active_conversations: int = 0
meetings_today: int = 0
meetings_this_week: int = 0
total_deals: int = 0
deals_won: int = 0
pipeline_value: float = 0.0
revenue_this_month: float = 0.0
active_affiliates: int = 0
ai_conversations_today: int = 0
class PipelineSummary(BaseModel):
stage: str
count: int
total_value: float
class RevenueMetrics(BaseModel):
period: str
revenue: float
deals_closed: int
avg_deal_size: float
# ── Pagination ──────────────────────────────────────────────────
class PaginatedResponse(BaseModel):
items: List[Any]
total: int
page: int
page_size: int
pages: int

View File

@ -0,0 +1,26 @@
"""
Dealix Services Layer
Business logic for all core platform operations.
"""
from app.services.auth_service import AuthService
from app.services.lead_service import LeadService
from app.services.deal_service import DealService
from app.services.company_service import CompanyService
from app.services.meeting_service import MeetingService
from app.services.affiliate_service import AffiliateService
from app.services.notification_service import NotificationService
from app.services.analytics_service import AnalyticsService
from app.services.trust_score_service import TrustScoreService
__all__ = [
"AuthService",
"LeadService",
"DealService",
"CompanyService",
"MeetingService",
"AffiliateService",
"NotificationService",
"AnalyticsService",
"TrustScoreService",
]

View File

@ -0,0 +1,311 @@
"""
Affiliate Service Recruitment, commissions, career path, performance tracking.
"""
import uuid
from datetime import datetime, timezone
from decimal import Decimal
from typing import Optional
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
TIER_THRESHOLDS = {
"bronze": {"min_deals": 0, "commission_rate": 10.0},
"silver": {"min_deals": 5, "commission_rate": 12.5},
"gold": {"min_deals": 15, "commission_rate": 15.0},
"platinum": {"min_deals": 30, "commission_rate": 20.0},
}
CAREER_PATH = {
"affiliate": {"next": "senior_affiliate", "deals_required": 10, "months": 3},
"senior_affiliate": {"next": "team_lead", "deals_required": 25, "months": 6},
"team_lead": {"next": "employee", "deals_required": 50, "months": 12},
}
class AffiliateService:
"""Full affiliate lifecycle: recruitment, performance, commissions, career path."""
def __init__(self, db: AsyncSession):
self.db = db
# ── Recruitment ───────────────────────────────
async def apply(
self,
tenant_id: str,
user_id: str,
referral_code: str = None,
) -> dict:
from app.models.affiliate import Affiliate
import secrets
affiliate = Affiliate(
id=uuid.uuid4(),
tenant_id=uuid.UUID(tenant_id),
user_id=uuid.UUID(user_id),
status="applied",
tier="bronze",
referral_code=referral_code or secrets.token_urlsafe(8).upper()[:8],
commission_rate=Decimal("10.0"),
)
self.db.add(affiliate)
await self.db.flush()
return self._to_dict(affiliate)
async def approve(self, tenant_id: str, affiliate_id: str) -> Optional[dict]:
from app.models.affiliate import Affiliate
result = await self.db.execute(
select(Affiliate).where(
Affiliate.id == uuid.UUID(affiliate_id),
Affiliate.tenant_id == uuid.UUID(tenant_id),
)
)
aff = result.scalar_one_or_none()
if not aff:
return None
aff.status = "active"
aff.approved_at = datetime.now(timezone.utc)
await self.db.flush()
return self._to_dict(aff)
async def suspend(self, tenant_id: str, affiliate_id: str, reason: str = "") -> Optional[dict]:
from app.models.affiliate import Affiliate
result = await self.db.execute(
select(Affiliate).where(
Affiliate.id == uuid.UUID(affiliate_id),
Affiliate.tenant_id == uuid.UUID(tenant_id),
)
)
aff = result.scalar_one_or_none()
if not aff:
return None
aff.status = "suspended"
await self.db.flush()
return self._to_dict(aff)
# ── Commission Calculation ────────────────────
async def calculate_commission(
self,
tenant_id: str,
affiliate_id: str,
deal_id: str,
deal_value: float,
) -> dict:
from app.models.commission import Commission
from app.models.affiliate import Affiliate
result = await self.db.execute(
select(Affiliate).where(
Affiliate.id == uuid.UUID(affiliate_id),
Affiliate.tenant_id == uuid.UUID(tenant_id),
)
)
aff = result.scalar_one_or_none()
if not aff:
return {}
rate = float(aff.commission_rate)
amount = round(deal_value * rate / 100, 2)
commission = Commission(
id=uuid.uuid4(),
tenant_id=uuid.UUID(tenant_id),
affiliate_id=uuid.UUID(affiliate_id),
deal_id=uuid.UUID(deal_id),
amount=Decimal(str(amount)),
currency="SAR",
rate=aff.commission_rate,
status="pending",
period=datetime.now(timezone.utc).date().replace(day=1),
)
self.db.add(commission)
await self.db.flush()
return {
"commission_id": str(commission.id),
"amount": amount,
"rate": rate,
"status": "pending",
}
# ── Tier Progression ──────────────────────────
async def check_tier_upgrade(self, tenant_id: str, affiliate_id: str) -> Optional[dict]:
from app.models.affiliate import Affiliate, AffiliatePerformance
result = await self.db.execute(
select(Affiliate).where(
Affiliate.id == uuid.UUID(affiliate_id),
Affiliate.tenant_id == uuid.UUID(tenant_id),
)
)
aff = result.scalar_one_or_none()
if not aff:
return None
# Get total deals closed
perf_q = select(func.coalesce(func.sum(AffiliatePerformance.deals_closed), 0)).where(
AffiliatePerformance.affiliate_id == uuid.UUID(affiliate_id),
)
total_deals = (await self.db.execute(perf_q)).scalar() or 0
# Check upgrade
tiers = ["bronze", "silver", "gold", "platinum"]
current_idx = tiers.index(aff.tier) if aff.tier in tiers else 0
for i in range(current_idx + 1, len(tiers)):
tier = tiers[i]
if total_deals >= TIER_THRESHOLDS[tier]["min_deals"]:
aff.tier = tier
aff.commission_rate = Decimal(str(TIER_THRESHOLDS[tier]["commission_rate"]))
await self.db.flush()
return {
"upgraded": True,
"new_tier": tier,
"new_rate": TIER_THRESHOLDS[tier]["commission_rate"],
"total_deals": total_deals,
}
return {
"upgraded": False,
"current_tier": aff.tier,
"total_deals": total_deals,
"next_tier": tiers[current_idx + 1] if current_idx < len(tiers) - 1 else None,
"deals_needed": TIER_THRESHOLDS[tiers[min(current_idx + 1, len(tiers) - 1)]]["min_deals"] - total_deals,
}
# ── Career Path (Affiliate → Employee) ────────
async def check_career_path(self, tenant_id: str, affiliate_id: str) -> dict:
from app.models.affiliate import Affiliate, AffiliatePerformance
result = await self.db.execute(
select(Affiliate).where(
Affiliate.id == uuid.UUID(affiliate_id),
Affiliate.tenant_id == uuid.UUID(tenant_id),
)
)
aff = result.scalar_one_or_none()
if not aff:
return {}
perf_q = select(func.coalesce(func.sum(AffiliatePerformance.deals_closed), 0)).where(
AffiliatePerformance.affiliate_id == uuid.UUID(affiliate_id),
)
total_deals = (await self.db.execute(perf_q)).scalar() or 0
months_active = 0
if aff.approved_at:
delta = datetime.now(timezone.utc) - aff.approved_at.replace(tzinfo=timezone.utc)
months_active = delta.days // 30
# Employee eligibility
eligible = total_deals >= 50 and months_active >= 12
return {
"affiliate_id": str(affiliate_id),
"total_deals": total_deals,
"months_active": months_active,
"eligible_for_employment": eligible,
"current_tier": aff.tier,
"progress": {
"deals": {"current": total_deals, "required": 50, "percent": min(100, total_deals * 100 // 50)},
"months": {"current": months_active, "required": 12, "percent": min(100, months_active * 100 // 12)},
},
}
# ── Leaderboard ───────────────────────────────
async def get_leaderboard(self, tenant_id: str, limit: int = 20) -> list:
from app.models.affiliate import Affiliate, AffiliatePerformance
q = (
select(
Affiliate.id,
Affiliate.tier,
Affiliate.referral_code,
func.coalesce(func.sum(AffiliatePerformance.deals_closed), 0).label("total_deals"),
func.coalesce(func.sum(AffiliatePerformance.revenue_attributed), 0).label("total_revenue"),
func.coalesce(func.sum(AffiliatePerformance.commission_earned), 0).label("total_commission"),
)
.outerjoin(AffiliatePerformance, Affiliate.id == AffiliatePerformance.affiliate_id)
.where(
Affiliate.tenant_id == uuid.UUID(tenant_id),
Affiliate.status == "active",
)
.group_by(Affiliate.id, Affiliate.tier, Affiliate.referral_code)
.order_by(func.sum(AffiliatePerformance.revenue_attributed).desc().nullslast())
.limit(limit)
)
rows = (await self.db.execute(q)).all()
return [
{
"rank": i + 1,
"affiliate_id": str(row.id),
"tier": row.tier,
"referral_code": row.referral_code,
"total_deals": int(row.total_deals),
"total_revenue": float(row.total_revenue),
"total_commission": float(row.total_commission),
}
for i, row in enumerate(rows)
]
# ── Performance Summary ───────────────────────
async def get_performance(self, tenant_id: str, affiliate_id: str) -> dict:
from app.models.affiliate import AffiliatePerformance
q = select(AffiliatePerformance).where(
AffiliatePerformance.affiliate_id == uuid.UUID(affiliate_id),
).order_by(AffiliatePerformance.period.desc()).limit(12)
rows = (await self.db.execute(q)).scalars().all()
monthly = [
{
"period": row.period.isoformat() if row.period else None,
"leads_generated": row.leads_generated,
"deals_closed": row.deals_closed,
"revenue_attributed": float(row.revenue_attributed) if row.revenue_attributed else 0,
"commission_earned": float(row.commission_earned) if row.commission_earned else 0,
"conversion_rate": float(row.conversion_rate) if row.conversion_rate else 0,
}
for row in rows
]
return {
"affiliate_id": str(affiliate_id),
"monthly": monthly,
"totals": {
"leads": sum(m["leads_generated"] for m in monthly),
"deals": sum(m["deals_closed"] for m in monthly),
"revenue": sum(m["revenue_attributed"] for m in monthly),
"commission": sum(m["commission_earned"] for m in monthly),
},
}
@staticmethod
def _to_dict(aff) -> dict:
if not aff:
return {}
return {
"id": str(aff.id),
"tenant_id": str(aff.tenant_id),
"user_id": str(aff.user_id),
"status": aff.status,
"tier": aff.tier,
"referral_code": aff.referral_code,
"commission_rate": float(aff.commission_rate) if aff.commission_rate else 0,
"approved_at": aff.approved_at.isoformat() if aff.approved_at else None,
"created_at": aff.created_at.isoformat() if aff.created_at else None,
}

View File

@ -0,0 +1,98 @@
"""
Vector Embeddings & RAG Engine
Handles text embedding and semantic search using pgvector.
"""
import logging
from typing import List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from app.config import get_settings
logger = logging.getLogger("dealix.agents.embeddings")
settings = get_settings()
class EmbeddingsEngine:
"""Generates embeddings and performs vector search against knowledge base."""
def __init__(self, db: AsyncSession):
self.db = db
async def get_embedding(self, text: str) -> List[float]:
"""Generate vector embedding for text using configured provider."""
if settings.EMBEDDING_PROVIDER == "openai":
import openai
client = openai.AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
response = await client.embeddings.create(
input=text,
model=settings.EMBEDDING_MODEL
)
return response.data[0].embedding
else:
# Fallback for local models
raise NotImplementedError(f"Embedding provider {settings.EMBEDDING_PROVIDER} not fully implemented yet.")
async def add_knowledge(self, tenant_id: str, title: str, content: str, metadata: dict = None) -> str:
"""Embed document and store in database vector index."""
try:
vector = await self.get_embedding(f"{title}\n\n{content}")
# Using pgvector to insert knowledge.
query = text("""
INSERT INTO knowledge_articles (id, tenant_id, title, content, embedding, metadata)
VALUES (gen_random_uuid(), :tenant_id, :title, :content, :embedding, :metadata)
RETURNING id
""")
# Note: The knowledge_articles model needs to have the vector column added
# We'll use raw SQL here to interface directly with pgvector
# We assume the column `embedding` exists as vector(1536)
import json
result = await self.db.execute(query, {
"tenant_id": tenant_id,
"title": title,
"content": content,
"embedding": str(vector), # pgvector parses strings of arrays directly
"metadata": json.dumps(metadata or {})
})
await self.db.flush()
return str(result.scalar())
except Exception as e:
logger.error(f"Failed to add knowledge: {e}")
raise
async def search_knowledge(self, tenant_id: str, query_text: str, limit: int = 3) -> List[dict]:
"""Semantic search using L2 distance (or cosine similarity via pgvector)."""
try:
query_vector = await self.get_embedding(query_text)
# Using pgvector cosine distance `<=>` operator to find closest rows
query = text("""
SELECT id, title, content, metadata, 1 - (embedding <=> :query_vector) as similarity
FROM knowledge_articles
WHERE tenant_id = :tenant_id
ORDER BY embedding <=> :query_vector
LIMIT :limit
""")
result = await self.db.execute(query, {
"tenant_id": tenant_id,
"query_vector": str(query_vector),
"limit": limit
})
rows = result.fetchall()
return [
{
"id": str(row.id),
"title": row.title,
"content": row.content,
"metadata": row.metadata,
"similarity": float(row.similarity)
}
for row in rows
]
except Exception as e:
logger.error(f"Failed to search knowledge: {e}")
return []

View File

@ -0,0 +1,339 @@
"""
Agent Executor Runs AI agents with LLM calls, input validation,
output parsing, escalation checks, and action dispatch.
This is the engine that powers every single AI agent in Dealix.
"""
import time
import uuid
import json
import logging
from typing import Optional
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.llm.provider import get_llm, LLMResponse
from app.services.agents.router import AgentRouter
logger = logging.getLogger("dealix.agents.executor")
# Load prompt files path
PROMPTS_DIR = Path(__file__).parent.parent.parent.parent.parent / "ai-agents" / "prompts"
class AgentResult:
"""Standardized agent execution result."""
def __init__(self, agent_type: str, output: dict, tokens_used: int = 0,
latency_ms: int = 0, status: str = "success",
escalation: dict = None, actions: list = None):
self.agent_type = agent_type
self.output = output
self.tokens_used = tokens_used
self.latency_ms = latency_ms
self.status = status # success, error, escalated
self.escalation = escalation # {needed: bool, reason: str, target: str}
self.actions = actions or [] # [{type: "send_message", ...}, ...]
def to_dict(self) -> dict:
return {
"agent_type": self.agent_type,
"output": self.output,
"tokens_used": self.tokens_used,
"latency_ms": self.latency_ms,
"status": self.status,
"escalation": self.escalation,
"actions": self.actions,
}
class AgentExecutor:
"""
Executes AI agents by:
1. Loading the agent's system prompt
2. Building context from input data
3. Calling the LLM
4. Parsing structured output
5. Checking escalation rules
6. Dispatching actions (DB updates, messages, bookings)
7. Logging to ai_conversations
"""
def __init__(self, db: AsyncSession):
self.db = db
self.llm = get_llm()
self.router = AgentRouter()
async def execute(self, agent_type: str, input_data: dict,
tenant_id: str = None, lead_id: str = None,
conversation_id: str = None) -> AgentResult:
"""Execute an AI agent and return structured result."""
start = time.time()
try:
# 1. Load system prompt
system_prompt = self._load_prompt(agent_type)
# 2. Build user message from input data
user_message = self._build_user_message(agent_type, input_data)
# 3. Call LLM
llm_response = await self.llm.complete(
system_prompt=system_prompt,
user_message=user_message,
json_mode=True,
temperature=self._get_temperature(agent_type),
max_tokens=self._get_max_tokens(agent_type),
)
# 4. Parse output
output = llm_response.parse_json()
if output is None:
output = {"raw_response": llm_response.content}
# 5. Check escalation
escalation = self._check_escalation(agent_type, output, input_data)
# 6. Build actions
actions = self._build_actions(agent_type, output, input_data)
latency = int((time.time() - start) * 1000)
result = AgentResult(
agent_type=agent_type,
output=output,
tokens_used=llm_response.tokens_used,
latency_ms=latency,
status="escalated" if escalation and escalation.get("needed") else "success",
escalation=escalation,
actions=actions,
)
# 7. Log to database
await self._log_conversation(
tenant_id=tenant_id,
agent_type=agent_type,
lead_id=lead_id,
input_data=input_data,
output=result.to_dict(),
tokens_used=llm_response.tokens_used,
latency_ms=latency,
status=result.status,
)
logger.info(
f"Agent {agent_type} executed: "
f"tokens={llm_response.tokens_used} "
f"latency={latency}ms "
f"status={result.status}"
)
return result
except Exception as e:
latency = int((time.time() - start) * 1000)
logger.error(f"Agent {agent_type} failed: {e}")
result = AgentResult(
agent_type=agent_type,
output={"error": str(e)},
latency_ms=latency,
status="error",
)
await self._log_conversation(
tenant_id=tenant_id,
agent_type=agent_type,
lead_id=lead_id,
input_data=input_data,
output=result.to_dict(),
tokens_used=0,
latency_ms=latency,
status="error",
)
return result
async def execute_event(self, event_type: str, input_data: dict,
tenant_id: str = None, **kwargs) -> list[AgentResult]:
"""Execute all agents registered for an event type."""
agent_ids = self.router.get_agents_for_event(event_type)
results = []
for agent_id in agent_ids:
result = await self.execute(
agent_type=agent_id,
input_data=input_data,
tenant_id=tenant_id,
**kwargs,
)
results.append(result)
# Stop chain if escalation needed
if result.escalation and result.escalation.get("needed"):
logger.info(f"Agent chain stopped at {agent_id} due to escalation")
break
return results
# ── Prompt Loading ──────────────────────────────
def _load_prompt(self, agent_type: str) -> str:
"""Load system prompt from the ai-agents/prompts directory."""
# Map agent_type to filename
filename_map = {
"lead_qualification": "lead-qualification-agent.md",
"arabic_whatsapp": "arabic-whatsapp-agent.md",
"english_conversation": "english-conversation-agent.md",
"outreach_writer": "outreach-message-writer.md",
"meeting_booking": "meeting-booking-agent.md",
"objection_handler": "objection-handling-agent.md",
"proposal_drafter": "proposal-drafting-agent.md",
"sector_strategist": "sector-sales-strategist.md",
"knowledge_retrieval": "knowledge-retrieval-agent.md",
"compliance_reviewer": "compliance-reviewer.md",
"fraud_reviewer": "fraud-reviewer.md",
"revenue_attribution": "revenue-attribution-agent.md",
"management_summary": "management-summary-agent.md",
"qa_reviewer": "conversation-qa-reviewer.md",
"affiliate_evaluator": "affiliate-recruitment-evaluator.md",
"onboarding_coach": "affiliate-onboarding-coach.md",
"guarantee_reviewer": "guarantee-claim-reviewer.md",
"voice_call": "voice-call-flow-agent.md",
}
filename = filename_map.get(agent_type)
if not filename:
return f"You are the {agent_type} agent for Dealix. Respond with structured JSON."
prompt_path = PROMPTS_DIR / filename
if prompt_path.exists():
return prompt_path.read_text(encoding="utf-8")
else:
logger.warning(f"Prompt file not found: {prompt_path}")
return f"You are the {agent_type} agent for Dealix. Respond with structured JSON."
def _build_user_message(self, agent_type: str, input_data: dict) -> str:
"""Build the user message from input data."""
# General format: JSON dump of input data with clear instructions
context = json.dumps(input_data, ensure_ascii=False, indent=2, default=str)
return f"""## Input Data
{context}
## Instructions
Process this input according to your role and return a structured JSON response.
Include all required output fields as defined in your schema.
Use Arabic where appropriate (especially for client-facing content).
Respond ONLY with valid JSON."""
# ── Configuration per Agent ────────────────────
def _get_temperature(self, agent_type: str) -> float:
"""Agent-specific temperature settings."""
# Creative agents need higher temperature
creative = {"outreach_writer": 0.7, "proposal_drafter": 0.5, "sector_strategist": 0.5}
# Analytical agents need low temperature
analytical = {
"lead_qualification": 0.1, "compliance_reviewer": 0.1,
"fraud_reviewer": 0.1, "revenue_attribution": 0.1,
}
return creative.get(agent_type, analytical.get(agent_type, 0.3))
def _get_max_tokens(self, agent_type: str) -> int:
"""Agent-specific max token settings."""
verbose = {"proposal_drafter": 4096, "management_summary": 4096, "sector_strategist": 3000}
return verbose.get(agent_type, 2048)
# ── Escalation Rules ──────────────────────────
def _check_escalation(self, agent_type: str, output: dict, input_data: dict) -> Optional[dict]:
"""Check if the agent output requires escalation to a human."""
escalation = output.get("escalation", {})
if isinstance(escalation, dict) and escalation.get("needed"):
return escalation
# Agent-specific checks
if agent_type == "arabic_whatsapp":
confidence = output.get("confidence", 1.0)
if confidence < 0.5:
return {"needed": True, "reason": "Low confidence response", "target": "human_agent"}
if agent_type == "lead_qualification":
score = output.get("score", 50)
if 40 <= score <= 60:
return {"needed": True, "reason": "Ambiguous qualification score", "target": "sales_manager"}
if agent_type == "fraud_reviewer":
risk_score = output.get("risk_score", 0)
if risk_score > 80:
return {"needed": True, "reason": "High fraud risk detected", "target": "admin"}
return None
# ── Action Building ───────────────────────────
def _build_actions(self, agent_type: str, output: dict, input_data: dict) -> list:
"""Build a list of actions to execute based on agent output."""
actions = []
if agent_type == "arabic_whatsapp" and output.get("response_message_ar"):
actions.append({
"type": "send_whatsapp",
"message": output["response_message_ar"],
"phone": input_data.get("contact_phone", ""),
})
if agent_type == "meeting_booking" and output.get("meeting_booked", {}).get("confirmed"):
actions.append({
"type": "create_meeting",
"datetime": output["meeting_booked"].get("datetime"),
"lead_id": input_data.get("lead_id"),
})
if agent_type == "outreach_writer" and output.get("draft_message"):
actions.append({
"type": "queue_message",
"channel": input_data.get("channel", "whatsapp"),
"message": output["draft_message"],
})
if agent_type == "lead_qualification":
actions.append({
"type": "update_lead_score",
"lead_id": input_data.get("lead_id"),
"score": output.get("score", 0),
"status": output.get("status_recommendation", "contacted"),
})
return actions
# ── Database Logging ──────────────────────────
async def _log_conversation(self, tenant_id: str, agent_type: str,
lead_id: str, input_data: dict, output: dict,
tokens_used: int, latency_ms: int, status: str):
"""Log agent execution to ai_conversations table."""
try:
from app.models.ai_conversation import AIConversation
log_entry = AIConversation(
tenant_id=uuid.UUID(tenant_id) if tenant_id else None,
contact_name=input_data.get("contact_name"),
contact_phone=input_data.get("contact_phone"),
channel="system",
status=status,
lead_id=uuid.UUID(lead_id) if lead_id else None,
context={
"agent_type": agent_type,
"input": input_data,
"output": output,
"tokens_used": tokens_used,
"latency_ms": latency_ms,
},
)
self.db.add(log_entry)
await self.db.flush()
except Exception as e:
logger.error(f"Failed to log agent conversation: {e}")

View File

@ -0,0 +1,86 @@
"""
Agent Router Determines which AI agent handles which event.
The central nervous system of Dealix's AI engine.
"""
import logging
from typing import Optional
from uuid import UUID
logger = logging.getLogger("dealix.agents")
# ── Event → Agent Mapping ─────────────────────────────────────
AGENT_REGISTRY = {
# Lead lifecycle
"lead_created": ["lead_qualification"],
"lead_score_updated": ["lead_qualification"],
"lead_qualified": ["outreach_writer", "meeting_booking"],
# Communication
"whatsapp_inbound": ["arabic_whatsapp"],
"whatsapp_outbound": ["outreach_writer"],
"email_inbound": ["english_conversation"],
"email_outbound": ["outreach_writer"],
"voice_call_completed": ["voice_call"],
# Meeting lifecycle
"meeting_requested": ["meeting_booking"],
"meeting_confirmed": ["ai_rehearsal"],
"meeting_upcoming": ["ai_rehearsal"],
# Deal lifecycle
"deal_created": ["sector_strategist"],
"deal_stage_changed": ["proposal_drafter"],
"deal_proposal_requested": ["proposal_drafter"],
# Quality & Compliance
"content_review": ["qa_reviewer"],
"compliance_check": ["compliance_reviewer"],
"objection_detected": ["objection_handler"],
# Affiliate lifecycle
"affiliate_applied": ["affiliate_evaluator"],
"affiliate_approved": ["onboarding_coach"],
# Analytics
"revenue_attribution": ["revenue_attribution"],
"fraud_check": ["fraud_reviewer"],
"guarantee_claim": ["guarantee_reviewer"],
"management_report": ["management_summary"],
# Knowledge
"knowledge_query": ["knowledge_retrieval"],
"sector_strategy": ["sector_strategist"],
}
class AgentRouter:
"""Routes events to the appropriate AI agent(s)."""
def get_agents_for_event(self, event_type: str) -> list[str]:
"""Return list of agent IDs that should handle this event."""
agents = AGENT_REGISTRY.get(event_type, [])
if not agents:
logger.warning(f"No agent registered for event: {event_type}")
return agents
def get_primary_agent(self, event_type: str) -> Optional[str]:
"""Return the primary (first) agent for an event."""
agents = self.get_agents_for_event(event_type)
return agents[0] if agents else None
def list_all_agents(self) -> list[dict]:
"""List all registered agents with their event triggers."""
agent_events = {}
for event, agents in AGENT_REGISTRY.items():
for agent in agents:
if agent not in agent_events:
agent_events[agent] = []
agent_events[agent].append(event)
return [
{"agent_id": agent_id, "events": events}
for agent_id, events in agent_events.items()
]

View File

@ -0,0 +1,220 @@
"""
Analytics Service ROI tracking, conversion funnels, channel performance.
"""
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy import select, func, and_, case, extract
from sqlalchemy.ext.asyncio import AsyncSession
class AnalyticsService:
"""Platform-wide analytics and ROI tracking for B2B clients."""
def __init__(self, db: AsyncSession):
self.db = db
async def get_kpi_summary(self, tenant_id: str, days: int = 30) -> dict:
from app.models.lead import Lead
from app.models.deal import Deal
tid = uuid.UUID(tenant_id)
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
# Leads
total_leads = await self._count(Lead, tid)
new_leads = await self._count(Lead, tid, Lead.created_at >= cutoff)
qualified = await self._count(Lead, tid, Lead.status == "qualified")
converted = await self._count(Lead, tid, Lead.status == "converted")
# Deals
total_deals = await self._count(Deal, tid)
won_deals = await self._count(Deal, tid, Deal.stage == "closed_won")
total_revenue = await self._sum(Deal, Deal.value, tid, Deal.stage == "closed_won")
pipeline_value = await self._sum(
Deal, Deal.value, tid,
Deal.stage.in_(["discovery", "proposal", "negotiation"])
)
# Rates
conversion_rate = (converted / total_leads * 100) if total_leads > 0 else 0
win_rate = (won_deals / total_deals * 100) if total_deals > 0 else 0
return {
"period_days": days,
"leads": {
"total": total_leads,
"new": new_leads,
"qualified": qualified,
"converted": converted,
"conversion_rate": round(conversion_rate, 1),
},
"deals": {
"total": total_deals,
"won": won_deals,
"win_rate": round(win_rate, 1),
"total_revenue": total_revenue,
"pipeline_value": pipeline_value,
},
"roi": {
"revenue": total_revenue,
"cost_per_lead": 0, # Calculated when billing is active
"cost_per_meeting": 0,
"cost_per_deal": 0,
},
}
async def get_conversion_funnel(self, tenant_id: str) -> dict:
from app.models.lead import Lead
tid = uuid.UUID(tenant_id)
stages = {
"total_leads": await self._count(Lead, tid),
"contacted": await self._count(Lead, tid, Lead.status.in_(["contacted", "qualified", "converted"])),
"qualified": await self._count(Lead, tid, Lead.status.in_(["qualified", "converted"])),
"converted": await self._count(Lead, tid, Lead.status == "converted"),
}
total = stages["total_leads"] or 1
funnel = [
{"stage": "العملاء المحتملين", "stage_en": "Leads", "count": stages["total_leads"], "rate": 100},
{"stage": "تم التواصل", "stage_en": "Contacted", "count": stages["contacted"], "rate": round(stages["contacted"] / total * 100, 1)},
{"stage": "مؤهل", "stage_en": "Qualified", "count": stages["qualified"], "rate": round(stages["qualified"] / total * 100, 1)},
{"stage": "تم التحويل", "stage_en": "Converted", "count": stages["converted"], "rate": round(stages["converted"] / total * 100, 1)},
]
return {"funnel": funnel}
async def get_channel_performance(self, tenant_id: str) -> dict:
from app.models.lead import Lead
tid = uuid.UUID(tenant_id)
q = (
select(
Lead.source,
func.count().label("count"),
func.avg(Lead.score).label("avg_score"),
)
.where(Lead.tenant_id == tid)
.group_by(Lead.source)
.order_by(func.count().desc())
)
rows = (await self.db.execute(q)).all()
channels = []
for row in rows:
converted_q = select(func.count()).where(
Lead.tenant_id == tid,
Lead.source == row.source,
Lead.status == "converted",
)
converted = (await self.db.execute(converted_q)).scalar() or 0
channels.append({
"channel": row.source,
"leads": row.count,
"avg_score": round(float(row.avg_score or 0), 1),
"converted": converted,
"conversion_rate": round(converted / row.count * 100, 1) if row.count > 0 else 0,
})
return {"channels": channels}
async def get_sector_performance(self, tenant_id: str) -> dict:
from app.models.lead import Lead
tid = uuid.UUID(tenant_id)
q = (
select(
Lead.sector,
func.count().label("total"),
func.avg(Lead.score).label("avg_score"),
)
.where(Lead.tenant_id == tid, Lead.sector != "")
.group_by(Lead.sector)
.order_by(func.count().desc())
)
rows = (await self.db.execute(q)).all()
sectors = []
for row in rows:
converted_q = select(func.count()).where(
Lead.tenant_id == tid,
Lead.sector == row.sector,
Lead.status == "converted",
)
converted = (await self.db.execute(converted_q)).scalar() or 0
sectors.append({
"sector": row.sector,
"total_leads": row.total,
"avg_score": round(float(row.avg_score or 0), 1),
"converted": converted,
"conversion_rate": round(converted / row.total * 100, 1) if row.total > 0 else 0,
})
return {"sectors": sectors}
async def get_agent_performance(self, tenant_id: str) -> dict:
from app.models.lead import Lead
from app.models.user import User
tid = uuid.UUID(tenant_id)
agents_q = select(User).where(
User.tenant_id == tid,
User.role.in_(["agent", "manager"]),
User.is_active == True,
)
agents = (await self.db.execute(agents_q)).scalars().all()
performance = []
for agent in agents:
total = await self._count(Lead, tid, Lead.assigned_to == agent.id)
converted = await self._count(
Lead, tid, Lead.assigned_to == agent.id, Lead.status == "converted"
)
performance.append({
"agent_id": str(agent.id),
"name": agent.full_name,
"total_leads": total,
"converted": converted,
"conversion_rate": round(converted / total * 100, 1) if total > 0 else 0,
})
performance.sort(key=lambda x: x["converted"], reverse=True)
return {"agents": performance}
async def get_trends(self, tenant_id: str, days: int = 90) -> dict:
from app.models.lead import Lead
tid = uuid.UUID(tenant_id)
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
q = (
select(
func.date_trunc("day", Lead.created_at).label("day"),
func.count().label("count"),
)
.where(Lead.tenant_id == tid, Lead.created_at >= cutoff)
.group_by("day")
.order_by("day")
)
rows = (await self.db.execute(q)).all()
return {
"daily_leads": [
{"date": str(row.day), "count": row.count} for row in rows
],
}
# ── Helpers ───────────────────────────────────
async def _count(self, model, tenant_id, *filters):
q = select(func.count()).where(model.tenant_id == tenant_id, *filters)
return (await self.db.execute(q)).scalar() or 0
async def _sum(self, model, field, tenant_id, *filters):
q = select(func.coalesce(func.sum(field), 0)).where(
model.tenant_id == tenant_id, *filters
)
return float((await self.db.execute(q)).scalar() or 0)

View File

@ -0,0 +1,205 @@
"""
Auth Service JWT tokens, RBAC, OTP, multi-tenant authentication.
"""
import secrets
import string
from datetime import datetime, timedelta, timezone
from typing import Optional
from uuid import UUID
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
class AuthService:
"""Handles authentication, authorization, and tenant isolation."""
def __init__(self, db: AsyncSession):
self.db = db
# ── Password Hashing ──────────────────────────
@staticmethod
def hash_password(password: str) -> str:
return pwd_context.hash(password)
@staticmethod
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
# ── JWT Tokens ────────────────────────────────
@staticmethod
def create_access_token(
user_id: str,
tenant_id: str,
role: str,
extra: dict = None,
) -> str:
payload = {
"sub": user_id,
"tenant_id": tenant_id,
"role": role,
"type": "access",
"exp": datetime.now(timezone.utc)
+ timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
"iat": datetime.now(timezone.utc),
}
if extra:
payload.update(extra)
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
@staticmethod
def create_refresh_token(user_id: str, tenant_id: str) -> str:
payload = {
"sub": user_id,
"tenant_id": tenant_id,
"type": "refresh",
"exp": datetime.now(timezone.utc)
+ timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
"iat": datetime.now(timezone.utc),
}
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
@staticmethod
def decode_token(token: str) -> Optional[dict]:
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
return payload
except JWTError:
return None
# ── OTP ───────────────────────────────────────
@staticmethod
def generate_otp() -> str:
return "".join(
secrets.choice(string.digits) for _ in range(settings.OTP_LENGTH)
)
@staticmethod
def verify_otp(stored_otp: str, provided_otp: str, created_at: datetime) -> bool:
if stored_otp != provided_otp:
return False
expiry = created_at + timedelta(minutes=settings.OTP_EXPIRE_MINUTES)
return datetime.now(timezone.utc) <= expiry
# ── Registration ──────────────────────────────
async def register_tenant(
self,
name: str,
email: str,
password: str,
phone: str = "",
plan: str = "free",
) -> dict:
"""Register a new tenant with an owner user."""
from app.models.tenant import Tenant
from app.models.user import User
import uuid
tenant_id = uuid.uuid4()
user_id = uuid.uuid4()
slug = name.lower().replace(" ", "-").replace(".", "")[:50]
tenant = Tenant(
id=tenant_id,
name=name,
slug=slug,
plan=plan,
is_active=True,
)
self.db.add(tenant)
user = User(
id=user_id,
tenant_id=tenant_id,
email=email,
phone=phone,
hashed_password=self.hash_password(password),
full_name=name,
role="owner",
language="ar",
is_active=True,
)
self.db.add(user)
await self.db.flush()
access = self.create_access_token(str(user_id), str(tenant_id), "owner")
refresh = self.create_refresh_token(str(user_id), str(tenant_id))
return {
"user_id": str(user_id),
"tenant_id": str(tenant_id),
"access_token": access,
"refresh_token": refresh,
"token_type": "bearer",
}
async def login(self, email: str, password: str) -> Optional[dict]:
"""Authenticate user and return tokens."""
from app.models.user import User
result = await self.db.execute(
select(User).where(User.email == email, User.is_active == True)
)
user = result.scalar_one_or_none()
if not user or not self.verify_password(password, user.hashed_password):
return None
user.last_login_at = datetime.now(timezone.utc)
await self.db.flush()
access = self.create_access_token(
str(user.id), str(user.tenant_id), user.role
)
refresh = self.create_refresh_token(str(user.id), str(user.tenant_id))
return {
"user_id": str(user.id),
"tenant_id": str(user.tenant_id),
"role": user.role,
"access_token": access,
"refresh_token": refresh,
"token_type": "bearer",
}
async def get_current_user(self, token: str) -> Optional[dict]:
"""Validate token and return user info."""
payload = self.decode_token(token)
if not payload or payload.get("type") != "access":
return None
return {
"user_id": payload["sub"],
"tenant_id": payload["tenant_id"],
"role": payload["role"],
}
# ── RBAC Helpers ──────────────────────────────
ROLE_HIERARCHY = {
"viewer": 0,
"affiliate": 1,
"agent": 2,
"manager": 3,
"admin": 4,
"owner": 5,
}
@classmethod
def has_permission(cls, user_role: str, required_role: str) -> bool:
return cls.ROLE_HIERARCHY.get(user_role, 0) >= cls.ROLE_HIERARCHY.get(
required_role, 0
)

View File

@ -0,0 +1,201 @@
"""
Company Service B2B company management, enrichment, CR validation.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
class CompanyService:
"""Manages B2B company profiles and account intelligence."""
def __init__(self, db: AsyncSession):
self.db = db
async def create_company(
self,
tenant_id: str,
name: str,
name_ar: str = "",
sector: str = "",
size: str = "small",
city: str = "",
region: str = "",
cr_number: str = "",
website: str = "",
) -> dict:
from app.models.company import Company
company = Company(
id=uuid.uuid4(),
tenant_id=uuid.UUID(tenant_id),
name=name,
name_ar=name_ar,
sector=sector,
size=size,
city=city,
region=region,
cr_number=cr_number,
website=website,
is_active=True,
)
self.db.add(company)
await self.db.flush()
return self._to_dict(company)
async def get_company(self, tenant_id: str, company_id: str) -> Optional[dict]:
from app.models.company import Company
result = await self.db.execute(
select(Company).where(
Company.id == uuid.UUID(company_id),
Company.tenant_id == uuid.UUID(tenant_id),
)
)
c = result.scalar_one_or_none()
return self._to_dict(c) if c else None
async def list_companies(
self,
tenant_id: str,
sector: str = None,
size: str = None,
city: str = None,
search: str = None,
page: int = 1,
per_page: int = 25,
) -> dict:
from app.models.company import Company
query = select(Company).where(
Company.tenant_id == uuid.UUID(tenant_id),
Company.is_active == True,
)
if sector:
query = query.where(Company.sector == sector)
if size:
query = query.where(Company.size == size)
if city:
query = query.where(Company.city == city)
if search:
pattern = f"%{search}%"
query = query.where(
or_(
Company.name.ilike(pattern),
Company.name_ar.ilike(pattern),
Company.cr_number.ilike(pattern),
)
)
count_q = select(func.count()).select_from(query.subquery())
total = (await self.db.execute(count_q)).scalar() or 0
query = query.order_by(Company.created_at.desc())
query = query.offset((page - 1) * per_page).limit(per_page)
result = await self.db.execute(query)
companies = [self._to_dict(c) for c in result.scalars().all()]
return {"items": companies, "total": total, "page": page, "per_page": per_page}
async def update_company(self, tenant_id: str, company_id: str, **updates) -> Optional[dict]:
from app.models.company import Company
result = await self.db.execute(
select(Company).where(
Company.id == uuid.UUID(company_id),
Company.tenant_id == uuid.UUID(tenant_id),
)
)
company = result.scalar_one_or_none()
if not company:
return None
for key, value in updates.items():
if hasattr(company, key) and value is not None:
setattr(company, key, value)
company.updated_at = datetime.now(timezone.utc)
await self.db.flush()
return self._to_dict(company)
async def get_company_contacts(self, tenant_id: str, company_id: str) -> list:
from app.models.company import Contact
result = await self.db.execute(
select(Contact).where(
Contact.company_id == uuid.UUID(company_id),
Contact.tenant_id == uuid.UUID(tenant_id),
)
)
return [
{
"id": str(c.id),
"full_name": c.full_name,
"job_title": c.job_title,
"email": c.email,
"phone": c.phone,
}
for c in result.scalars().all()
]
async def get_company_deals(self, tenant_id: str, company_id: str) -> list:
from app.models.deal import Deal
from app.models.lead import Lead
result = await self.db.execute(
select(Deal)
.join(Lead, Deal.lead_id == Lead.id)
.where(
Deal.tenant_id == uuid.UUID(tenant_id),
Lead.company_name != "",
)
)
return [
{
"id": str(d.id),
"title": d.title,
"stage": d.stage,
"value": float(d.value) if d.value else 0,
}
for d in result.scalars().all()
]
async def get_sector_breakdown(self, tenant_id: str) -> dict:
from app.models.company import Company
q = (
select(Company.sector, func.count().label("count"))
.where(
Company.tenant_id == uuid.UUID(tenant_id),
Company.is_active == True,
Company.sector != "",
)
.group_by(Company.sector)
.order_by(func.count().desc())
)
rows = (await self.db.execute(q)).all()
return {row.sector: row.count for row in rows}
@staticmethod
def _to_dict(company) -> dict:
if not company:
return {}
return {
"id": str(company.id),
"tenant_id": str(company.tenant_id),
"name": company.name,
"name_ar": company.name_ar,
"sector": company.sector,
"size": company.size,
"city": company.city,
"region": company.region,
"cr_number": company.cr_number,
"website": company.website,
"is_active": company.is_active,
"created_at": company.created_at.isoformat() if company.created_at else None,
}

View File

@ -0,0 +1,269 @@
"""
CRM Sync Service Bidirectional sync with Salesforce, HubSpot, and generic CRMs.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
settings = get_settings()
class CRMSyncService:
"""
Manages bidirectional data sync between Dealix and external CRM systems.
Supports Salesforce, HubSpot, and generic webhook-based CRMs.
"""
def __init__(self, db: AsyncSession):
self.db = db
# ── Salesforce ────────────────────────────────
async def salesforce_push_lead(self, lead: dict, credentials: dict) -> dict:
"""Push a lead from Dealix to Salesforce."""
access_token = credentials.get("access_token")
instance_url = credentials.get("instance_url")
if not access_token or not instance_url:
return {"status": "error", "message": "Invalid Salesforce credentials"}
sf_lead = {
"FirstName": lead.get("full_name", "").split()[0] if lead.get("full_name") else "",
"LastName": lead.get("full_name", "").split()[-1] if lead.get("full_name") else "Unknown",
"Phone": lead.get("phone", ""),
"Email": lead.get("email", ""),
"Company": lead.get("company_name", "Unknown"),
"Industry": lead.get("sector", ""),
"City": lead.get("city", ""),
"LeadSource": f"Dealix - {lead.get('source', 'web')}",
"Description": lead.get("notes", ""),
"Rating": self._score_to_sf_rating(lead.get("score", 0)),
}
async with httpx.AsyncClient() as client:
response = await client.post(
f"{instance_url}/services/data/{settings.SALESFORCE_API_VERSION}/sobjects/Lead/",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
},
json=sf_lead,
)
if response.status_code in (200, 201):
sf_id = response.json().get("id")
return {"status": "success", "salesforce_id": sf_id}
return {"status": "error", "message": response.text}
async def salesforce_pull_leads(self, credentials: dict, since: str = None) -> list:
"""Pull leads from Salesforce into Dealix."""
access_token = credentials.get("access_token")
instance_url = credentials.get("instance_url")
query = "SELECT Id, FirstName, LastName, Phone, Email, Company, Industry, City, Rating FROM Lead"
if since:
query += f" WHERE LastModifiedDate > {since}"
query += " ORDER BY LastModifiedDate DESC LIMIT 100"
async with httpx.AsyncClient() as client:
response = await client.get(
f"{instance_url}/services/data/{settings.SALESFORCE_API_VERSION}/query/",
params={"q": query},
headers={"Authorization": f"Bearer {access_token}"},
)
if response.status_code != 200:
return []
records = response.json().get("records", [])
return [
{
"external_id": r["Id"],
"full_name": f"{r.get('FirstName', '')} {r.get('LastName', '')}".strip(),
"phone": r.get("Phone", ""),
"email": r.get("Email", ""),
"company_name": r.get("Company", ""),
"sector": r.get("Industry", ""),
"city": r.get("City", ""),
}
for r in records
]
# ── HubSpot ───────────────────────────────────
async def hubspot_push_contact(self, lead: dict, api_key: str) -> dict:
"""Push a contact from Dealix to HubSpot."""
hs_contact = {
"properties": {
"firstname": lead.get("full_name", "").split()[0] if lead.get("full_name") else "",
"lastname": lead.get("full_name", "").split()[-1] if lead.get("full_name") else "",
"phone": lead.get("phone", ""),
"email": lead.get("email", ""),
"company": lead.get("company_name", ""),
"industry": lead.get("sector", ""),
"city": lead.get("city", ""),
"leadsource": f"Dealix - {lead.get('source', 'web')}",
"hs_lead_status": self._status_to_hs(lead.get("status", "new")),
}
}
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api.hubapi.com/crm/v3/objects/contacts",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json=hs_contact,
)
if response.status_code in (200, 201):
hs_id = response.json().get("id")
return {"status": "success", "hubspot_id": hs_id}
return {"status": "error", "message": response.text}
async def hubspot_pull_contacts(self, api_key: str, after: str = None) -> list:
"""Pull contacts from HubSpot into Dealix."""
params = {
"limit": 100,
"properties": "firstname,lastname,phone,email,company,industry,city",
}
if after:
params["after"] = after
async with httpx.AsyncClient() as client:
response = await client.get(
"https://api.hubapi.com/crm/v3/objects/contacts",
params=params,
headers={"Authorization": f"Bearer {api_key}"},
)
if response.status_code != 200:
return []
results = response.json().get("results", [])
return [
{
"external_id": r["id"],
"full_name": f"{r['properties'].get('firstname', '')} {r['properties'].get('lastname', '')}".strip(),
"phone": r["properties"].get("phone", ""),
"email": r["properties"].get("email", ""),
"company_name": r["properties"].get("company", ""),
"sector": r["properties"].get("industry", ""),
"city": r["properties"].get("city", ""),
}
for r in results
]
# ── Generic Sync ──────────────────────────────
async def sync_lead_to_crm(
self, tenant_id: str, lead_id: str, provider: str
) -> dict:
"""Sync a lead to the configured CRM for this tenant."""
from app.services.lead_service import LeadService
lead_svc = LeadService(self.db)
lead = await lead_svc.get_lead(tenant_id, lead_id)
if not lead:
return {"status": "error", "message": "Lead not found"}
# Get CRM credentials for tenant (from tenant settings)
credentials = await self._get_crm_credentials(tenant_id, provider)
if not credentials:
return {"status": "error", "message": f"No {provider} credentials configured"}
if provider == "salesforce":
return await self.salesforce_push_lead(lead, credentials)
elif provider == "hubspot":
return await self.hubspot_push_contact(lead, credentials.get("api_key", ""))
return {"status": "error", "message": f"Unsupported provider: {provider}"}
async def full_sync(self, tenant_id: str, provider: str) -> dict:
"""Full bidirectional sync with CRM."""
pushed = 0
pulled = 0
errors = []
credentials = await self._get_crm_credentials(tenant_id, provider)
if not credentials:
return {"status": "error", "message": "No credentials configured"}
# Pull from CRM
try:
if provider == "salesforce":
external_leads = await self.salesforce_pull_leads(credentials)
elif provider == "hubspot":
external_leads = await self.hubspot_pull_contacts(credentials.get("api_key", ""))
else:
external_leads = []
from app.services.lead_service import LeadService
lead_svc = LeadService(self.db)
for ext_lead in external_leads:
try:
await lead_svc.create_lead(
tenant_id=tenant_id,
full_name=ext_lead["full_name"],
phone=ext_lead.get("phone", ""),
email=ext_lead.get("email", ""),
company_name=ext_lead.get("company_name", ""),
sector=ext_lead.get("sector", ""),
city=ext_lead.get("city", ""),
source=provider,
)
pulled += 1
except Exception as e:
errors.append({"type": "pull", "error": str(e)})
except Exception as e:
errors.append({"type": "pull_batch", "error": str(e)})
return {
"status": "completed",
"pushed": pushed,
"pulled": pulled,
"errors": errors,
}
# ── Helpers ───────────────────────────────────
async def _get_crm_credentials(self, tenant_id: str, provider: str) -> Optional[dict]:
"""Get CRM credentials from tenant settings."""
# In production, this would fetch from encrypted tenant settings
if provider == "salesforce":
return {
"access_token": settings.SALESFORCE_CLIENT_SECRET,
"instance_url": "",
} if settings.SALESFORCE_CLIENT_ID else None
elif provider == "hubspot":
return {
"api_key": settings.HUBSPOT_API_KEY,
} if settings.HUBSPOT_API_KEY else None
return None
@staticmethod
def _score_to_sf_rating(score: int) -> str:
if score >= 80:
return "Hot"
elif score >= 50:
return "Warm"
return "Cold"
@staticmethod
def _status_to_hs(status: str) -> str:
mapping = {
"new": "NEW",
"contacted": "IN_PROGRESS",
"qualified": "QUALIFIED",
"converted": "CUSTOMER",
"lost": "UNQUALIFIED",
}
return mapping.get(status, "NEW")

View File

@ -0,0 +1,237 @@
"""
Deal Service Pipeline management, stage transitions, forecasting.
"""
import uuid
from datetime import datetime, timezone
from decimal import Decimal
from typing import Optional
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
VALID_STAGES = ["discovery", "proposal", "negotiation", "closed_won", "closed_lost"]
STAGE_PROBABILITIES = {
"discovery": 20,
"proposal": 40,
"negotiation": 60,
"closed_won": 100,
"closed_lost": 0,
}
class DealService:
"""Manages the deal pipeline from discovery to close."""
def __init__(self, db: AsyncSession):
self.db = db
# ── CRUD ──────────────────────────────────────
async def create_deal(
self,
tenant_id: str,
lead_id: str = None,
assigned_to: str = None,
title: str = "",
stage: str = "discovery",
value: float = 0,
currency: str = "SAR",
expected_close: str = None,
) -> dict:
from app.models.deal import Deal
deal = Deal(
id=uuid.uuid4(),
tenant_id=uuid.UUID(tenant_id),
lead_id=uuid.UUID(lead_id) if lead_id else None,
assigned_to=uuid.UUID(assigned_to) if assigned_to else None,
title=title,
stage=stage,
value=Decimal(str(value)),
currency=currency,
probability=STAGE_PROBABILITIES.get(stage, 20),
expected_close=datetime.fromisoformat(expected_close) if expected_close else None,
)
self.db.add(deal)
await self.db.flush()
return self._to_dict(deal)
async def get_deal(self, tenant_id: str, deal_id: str) -> Optional[dict]:
from app.models.deal import Deal
result = await self.db.execute(
select(Deal).where(
Deal.id == uuid.UUID(deal_id),
Deal.tenant_id == uuid.UUID(tenant_id),
)
)
deal = result.scalar_one_or_none()
return self._to_dict(deal) if deal else None
async def list_deals(
self,
tenant_id: str,
stage: str = None,
assigned_to: str = None,
min_value: float = None,
page: int = 1,
per_page: int = 25,
) -> dict:
from app.models.deal import Deal
query = select(Deal).where(Deal.tenant_id == uuid.UUID(tenant_id))
if stage:
query = query.where(Deal.stage == stage)
if assigned_to:
query = query.where(Deal.assigned_to == uuid.UUID(assigned_to))
if min_value is not None:
query = query.where(Deal.value >= Decimal(str(min_value)))
count_q = select(func.count()).select_from(query.subquery())
total = (await self.db.execute(count_q)).scalar() or 0
query = query.order_by(Deal.created_at.desc())
query = query.offset((page - 1) * per_page).limit(per_page)
result = await self.db.execute(query)
deals = [self._to_dict(d) for d in result.scalars().all()]
return {
"items": deals,
"total": total,
"page": page,
"per_page": per_page,
}
async def update_deal(self, tenant_id: str, deal_id: str, **updates) -> Optional[dict]:
from app.models.deal import Deal
result = await self.db.execute(
select(Deal).where(
Deal.id == uuid.UUID(deal_id),
Deal.tenant_id == uuid.UUID(tenant_id),
)
)
deal = result.scalar_one_or_none()
if not deal:
return None
for key, value in updates.items():
if hasattr(deal, key) and value is not None:
setattr(deal, key, value)
deal.updated_at = datetime.now(timezone.utc)
await self.db.flush()
return self._to_dict(deal)
# ── Stage Management ──────────────────────────
async def move_stage(
self,
tenant_id: str,
deal_id: str,
new_stage: str,
lost_reason: str = None,
) -> Optional[dict]:
if new_stage not in VALID_STAGES:
return None
updates = {
"stage": new_stage,
"probability": STAGE_PROBABILITIES[new_stage],
}
if new_stage == "closed_won":
updates["closed_at"] = datetime.now(timezone.utc)
elif new_stage == "closed_lost":
updates["closed_at"] = datetime.now(timezone.utc)
if lost_reason:
updates["lost_reason"] = lost_reason
return await self.update_deal(tenant_id, deal_id, **updates)
# ── Pipeline Analytics ────────────────────────
async def get_pipeline(self, tenant_id: str) -> dict:
from app.models.deal import Deal
pipeline = {}
for stage in VALID_STAGES:
count_q = select(func.count()).where(
Deal.tenant_id == uuid.UUID(tenant_id),
Deal.stage == stage,
)
value_q = select(func.coalesce(func.sum(Deal.value), 0)).where(
Deal.tenant_id == uuid.UUID(tenant_id),
Deal.stage == stage,
)
count = (await self.db.execute(count_q)).scalar() or 0
value = (await self.db.execute(value_q)).scalar() or 0
pipeline[stage] = {
"count": count,
"value": float(value),
"weighted": float(value) * STAGE_PROBABILITIES[stage] / 100,
}
total_value = sum(s["value"] for s in pipeline.values())
total_weighted = sum(s["weighted"] for s in pipeline.values())
return {
"stages": pipeline,
"total_deals": sum(s["count"] for s in pipeline.values()),
"total_value": total_value,
"weighted_value": total_weighted,
}
async def get_forecast(self, tenant_id: str) -> dict:
from app.models.deal import Deal
open_stages = ["discovery", "proposal", "negotiation"]
monthly = {}
for stage in open_stages:
q = select(
func.date_trunc("month", Deal.expected_close).label("month"),
func.sum(Deal.value).label("value"),
func.count().label("count"),
).where(
Deal.tenant_id == uuid.UUID(tenant_id),
Deal.stage == stage,
Deal.expected_close.isnot(None),
).group_by("month")
rows = (await self.db.execute(q)).all()
for row in rows:
key = str(row.month)
if key not in monthly:
monthly[key] = {"value": 0, "weighted": 0, "count": 0}
monthly[key]["value"] += float(row.value or 0)
monthly[key]["weighted"] += float(row.value or 0) * STAGE_PROBABILITIES[stage] / 100
monthly[key]["count"] += row.count
return {"monthly_forecast": monthly}
# ── Helpers ───────────────────────────────────
@staticmethod
def _to_dict(deal) -> dict:
if not deal:
return {}
return {
"id": str(deal.id),
"tenant_id": str(deal.tenant_id),
"lead_id": str(deal.lead_id) if deal.lead_id else None,
"assigned_to": str(deal.assigned_to) if deal.assigned_to else None,
"title": deal.title,
"stage": deal.stage,
"value": float(deal.value) if deal.value else 0,
"currency": deal.currency,
"probability": deal.probability,
"expected_close": deal.expected_close.isoformat() if deal.expected_close else None,
"closed_at": deal.closed_at.isoformat() if deal.closed_at else None,
"lost_reason": deal.lost_reason,
"created_at": deal.created_at.isoformat() if deal.created_at else None,
"updated_at": deal.updated_at.isoformat() if deal.updated_at else None,
}

View File

@ -0,0 +1,377 @@
"""
Lead Service CRUD, qualification, scoring, assignment, import/export.
The heart of the sales pipeline.
"""
import csv
import io
import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import select, func, and_, or_, update
from sqlalchemy.ext.asyncio import AsyncSession
class LeadService:
"""Manages the full lifecycle of leads from creation to conversion."""
def __init__(self, db: AsyncSession):
self.db = db
# ── CRUD ──────────────────────────────────────
async def create_lead(
self,
tenant_id: str,
full_name: str,
phone: str = "",
email: str = "",
company_name: str = "",
sector: str = "",
city: str = "",
source: str = "web",
notes: str = "",
assigned_to: str = None,
) -> dict:
from app.models.lead import Lead
lead = Lead(
id=uuid.uuid4(),
tenant_id=uuid.UUID(tenant_id),
full_name=full_name,
phone=phone,
email=email,
company_name=company_name,
sector=sector,
city=city,
source=source,
status="new",
score=0,
notes=notes,
assigned_to=uuid.UUID(assigned_to) if assigned_to else None,
)
self.db.add(lead)
await self.db.flush()
return self._to_dict(lead)
async def get_lead(self, tenant_id: str, lead_id: str) -> Optional[dict]:
from app.models.lead import Lead
result = await self.db.execute(
select(Lead).where(
Lead.id == uuid.UUID(lead_id),
Lead.tenant_id == uuid.UUID(tenant_id),
)
)
lead = result.scalar_one_or_none()
return self._to_dict(lead) if lead else None
async def list_leads(
self,
tenant_id: str,
status: str = None,
source: str = None,
sector: str = None,
city: str = None,
assigned_to: str = None,
min_score: int = None,
search: str = None,
page: int = 1,
per_page: int = 25,
sort_by: str = "created_at",
sort_dir: str = "desc",
) -> dict:
from app.models.lead import Lead
query = select(Lead).where(Lead.tenant_id == uuid.UUID(tenant_id))
if status:
query = query.where(Lead.status == status)
if source:
query = query.where(Lead.source == source)
if sector:
query = query.where(Lead.sector == sector)
if city:
query = query.where(Lead.city == city)
if assigned_to:
query = query.where(Lead.assigned_to == uuid.UUID(assigned_to))
if min_score is not None:
query = query.where(Lead.score >= min_score)
if search:
pattern = f"%{search}%"
query = query.where(
or_(
Lead.full_name.ilike(pattern),
Lead.email.ilike(pattern),
Lead.phone.ilike(pattern),
Lead.company_name.ilike(pattern),
)
)
# Count
count_q = select(func.count()).select_from(query.subquery())
total = (await self.db.execute(count_q)).scalar() or 0
# Sort
sort_col = getattr(Lead, sort_by, Lead.created_at)
if sort_dir == "asc":
query = query.order_by(sort_col.asc())
else:
query = query.order_by(sort_col.desc())
# Paginate
query = query.offset((page - 1) * per_page).limit(per_page)
result = await self.db.execute(query)
leads = [self._to_dict(l) for l in result.scalars().all()]
return {
"items": leads,
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page,
}
async def update_lead(
self, tenant_id: str, lead_id: str, **updates
) -> Optional[dict]:
from app.models.lead import Lead
result = await self.db.execute(
select(Lead).where(
Lead.id == uuid.UUID(lead_id),
Lead.tenant_id == uuid.UUID(tenant_id),
)
)
lead = result.scalar_one_or_none()
if not lead:
return None
for key, value in updates.items():
if hasattr(lead, key) and value is not None:
setattr(lead, key, value)
lead.updated_at = datetime.now(timezone.utc)
await self.db.flush()
return self._to_dict(lead)
async def delete_lead(self, tenant_id: str, lead_id: str) -> bool:
from app.models.lead import Lead
result = await self.db.execute(
select(Lead).where(
Lead.id == uuid.UUID(lead_id),
Lead.tenant_id == uuid.UUID(tenant_id),
)
)
lead = result.scalar_one_or_none()
if not lead:
return False
lead.status = "deleted"
lead.updated_at = datetime.now(timezone.utc)
await self.db.flush()
return True
# ── Assignment ────────────────────────────────
async def assign_lead(
self,
tenant_id: str,
lead_id: str,
agent_id: str,
) -> Optional[dict]:
return await self.update_lead(
tenant_id, lead_id, assigned_to=uuid.UUID(agent_id)
)
async def auto_assign_round_robin(self, tenant_id: str, lead_id: str) -> Optional[dict]:
"""Assign lead to the agent with the fewest active leads."""
from app.models.user import User
from app.models.lead import Lead
# Get active agents
agents_q = select(User.id).where(
User.tenant_id == uuid.UUID(tenant_id),
User.role.in_(["agent", "manager"]),
User.is_active == True,
)
agents = (await self.db.execute(agents_q)).scalars().all()
if not agents:
return None
# Count active leads per agent
best_agent = None
min_leads = float("inf")
for agent_id in agents:
count_q = select(func.count()).where(
Lead.tenant_id == uuid.UUID(tenant_id),
Lead.assigned_to == agent_id,
Lead.status.in_(["new", "contacted", "qualified"]),
)
count = (await self.db.execute(count_q)).scalar() or 0
if count < min_leads:
min_leads = count
best_agent = agent_id
if best_agent:
return await self.assign_lead(tenant_id, lead_id, str(best_agent))
return None
# ── Qualification ─────────────────────────────
async def qualify_lead(
self,
tenant_id: str,
lead_id: str,
score: int,
status: str = None,
reasoning: str = "",
) -> Optional[dict]:
updates = {"score": score}
if status:
updates["status"] = status
if score >= 70:
updates["status"] = "qualified"
updates["qualified_at"] = datetime.now(timezone.utc)
elif score < 30:
updates["status"] = "lost"
else:
updates["status"] = "contacted"
return await self.update_lead(tenant_id, lead_id, **updates)
# ── Conversion ────────────────────────────────
async def convert_to_deal(
self,
tenant_id: str,
lead_id: str,
deal_title: str = "",
deal_value: float = 0,
) -> Optional[dict]:
from app.models.deal import Deal
lead = await self.get_lead(tenant_id, lead_id)
if not lead:
return None
deal = Deal(
id=uuid.uuid4(),
tenant_id=uuid.UUID(tenant_id),
lead_id=uuid.UUID(lead_id),
assigned_to=uuid.UUID(lead["assigned_to"]) if lead.get("assigned_to") else None,
title=deal_title or f"Deal - {lead['full_name']}",
stage="discovery",
value=deal_value,
currency="SAR",
probability=20,
)
self.db.add(deal)
await self.update_lead(
tenant_id,
lead_id,
status="converted",
converted_at=datetime.now(timezone.utc),
)
await self.db.flush()
return {
"deal_id": str(deal.id),
"lead_id": lead_id,
"title": deal.title,
"stage": deal.stage,
"value": float(deal.value),
}
# ── Import/Export ─────────────────────────────
async def import_from_csv(self, tenant_id: str, csv_content: str) -> dict:
reader = csv.DictReader(io.StringIO(csv_content))
created = 0
errors = []
for i, row in enumerate(reader, 1):
try:
await self.create_lead(
tenant_id=tenant_id,
full_name=row.get("name", row.get("full_name", "")),
phone=row.get("phone", ""),
email=row.get("email", ""),
company_name=row.get("company", row.get("company_name", "")),
sector=row.get("sector", row.get("industry", "")),
city=row.get("city", ""),
source="import",
)
created += 1
except Exception as e:
errors.append({"row": i, "error": str(e)})
return {"created": created, "errors": errors, "total_rows": created + len(errors)}
async def export_to_csv(self, tenant_id: str, **filters) -> str:
data = await self.list_leads(tenant_id, per_page=10000, **filters)
output = io.StringIO()
if not data["items"]:
return ""
writer = csv.DictWriter(output, fieldnames=data["items"][0].keys())
writer.writeheader()
writer.writerows(data["items"])
return output.getvalue()
# ── Stats ─────────────────────────────────────
async def get_stats(self, tenant_id: str) -> dict:
from app.models.lead import Lead
base = select(func.count()).where(Lead.tenant_id == uuid.UUID(tenant_id))
total = (await self.db.execute(base)).scalar() or 0
statuses = {}
for s in ["new", "contacted", "qualified", "converted", "lost"]:
q = base.where(Lead.status == s)
statuses[s] = (await self.db.execute(q)).scalar() or 0
avg_score_q = select(func.avg(Lead.score)).where(
Lead.tenant_id == uuid.UUID(tenant_id),
Lead.score > 0,
)
avg_score = (await self.db.execute(avg_score_q)).scalar() or 0
return {
"total": total,
"by_status": statuses,
"avg_score": round(float(avg_score), 1),
"conversion_rate": round(
(statuses.get("converted", 0) / total * 100) if total > 0 else 0, 1
),
}
# ── Helpers ───────────────────────────────────
@staticmethod
def _to_dict(lead) -> dict:
if not lead:
return {}
return {
"id": str(lead.id),
"tenant_id": str(lead.tenant_id),
"assigned_to": str(lead.assigned_to) if lead.assigned_to else None,
"source": lead.source,
"status": lead.status,
"score": lead.score,
"full_name": lead.full_name,
"phone": lead.phone,
"email": lead.email,
"company_name": lead.company_name,
"sector": lead.sector,
"city": lead.city,
"notes": lead.notes,
"qualified_at": lead.qualified_at.isoformat() if lead.qualified_at else None,
"converted_at": lead.converted_at.isoformat() if lead.converted_at else None,
"created_at": lead.created_at.isoformat() if lead.created_at else None,
"updated_at": lead.updated_at.isoformat() if lead.updated_at else None,
}

View File

@ -0,0 +1,4 @@
"""LLM services package."""
from app.services.llm.provider import LLMRouter, get_llm, LLMResponse
__all__ = ["LLMRouter", "get_llm", "LLMResponse"]

View File

@ -0,0 +1,249 @@
"""
LLM Provider Abstraction Layer
Supports Groq (primary) and OpenAI (fallback) with automatic failover.
"""
import time
import json
import logging
from typing import Optional, AsyncGenerator
from abc import ABC, abstractmethod
from app.config import get_settings
settings = get_settings()
logger = logging.getLogger("dealix.llm")
class LLMResponse:
"""Standardized LLM response across providers."""
def __init__(self, content: str, tokens_used: int = 0, latency_ms: int = 0,
provider: str = "", model: str = "", raw: dict = None):
self.content = content
self.tokens_used = tokens_used
self.latency_ms = latency_ms
self.provider = provider
self.model = model
self.raw = raw or {}
def to_dict(self) -> dict:
return {
"content": self.content,
"tokens_used": self.tokens_used,
"latency_ms": self.latency_ms,
"provider": self.provider,
"model": self.model,
}
def parse_json(self) -> Optional[dict]:
"""Try to parse content as JSON."""
try:
# Handle markdown code blocks
text = self.content.strip()
if text.startswith("```json"):
text = text[7:]
if text.startswith("```"):
text = text[3:]
if text.endswith("```"):
text = text[:-3]
return json.loads(text.strip())
except (json.JSONDecodeError, ValueError):
return None
class BaseLLMProvider(ABC):
"""Abstract base for LLM providers."""
@abstractmethod
async def complete(self, system_prompt: str, user_message: str,
temperature: float = None, max_tokens: int = None,
json_mode: bool = False) -> LLMResponse:
pass
@abstractmethod
async def is_available(self) -> bool:
pass
class GroqProvider(BaseLLMProvider):
"""Groq API provider — ultra-fast inference."""
def __init__(self):
from groq import AsyncGroq
self.client = AsyncGroq(api_key=settings.GROQ_API_KEY) if settings.GROQ_API_KEY else None
self.model = settings.GROQ_MODEL
self.fast_model = settings.GROQ_FAST_MODEL
async def is_available(self) -> bool:
return bool(settings.GROQ_API_KEY and self.client)
async def complete(self, system_prompt: str, user_message: str,
temperature: float = None, max_tokens: int = None,
json_mode: bool = False, fast: bool = False) -> LLMResponse:
if not self.client:
raise RuntimeError("Groq API key not configured")
model = self.fast_model if fast else self.model
start = time.time()
kwargs = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
"temperature": temperature or settings.LLM_TEMPERATURE,
"max_tokens": max_tokens or settings.LLM_MAX_TOKENS,
}
if json_mode:
kwargs["response_format"] = {"type": "json_object"}
response = await self.client.chat.completions.create(**kwargs)
latency = int((time.time() - start) * 1000)
return LLMResponse(
content=response.choices[0].message.content or "",
tokens_used=response.usage.total_tokens if response.usage else 0,
latency_ms=latency,
provider="groq",
model=model,
raw=response.model_dump() if hasattr(response, "model_dump") else {},
)
class OpenAIProvider(BaseLLMProvider):
"""OpenAI API provider — highest quality, fallback."""
def __init__(self):
from openai import AsyncOpenAI
self.client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY) if settings.OPENAI_API_KEY else None
self.model = settings.OPENAI_MODEL
self.mini_model = settings.OPENAI_MINI_MODEL
async def is_available(self) -> bool:
return bool(settings.OPENAI_API_KEY and self.client)
async def complete(self, system_prompt: str, user_message: str,
temperature: float = None, max_tokens: int = None,
json_mode: bool = False, mini: bool = False) -> LLMResponse:
if not self.client:
raise RuntimeError("OpenAI API key not configured")
model = self.mini_model if mini else self.model
start = time.time()
kwargs = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
"temperature": temperature or settings.LLM_TEMPERATURE,
"max_tokens": max_tokens or settings.LLM_MAX_TOKENS,
}
if json_mode:
kwargs["response_format"] = {"type": "json_object"}
response = await self.client.chat.completions.create(**kwargs)
latency = int((time.time() - start) * 1000)
return LLMResponse(
content=response.choices[0].message.content or "",
tokens_used=response.usage.total_tokens if response.usage else 0,
latency_ms=latency,
provider="openai",
model=model,
raw=response.model_dump() if hasattr(response, "model_dump") else {},
)
class LLMRouter:
"""
Intelligent LLM routing with automatic failover.
Primary: Groq (fast, free/cheap)
Fallback: OpenAI (reliable, high quality)
"""
def __init__(self):
self.groq = GroqProvider()
self.openai = OpenAIProvider()
self._primary = settings.LLM_PRIMARY_PROVIDER
async def complete(self, system_prompt: str, user_message: str,
temperature: float = None, max_tokens: int = None,
json_mode: bool = False, provider: str = None,
fast: bool = False) -> LLMResponse:
"""
Send a completion request to the best available provider.
Args:
system_prompt: System instructions
user_message: User input
temperature: Override default temperature
max_tokens: Override default max tokens
json_mode: Request JSON output
provider: Force specific provider ("groq" or "openai")
fast: Use faster/smaller model variant
"""
# Determine provider order
if provider == "openai":
providers = [("openai", self.openai)]
elif provider == "groq":
providers = [("groq", self.groq)]
elif self._primary == "groq":
providers = [("groq", self.groq), ("openai", self.openai)]
else:
providers = [("openai", self.openai), ("groq", self.groq)]
last_error = None
for name, prov in providers:
if not await prov.is_available():
logger.warning(f"LLM provider {name} not available, trying next...")
continue
try:
kwargs = {
"system_prompt": system_prompt,
"user_message": user_message,
"temperature": temperature,
"max_tokens": max_tokens,
"json_mode": json_mode,
}
if name == "groq":
kwargs["fast"] = fast
elif name == "openai":
kwargs["mini"] = fast
result = await prov.complete(**kwargs)
logger.info(
f"LLM call: provider={name} model={result.model} "
f"tokens={result.tokens_used} latency={result.latency_ms}ms"
)
return result
except Exception as e:
last_error = e
logger.warning(f"LLM provider {name} failed: {e}, trying next...")
continue
raise RuntimeError(f"All LLM providers failed. Last error: {last_error}")
async def complete_json(self, system_prompt: str, user_message: str,
**kwargs) -> dict:
"""Shortcut: complete and parse as JSON."""
response = await self.complete(system_prompt, user_message,
json_mode=True, **kwargs)
parsed = response.parse_json()
if parsed is None:
raise ValueError(f"Failed to parse LLM response as JSON: {response.content[:200]}")
return parsed
# Singleton
_router: Optional[LLMRouter] = None
def get_llm() -> LLMRouter:
global _router
if _router is None:
_router = LLMRouter()
return _router

View File

@ -0,0 +1,247 @@
"""
Meeting Service AI-driven scheduling, calendar sync, preparation packages.
"""
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
class MeetingService:
"""Manages meeting lifecycle: schedule, confirm, prepare, remind."""
def __init__(self, db: AsyncSession):
self.db = db
async def create_meeting(
self,
tenant_id: str,
lead_id: str,
agent_id: str,
proposed_time: str,
channel: str = "whatsapp",
notes: str = "",
) -> dict:
from app.models.ai_conversation import AutoBooking
booking = AutoBooking(
id=uuid.uuid4(),
tenant_id=uuid.UUID(tenant_id),
lead_id=uuid.UUID(lead_id),
agent_id=uuid.UUID(agent_id),
proposed_time=datetime.fromisoformat(proposed_time),
status="proposed",
channel=channel,
)
self.db.add(booking)
await self.db.flush()
return self._to_dict(booking)
async def confirm_meeting(
self, tenant_id: str, meeting_id: str, confirmed_time: str = None
) -> Optional[dict]:
from app.models.ai_conversation import AutoBooking
result = await self.db.execute(
select(AutoBooking).where(
AutoBooking.id == uuid.UUID(meeting_id),
AutoBooking.tenant_id == uuid.UUID(tenant_id),
)
)
booking = result.scalar_one_or_none()
if not booking:
return None
booking.status = "confirmed"
if confirmed_time:
booking.confirmed_time = datetime.fromisoformat(confirmed_time)
else:
booking.confirmed_time = booking.proposed_time
booking.updated_at = datetime.now(timezone.utc)
await self.db.flush()
return self._to_dict(booking)
async def cancel_meeting(
self, tenant_id: str, meeting_id: str, reason: str = ""
) -> Optional[dict]:
from app.models.ai_conversation import AutoBooking
result = await self.db.execute(
select(AutoBooking).where(
AutoBooking.id == uuid.UUID(meeting_id),
AutoBooking.tenant_id == uuid.UUID(tenant_id),
)
)
booking = result.scalar_one_or_none()
if not booking:
return None
booking.status = "cancelled"
booking.updated_at = datetime.now(timezone.utc)
await self.db.flush()
return self._to_dict(booking)
async def reschedule_meeting(
self, tenant_id: str, meeting_id: str, new_time: str
) -> Optional[dict]:
from app.models.ai_conversation import AutoBooking
result = await self.db.execute(
select(AutoBooking).where(
AutoBooking.id == uuid.UUID(meeting_id),
AutoBooking.tenant_id == uuid.UUID(tenant_id),
)
)
booking = result.scalar_one_or_none()
if not booking:
return None
booking.proposed_time = datetime.fromisoformat(new_time)
booking.confirmed_time = None
booking.status = "rescheduled"
booking.updated_at = datetime.now(timezone.utc)
await self.db.flush()
return self._to_dict(booking)
async def list_meetings(
self,
tenant_id: str,
agent_id: str = None,
status: str = None,
from_date: str = None,
to_date: str = None,
page: int = 1,
per_page: int = 25,
) -> dict:
from app.models.ai_conversation import AutoBooking
query = select(AutoBooking).where(
AutoBooking.tenant_id == uuid.UUID(tenant_id)
)
if agent_id:
query = query.where(AutoBooking.agent_id == uuid.UUID(agent_id))
if status:
query = query.where(AutoBooking.status == status)
if from_date:
query = query.where(AutoBooking.proposed_time >= datetime.fromisoformat(from_date))
if to_date:
query = query.where(AutoBooking.proposed_time <= datetime.fromisoformat(to_date))
count_q = select(func.count()).select_from(query.subquery())
total = (await self.db.execute(count_q)).scalar() or 0
query = query.order_by(AutoBooking.proposed_time.asc())
query = query.offset((page - 1) * per_page).limit(per_page)
result = await self.db.execute(query)
meetings = [self._to_dict(m) for m in result.scalars().all()]
return {"items": meetings, "total": total, "page": page, "per_page": per_page}
async def get_availability(
self,
tenant_id: str,
agent_id: str,
date: str,
slot_duration_minutes: int = 30,
) -> list:
"""Get available time slots for an agent on a given date."""
from app.models.ai_conversation import AutoBooking
target_date = datetime.fromisoformat(date).date()
start = datetime.combine(target_date, datetime.min.time().replace(hour=8))
end = datetime.combine(target_date, datetime.min.time().replace(hour=18))
# Get booked slots
booked_q = select(AutoBooking.proposed_time, AutoBooking.confirmed_time).where(
AutoBooking.tenant_id == uuid.UUID(tenant_id),
AutoBooking.agent_id == uuid.UUID(agent_id),
AutoBooking.status.in_(["proposed", "confirmed"]),
AutoBooking.proposed_time >= start,
AutoBooking.proposed_time < end,
)
booked = (await self.db.execute(booked_q)).all()
booked_times = set()
for b in booked:
t = b.confirmed_time or b.proposed_time
booked_times.add(t.replace(minute=(t.minute // slot_duration_minutes) * slot_duration_minutes, second=0))
# Generate slots
slots = []
current = start.replace(tzinfo=timezone.utc)
end = end.replace(tzinfo=timezone.utc)
while current < end:
if current not in booked_times:
slots.append({
"time": current.isoformat(),
"available": True,
})
current += timedelta(minutes=slot_duration_minutes)
return slots
async def prepare_meeting_package(
self, tenant_id: str, meeting_id: str
) -> dict:
"""Generate a meeting preparation package (AI-powered)."""
from app.models.ai_conversation import AutoBooking
result = await self.db.execute(
select(AutoBooking).where(
AutoBooking.id == uuid.UUID(meeting_id),
AutoBooking.tenant_id == uuid.UUID(tenant_id),
)
)
booking = result.scalar_one_or_none()
if not booking:
return {}
# Get lead info for context
from app.services.lead_service import LeadService
lead_svc = LeadService(self.db)
lead = await lead_svc.get_lead(tenant_id, str(booking.lead_id))
return {
"meeting_id": str(booking.id),
"lead": lead,
"prep_items": {
"company_brief": f"Prepare brief for {lead.get('company_name', 'Unknown')}",
"sector": lead.get("sector", ""),
"talking_points": [], # AI will fill this
"predicted_objections": [], # AI will fill this
"recommended_presentation": None, # Will match to sector
},
"status": "pending_ai_enrichment",
}
async def get_today_schedule(self, tenant_id: str, agent_id: str) -> list:
today = datetime.now(timezone.utc).date()
tomorrow = today + timedelta(days=1)
data = await self.list_meetings(
tenant_id,
agent_id=agent_id,
from_date=datetime.combine(today, datetime.min.time()).isoformat(),
to_date=datetime.combine(tomorrow, datetime.min.time()).isoformat(),
per_page=50,
)
return data["items"]
@staticmethod
def _to_dict(booking) -> dict:
if not booking:
return {}
return {
"id": str(booking.id),
"tenant_id": str(booking.tenant_id),
"lead_id": str(booking.lead_id),
"agent_id": str(booking.agent_id),
"proposed_time": booking.proposed_time.isoformat() if booking.proposed_time else None,
"confirmed_time": booking.confirmed_time.isoformat() if booking.confirmed_time else None,
"status": booking.status,
"channel": booking.channel,
"calendar_event_id": booking.calendar_event_id,
"created_at": booking.created_at.isoformat() if booking.created_at else None,
}

View File

@ -0,0 +1,226 @@
"""
Notification Service Multi-channel delivery (in-app, WhatsApp, email, SMS).
"""
import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession
class NotificationService:
"""Manages notifications across all channels."""
def __init__(self, db: AsyncSession):
self.db = db
async def send(
self,
tenant_id: str,
user_id: str,
title: str,
body: str,
notification_type: str = "info",
channel: str = "in_app",
data: dict = None,
) -> dict:
from app.models.notification import Notification
notif = Notification(
id=uuid.uuid4(),
tenant_id=uuid.UUID(tenant_id),
user_id=uuid.UUID(user_id),
type=notification_type,
title=title,
body=body,
channel=channel,
is_read=False,
)
self.db.add(notif)
await self.db.flush()
# Dispatch to external channels
if channel == "whatsapp":
await self._send_whatsapp(user_id, body)
elif channel == "email":
await self._send_email(user_id, title, body)
elif channel == "sms":
await self._send_sms(user_id, body)
return {
"id": str(notif.id),
"channel": channel,
"status": "sent",
}
async def send_bulk(
self,
tenant_id: str,
user_ids: list,
title: str,
body: str,
notification_type: str = "info",
channel: str = "in_app",
) -> dict:
results = []
for uid in user_ids:
result = await self.send(tenant_id, uid, title, body, notification_type, channel)
results.append(result)
return {"sent": len(results), "results": results}
async def get_unread(self, tenant_id: str, user_id: str) -> list:
from app.models.notification import Notification
result = await self.db.execute(
select(Notification)
.where(
Notification.tenant_id == uuid.UUID(tenant_id),
Notification.user_id == uuid.UUID(user_id),
Notification.is_read == False,
)
.order_by(Notification.created_at.desc())
.limit(50)
)
return [self._to_dict(n) for n in result.scalars().all()]
async def get_all(
self, tenant_id: str, user_id: str, page: int = 1, per_page: int = 20
) -> dict:
from app.models.notification import Notification
query = select(Notification).where(
Notification.tenant_id == uuid.UUID(tenant_id),
Notification.user_id == uuid.UUID(user_id),
).order_by(Notification.created_at.desc())
count_q = select(func.count()).select_from(query.subquery())
total = (await self.db.execute(count_q)).scalar() or 0
query = query.offset((page - 1) * per_page).limit(per_page)
result = await self.db.execute(query)
return {
"items": [self._to_dict(n) for n in result.scalars().all()],
"total": total,
"unread_count": await self._count_unread(tenant_id, user_id),
}
async def mark_read(self, tenant_id: str, notification_id: str) -> bool:
from app.models.notification import Notification
result = await self.db.execute(
select(Notification).where(
Notification.id == uuid.UUID(notification_id),
Notification.tenant_id == uuid.UUID(tenant_id),
)
)
notif = result.scalar_one_or_none()
if not notif:
return False
notif.is_read = True
notif.read_at = datetime.now(timezone.utc)
await self.db.flush()
return True
async def mark_all_read(self, tenant_id: str, user_id: str) -> int:
from app.models.notification import Notification
result = await self.db.execute(
update(Notification)
.where(
Notification.tenant_id == uuid.UUID(tenant_id),
Notification.user_id == uuid.UUID(user_id),
Notification.is_read == False,
)
.values(is_read=True, read_at=datetime.now(timezone.utc))
)
return result.rowcount
# ── Alert Templates ───────────────────────────
async def notify_new_lead(self, tenant_id: str, agent_id: str, lead_name: str):
await self.send(
tenant_id, agent_id,
title="عميل محتمل جديد 🔔",
body=f"تم تعيين عميل جديد لك: {lead_name}",
notification_type="lead",
channel="in_app",
)
async def notify_meeting_booked(self, tenant_id: str, agent_id: str, lead_name: str, time: str):
await self.send(
tenant_id, agent_id,
title="موعد جديد مؤكد 📅",
body=f"تم حجز موعد مع {lead_name} في {time}",
notification_type="meeting",
channel="in_app",
)
async def notify_deal_won(self, tenant_id: str, agent_id: str, deal_title: str, value: float):
await self.send(
tenant_id, agent_id,
title="صفقة ناجحة! 🎉",
body=f"تم إغلاق صفقة {deal_title} بقيمة {value:,.0f} ريال",
notification_type="deal",
channel="in_app",
)
async def notify_commission_earned(self, tenant_id: str, affiliate_id: str, amount: float):
await self.send(
tenant_id, affiliate_id,
title="عمولة جديدة 💰",
body=f"تم إضافة عمولة {amount:,.0f} ريال إلى حسابك",
notification_type="commission",
channel="in_app",
)
async def notify_escalation(self, tenant_id: str, manager_id: str, reason: str):
await self.send(
tenant_id, manager_id,
title="تصعيد يتطلب انتباهك ⚠️",
body=reason,
notification_type="escalation",
channel="in_app",
)
# ── Channel Dispatchers ───────────────────────
async def _send_whatsapp(self, user_id: str, message: str):
# Will be implemented with WhatsApp integration
pass
async def _send_email(self, user_id: str, subject: str, body: str):
# Will be implemented with email integration
pass
async def _send_sms(self, user_id: str, message: str):
# Will be implemented with SMS integration
pass
async def _count_unread(self, tenant_id: str, user_id: str) -> int:
from app.models.notification import Notification
q = select(func.count()).where(
Notification.tenant_id == uuid.UUID(tenant_id),
Notification.user_id == uuid.UUID(user_id),
Notification.is_read == False,
)
return (await self.db.execute(q)).scalar() or 0
@staticmethod
def _to_dict(notif) -> dict:
if not notif:
return {}
return {
"id": str(notif.id),
"type": notif.type,
"title": notif.title,
"body": notif.body,
"channel": notif.channel,
"is_read": notif.is_read,
"read_at": notif.read_at.isoformat() if notif.read_at else None,
"created_at": notif.created_at.isoformat() if notif.created_at else None,
}

View File

@ -0,0 +1,247 @@
"""
Trust Score Service AI-powered scoring for leads and affiliates.
"""
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
class TrustScoreService:
"""Calculates trust scores for leads and affiliates to prioritize high-quality opportunities."""
def __init__(self, db: AsyncSession):
self.db = db
# ── Lead Trust Score (0-100) ──────────────────
async def calculate_lead_score(self, tenant_id: str, lead_id: str) -> dict:
from app.models.lead import Lead
from app.models.message import Message
result = await self.db.execute(
select(Lead).where(
Lead.id == uuid.UUID(lead_id),
Lead.tenant_id == uuid.UUID(tenant_id),
)
)
lead = result.scalar_one_or_none()
if not lead:
return {}
score = 0
breakdown = {}
# 1. Contact info completeness (+20 max)
contact_score = 0
if lead.phone and len(lead.phone) >= 9:
contact_score += 10
if lead.email and "@" in lead.email:
contact_score += 10
breakdown["contact_info"] = contact_score
score += contact_score
# 2. Company info (+25 max)
company_score = 0
if lead.company_name:
company_score += 10
if lead.sector:
company_score += 5
if lead.city:
company_score += 5
# CR number verification would add +5 more
breakdown["company_info"] = company_score
score += company_score
# 3. Engagement level (+25 max)
msg_count_q = select(func.count()).where(
Message.lead_id == uuid.UUID(lead_id),
Message.direction == "inbound",
)
msg_count = (await self.db.execute(msg_count_q)).scalar() or 0
engagement_score = min(25, msg_count * 5)
breakdown["engagement"] = engagement_score
score += engagement_score
# 4. Response speed (+15 max)
if msg_count > 0:
# Has responded = good sign
response_score = 15
else:
response_score = 0
breakdown["responsiveness"] = response_score
score += response_score
# 5. Source quality (+15 max)
source_scores = {
"referral": 15, "affiliate": 12, "web": 10,
"whatsapp": 8, "import": 5, "cold": 3,
}
source_score = source_scores.get(lead.source, 5)
breakdown["source_quality"] = source_score
score += source_score
# Normalize to 0-100
score = min(100, score)
# Classification
if score >= 70:
classification = "hot"
classification_ar = "ساخن 🔥"
elif score >= 40:
classification = "warm"
classification_ar = "دافئ ☀️"
else:
classification = "cold"
classification_ar = "بارد ❄️"
# Update lead score
lead.score = score
await self.db.flush()
return {
"lead_id": str(lead_id),
"trust_score": score,
"classification": classification,
"classification_ar": classification_ar,
"breakdown": breakdown,
"recommendation": self._get_lead_recommendation(classification),
}
# ── Affiliate Trust Score (0-100) ─────────────
async def calculate_affiliate_score(self, tenant_id: str, affiliate_id: str) -> dict:
from app.models.affiliate import Affiliate, AffiliatePerformance
result = await self.db.execute(
select(Affiliate).where(
Affiliate.id == uuid.UUID(affiliate_id),
Affiliate.tenant_id == uuid.UUID(tenant_id),
)
)
aff = result.scalar_one_or_none()
if not aff:
return {}
score = 0
breakdown = {}
# 1. Lead Quality — conversion rate (40% weight)
perf_q = select(
func.coalesce(func.sum(AffiliatePerformance.leads_generated), 0),
func.coalesce(func.sum(AffiliatePerformance.deals_closed), 0),
).where(AffiliatePerformance.affiliate_id == uuid.UUID(affiliate_id))
perf = (await self.db.execute(perf_q)).first()
total_leads = int(perf[0]) if perf else 0
total_deals = int(perf[1]) if perf else 0
if total_leads > 0:
conv_rate = total_deals / total_leads
quality_score = min(40, int(conv_rate * 200))
else:
quality_score = 0
breakdown["lead_quality"] = quality_score
score += quality_score
# 2. Activity Consistency (20% weight)
recent_q = select(func.count()).where(
AffiliatePerformance.affiliate_id == uuid.UUID(affiliate_id),
AffiliatePerformance.leads_generated > 0,
)
active_months = (await self.db.execute(recent_q)).scalar() or 0
consistency_score = min(20, active_months * 4)
breakdown["consistency"] = consistency_score
score += consistency_score
# 3. Volume (20% weight)
volume_score = min(20, total_deals * 2)
breakdown["volume"] = volume_score
score += volume_score
# 4. Tier bonus (10% weight)
tier_scores = {"bronze": 2, "silver": 5, "gold": 8, "platinum": 10}
tier_score = tier_scores.get(aff.tier, 0)
breakdown["tier_bonus"] = tier_score
score += tier_score
# 5. Longevity (10% weight)
months_active = 0
if aff.approved_at:
delta = datetime.now(timezone.utc) - aff.approved_at.replace(tzinfo=timezone.utc)
months_active = delta.days // 30
longevity = min(10, months_active)
breakdown["longevity"] = longevity
score += longevity
score = min(100, score)
if score >= 75:
tier_label = "Elite ⭐"
elif score >= 50:
tier_label = "Trusted ✅"
elif score >= 25:
tier_label = "Growing 📈"
else:
tier_label = "New 🆕"
return {
"affiliate_id": str(affiliate_id),
"trust_score": score,
"label": tier_label,
"breakdown": breakdown,
"stats": {
"total_leads": total_leads,
"total_deals": total_deals,
"months_active": months_active,
"conversion_rate": round(total_deals / total_leads * 100, 1) if total_leads > 0 else 0,
},
}
# ── Batch Scoring ─────────────────────────────
async def score_all_leads(self, tenant_id: str) -> dict:
from app.models.lead import Lead
result = await self.db.execute(
select(Lead.id).where(
Lead.tenant_id == uuid.UUID(tenant_id),
Lead.status.in_(["new", "contacted"]),
)
)
lead_ids = [str(lid) for lid in result.scalars().all()]
scored = 0
for lid in lead_ids:
await self.calculate_lead_score(tenant_id, lid)
scored += 1
return {"scored": scored, "total": len(lead_ids)}
# ── Helpers ───────────────────────────────────
@staticmethod
def _get_lead_recommendation(classification: str) -> dict:
recommendations = {
"hot": {
"action": "book_meeting",
"action_ar": "احجز موعد فوراً",
"priority": "critical",
"message": "This lead shows strong buying signals. Book a meeting immediately.",
},
"warm": {
"action": "nurture",
"action_ar": "تابع التواصل",
"priority": "high",
"message": "Engage with targeted content and schedule a follow-up.",
},
"cold": {
"action": "drip_campaign",
"action_ar": "أضف لحملة المتابعة",
"priority": "low",
"message": "Add to drip campaign. Re-evaluate in 2 weeks.",
},
}
return recommendations.get(classification, recommendations["cold"])

View File

@ -0,0 +1,80 @@
"""
AI Agent Async Tasks Celery
Executes agents asynchronously in the background.
"""
import asyncio
import logging
from celery import shared_task
from celery.utils.log import get_task_logger
logger = get_task_logger(__name__)
def execute_agent_sync(agent_type: str, input_data: dict, tenant_id: str = None,
lead_id: str = None, conversation_id: str = None):
"""Synchronous wrapper for async true agent executor."""
from app.database import async_session
from app.services.agents.executor import AgentExecutor
import json
async def run():
async with async_session() as db:
executor = AgentExecutor(db)
result = await executor.execute(
agent_type=agent_type,
input_data=input_data,
tenant_id=tenant_id,
lead_id=lead_id,
conversation_id=conversation_id
)
# Ensure DB updates are committed
await db.commit()
return result.to_dict()
return asyncio.run(run())
def execute_event_sync(event_type: str, input_data: dict, tenant_id: str = None,
lead_id: str = None, conversation_id: str = None):
"""Synchronous wrapper for async event executor."""
from app.database import async_session
from app.services.agents.executor import AgentExecutor
async def run():
async with async_session() as db:
executor = AgentExecutor(db)
results = await executor.execute_event(
event_type=event_type,
input_data=input_data,
tenant_id=tenant_id,
lead_id=lead_id,
conversation_id=conversation_id
)
await db.commit()
return [r.to_dict() for r in results]
return asyncio.run(run())
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def run_ai_agent(self, agent_type: str, input_data: dict, tenant_id: str = None,
lead_id: str = None, conversation_id: str = None):
"""Run a specific AI agent in the background."""
try:
logger.info(f"Starting agent {agent_type} for tenant {tenant_id}")
return execute_agent_sync(agent_type, input_data, tenant_id, lead_id, conversation_id)
except Exception as exc:
logger.error(f"Agent {agent_type} failed: {exc}")
self.retry(exc=exc)
@shared_task(bind=True, max_retries=3)
def process_agent_event(self, event_type: str, input_data: dict, tenant_id: str = None,
lead_id: str = None, conversation_id: str = None):
"""Process an event by triggering the appropriate AI agent chain."""
try:
logger.info(f"Processing agent event {event_type} for tenant {tenant_id}")
return execute_event_sync(event_type, input_data, tenant_id, lead_id, conversation_id)
except Exception as exc:
logger.error(f"Event {event_type} failed: {exc}")
self.retry(exc=exc)

View File

@ -1,21 +1,47 @@
# ── Core Framework ──────────────────────────────────────
fastapi==0.115.6
uvicorn[standard]==0.34.0
python-multipart==0.0.19
# ── Database ────────────────────────────────────────────
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
alembic==1.14.1
pgvector==0.3.6
# ── Validation / Settings ──────────────────────────────
pydantic==2.10.4
pydantic-settings==2.7.1
# ── Authentication ──────────────────────────────────────
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.19
# ── Task Queue ──────────────────────────────────────────
celery[redis]==5.4.0
redis==5.2.1
# ── HTTP Client ─────────────────────────────────────────
httpx==0.28.1
# ── Templating & Emails ────────────────────────────────
jinja2==3.1.5
python-dateutil==2.9.0
emails==0.6
# ── LLM Providers ──────────────────────────────────────
openai==1.58.1
groq==0.13.0
# ── Data Processing ────────────────────────────────────
python-dateutil==2.9.0
openpyxl==3.1.5
aiofiles==24.1.0
pillow==11.1.0
openpyxl==3.1.5
# ── Utilities ──────────────────────────────────────────
python-slugify==8.0.4
phonenumbers==8.13.50
# ── Testing ────────────────────────────────────────────
pytest==8.3.4
pytest-asyncio==0.25.0

Binary file not shown.

View File

@ -13,7 +13,11 @@
"react": "19.0.0",
"react-dom": "19.0.0",
"lucide-react": "0.469.0",
"clsx": "2.1.1"
"clsx": "2.1.1",
"tailwind-merge": "^2.5.5",
"framer-motion": "^11.15.0",
"date-fns": "^4.1.0",
"recharts": "^2.15.0"
},
"devDependencies": {
"@types/node": "22.10.5",

View File

@ -1,28 +1,117 @@
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600;700;800&family=Tajawal:wght@700;800;900&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
scroll-behavior: smooth;
@layer base {
:root {
--background: 220 20% 97%;
--foreground: 220 30% 15%;
--card: 0 0% 100%;
--card-foreground: 220 30% 15%;
--popover: 0 0% 100%;
--popover-foreground: 220 30% 15%;
--primary: 240 70% 50%;
--primary-foreground: 0 0% 100%;
--secondary: 220 15% 90%;
--secondary-foreground: 220 30% 15%;
--muted: 220 15% 90%;
--muted-foreground: 220 10% 45%;
--accent: 260 80% 60%;
--accent-foreground: 0 0% 100%;
--destructive: 0 85% 60%;
--destructive-foreground: 0 0% 100%;
--success: 140 70% 40%;
--success-foreground: 0 0% 100%;
--border: 220 15% 85%;
--input: 220 15% 85%;
--ring: 240 70% 50%;
--radius: 0.75rem;
}
.dark {
--background: 224 30% 8%; /* Extremely sleek dark blue/black */
--foreground: 210 20% 90%;
--card: 224 30% 12%;
--card-foreground: 210 20% 90%;
--popover: 224 30% 12%;
--popover-foreground: 210 20% 90%;
--primary: 240 80% 65%; /* Vibrant glowing blue */
--primary-foreground: 224 30% 8%;
--secondary: 224 30% 15%;
--secondary-foreground: 210 20% 90%;
--muted: 224 30% 15%;
--muted-foreground: 215 15% 65%;
--accent: 270 80% 65%; /* Vibrant purple accent */
--accent-foreground: 0 0% 100%;
--destructive: 0 70% 50%;
--destructive-foreground: 0 0% 100%;
--success: 140 60% 50%;
--success-foreground: 0 0% 100%;
--border: 224 30% 20%;
--input: 224 30% 20%;
--ring: 240 80% 65%;
}
}
body {
font-family: 'IBM Plex Sans Arabic', 'Inter', sans-serif;
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground bg-gradient-to-tr from-background to-background/80 min-h-screen bg-fixed antialiased selection:bg-primary/20 selection:text-primary;
}
}
/* Glassmorphism Utilities */
@layer utilities {
.text-gradient {
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-secondary;
.glass {
@apply bg-white/10 dark:bg-black/20 backdrop-blur-md border border-white/20 dark:border-white/10 shadow-lg;
}
.bg-hero-gradient {
background: linear-gradient(135deg, #0F4C81 0%, #1A1A2E 50%, #0F4C81 100%);
}
.bg-grid {
background-image:
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
background-size: 60px 60px;
.glass-card {
@apply bg-card/80 backdrop-blur-xl border border-border shadow-xl rounded-2xl transition-all duration-300 hover:shadow-primary/5 hover:border-primary/20;
}
}
/* Animations & Scrollbar */
@layer utilities {
.animate-float {
animation: float 6s ease-in-out infinite;
}
@keyframes float {
0% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
100% { transform: translateY(0px); }
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-muted-foreground/30 rounded-full hover:bg-muted-foreground/50 transition-colors;
}
}

View File

@ -1,23 +1,32 @@
import type { Metadata } from "next";
import { Noto_Kufi_Arabic } from "next/font/google";
import "./globals.css";
const kufi = Noto_Kufi_Arabic({
subsets: ["arabic", "latin"],
weight: ["300", "400", "500", "600", "700"],
variable: "--font-kufi",
});
export const metadata: Metadata = {
title: "Dealix - ديل اي اكس | منصة مبيعات ذكية للشركات",
description: "منصة ذكاء اصطناعي لأتمتة المبيعات. تدير عملاءك، تتابعهم تلقائياً، وتغلق الصفقات. مصممة للسوق السعودي.",
keywords: "مبيعات, CRM, SaaS, ذكاء اصطناعي, أتمتة, عيادات, عقارات, الرياض, السعودية",
title: "ديل اي اكس - Dealix OS",
description: "The autonomous AI sales engine for the Saudi market.",
};
export default function RootLayout({
children,
}: {
}: Readonly<{
children: React.ReactNode;
}) {
}>) {
return (
<html lang="ar" dir="rtl">
<head>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
</head>
<body className="antialiased">{children}</body>
<html lang="ar" dir="rtl" className="dark">
<body className={`${kufi.variable} font-sans antialiased`}>
{/* Background Gradients for depth */}
<div className="fixed inset-0 z-[-1] bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-primary/10 via-background to-background pointer-events-none" />
<div className="fixed top-20 left-10 w-96 h-96 bg-accent/10 rounded-full mix-blend-multiply filter blur-[100px] opacity-50 z-[-1]" />
{children}
</body>
</html>
);
}

View File

@ -1,505 +1,137 @@
import {
Users, MessageSquare, BarChart3, Target, Zap, Phone,
CheckCircle2, ArrowLeft, Star, ChevronDown, Building2,
Stethoscope, Home, Clock, Shield, Globe, Award, Briefcase,
Bot, UserPlus
"use client";
import { useState } from "react";
import {
BarChart3,
Users,
Target,
MessageSquare,
Zap,
Bell,
Search,
BrainCircuit,
Settings,
BookOpen,
MonitorPlay,
FileSignature,
ShieldCheck,
Phone
} from "lucide-react";
const features = [
{ icon: Users, title: "إدارة العملاء المحتملين", titleEn: "Lead Management", desc: "التقط العملاء من واتساب، الموقع، ووسائل التواصل تلقائياً" },
{ icon: MessageSquare, title: "المتابعة التلقائية", titleEn: "Auto Follow-up", desc: "الذكاء الاصطناعي يرسل رسائل وتذكيرات بدون تدخلك" },
{ icon: Target, title: "خط أنابيب المبيعات", titleEn: "Sales Pipeline", desc: "تابع صفقاتك بصرياً وحرّك كل صفقة بين المراحل بسهولة" },
{ icon: Zap, title: "عروض أسعار ذكية", titleEn: "Smart Proposals", desc: "أنشئ وأرسل عروض أسعار احترافية في دقائق" },
{ icon: BarChart3, title: "تقارير وتحليلات", titleEn: "Reports & Analytics", desc: "لوحات بيانات فورية تتابع إيراداتك وأداء فريقك" },
{ icon: Phone, title: "واتساب بزنس", titleEn: "WhatsApp Business", desc: "أرسل واستقبل الرسائل مباشرة من المنصة" },
];
import { DashboardView } from "../components/dealix/dashboard-view";
import { AffiliatesView } from "../components/dealix/affiliates-view";
import { ChatbotView } from "../components/dealix/chatbot-view";
import { PresentationsView } from "../components/dealix/presentations-view";
import { ScriptsView } from "../components/dealix/scripts-view";
import { AgreementsView } from "../components/dealix/agreements-view";
import { GuaranteesView } from "../components/dealix/guarantees-view";
import { OnboardingView } from "../components/dealix/onboarding-view";
const painPoints = [
{ emoji: "😰", text: "تضيع عملاء لأن المتابعة متأخرة؟" },
{ emoji: "😵", text: "فريقك يشتغل بدون نظام واضح؟" },
{ emoji: "💸", text: "ما تعرف وين فلوسك رايحة؟" },
];
export default function AppLayout() {
const [activeTab, setActiveTab] = useState("overview");
const steps = [
{ num: "01", title: "سجّل شركتك", desc: "في دقيقتين فقط", icon: Building2 },
{ num: "02", title: "اختر قالب قطاعك", desc: "عيادات، عقارات، أو غيرها", icon: Globe },
{ num: "03", title: "المنصة تبدأ تبيع لك", desc: "المتابعة التلقائية تبدأ فوراً", icon: Zap },
];
const NAV_ITEMS = [
{ id: "overview", label: "نظرة عامة", icon: BarChart3 },
{ id: "affiliates", label: "المسوقين والموظفين", icon: Users },
{ id: "agents", label: "الوكلاء الأذكياء (Agents)", icon: BrainCircuit },
{ id: "presentations", label: "البرزنتيشنات القطاعية", icon: MonitorPlay },
{ id: "scripts", label: "سكربتات المبيعات", icon: Phone },
{ id: "agreements", label: "الاتفاقيات واHR", icon: FileSignature },
{ id: "guarantee", label: "الضمان الذهبي", icon: ShieldCheck },
{ id: "onboarding", label: "ديل المسوق وتأهيله", icon: BookOpen },
];
const plans = [
{
name: "أساسي", nameEn: "Basic", price: "299", popular: false,
features: ["2 مستخدمين", "100 عميل محتمل/شهر", "500 رسالة واتساب", "3 أتمتة", "تقارير أساسية", "دعم بالإيميل"],
},
{
name: "احترافي", nameEn: "Professional", price: "699", popular: true,
features: ["10 مستخدمين", "1,000 عميل محتمل/شهر", "5,000 رسالة واتساب", "20 أتمتة", "تقارير متقدمة", "دعم أولوية", "قوالب قطاعية"],
},
{
name: "مؤسسات", nameEn: "Enterprise", price: "1,499", popular: false,
features: ["مستخدمين بلا حدود", "عملاء بلا حدود", "رسائل بلا حدود", "أتمتة بلا حدود", "تقارير مخصصة", "دعم مخصص", "API كامل", "مدير حساب خاص"],
},
];
const renderContent = () => {
switch (activeTab) {
case "overview": return <DashboardView />;
case "affiliates": return <AffiliatesView />;
case "agents": return <ChatbotView />;
case "presentations": return <PresentationsView />;
case "scripts": return <ScriptsView />;
case "agreements": return <AgreementsView />;
case "guarantee": return <GuaranteesView />;
case "onboarding": return <OnboardingView />;
default: return <DashboardView />;
}
};
const faqs = [
{ q: "هل يدعم الواتساب؟", a: "نعم، نربط مع واتساب بزنس API مباشرة. ترسل وتستقبل الرسائل من داخل المنصة." },
{ q: "هل بياناتي آمنة؟", a: "تشفير كامل لكل البيانات. سيرفرات آمنة مع نسخ احتياطية يومية." },
{ q: "كم يوم التجربة المجانية؟", a: "14 يوم كاملة بكل المميزات بدون بطاقة ائتمان." },
{ q: "هل يدعم العربي؟", a: "المنصة كاملة بالعربي والإنجليزي. مصممة للسوق السعودي." },
{ q: "أقدر ألغي أي وقت؟", a: "نعم، بدون أي التزام أو رسوم إلغاء." },
];
const stats = [
{ value: "+500", label: "شركة تثق بنا" },
{ value: "+10,000", label: "صفقة تم إغلاقها" },
{ value: "+2M", label: "رسالة تم إرسالها" },
{ value: "24/7", label: "أتمتة مستمرة" },
];
export default function LandingPage() {
return (
<div className="min-h-screen bg-white text-gray-900">
{/* Navigation */}
<nav className="fixed top-0 w-full z-50 bg-white/80 backdrop-blur-md border-b border-gray-100">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center gap-2">
<img src="/logo.svg" alt="Dealix" className="h-9 w-9" />
<span className="text-xl font-bold text-primary">Dealix</span>
<span className="text-sm text-gray-400 hidden sm:block">ديل اي اكس</span>
</div>
<div className="hidden md:flex items-center gap-8 text-sm">
<a href="#features" className="text-gray-600 hover:text-primary transition">المميزات</a>
<a href="#how-it-works" className="text-gray-600 hover:text-primary transition">كيف يعمل</a>
<a href="#industries" className="text-gray-600 hover:text-primary transition">القطاعات</a>
<a href="#pricing" className="text-gray-600 hover:text-primary transition">الأسعار</a>
<a href="#affiliate" className="text-gray-600 hover:text-primary transition">انضم لنا</a>
</div>
<div className="flex items-center gap-3">
<a href="/ar/login" className="text-sm text-gray-600 hover:text-primary transition hidden sm:block">تسجيل دخول</a>
<a href="/ar/register" className="bg-accent hover:bg-accent-600 text-white px-5 py-2 rounded-lg text-sm font-medium transition shadow-lg shadow-accent/25">
ابدأ مجاناً
</a>
<div className="min-h-screen flex w-full">
{/* ── Sidebar ────────────────────────────────────────────────── */}
<aside className="w-72 hidden lg:flex flex-col border-l border-border bg-card/50 backdrop-blur-xl">
<div className="h-20 flex items-center px-8 border-b border-border">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-tr from-primary to-accent flex items-center justify-center shadow-lg shadow-primary/20">
<Zap className="w-5 h-5 text-white" />
</div>
<span className="text-xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white to-white/70">
Dealix OS
</span>
</div>
</div>
</nav>
{/* Hero Section */}
<section className="bg-hero-gradient bg-grid pt-32 pb-20 px-4 text-white overflow-hidden relative">
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-primary/20"></div>
<div className="max-w-7xl mx-auto relative z-10">
<div className="max-w-3xl mx-auto text-center">
<div className="inline-flex items-center gap-2 bg-white/10 rounded-full px-4 py-1.5 text-sm mb-6 backdrop-blur-sm">
<span className="w-2 h-2 bg-secondary rounded-full animate-pulse"></span>
صنع في السعودية للسوق السعودي
</div>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold font-arabic leading-tight mb-6">
حوّل مبيعاتك إلى
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-l from-secondary to-emerald-300">
ماكينة أرباح تعمل 24/7
</span>
</h1>
<p className="text-lg sm:text-xl text-gray-300 mb-8 max-w-2xl mx-auto leading-relaxed">
منصة ذكاء اصطناعي تدير عملاءك، تتابعهم تلقائياً، وتغلق الصفقات بدون تدخل. مصممة للشركات الصغيرة والمتوسطة.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<a href="/ar/register" className="bg-accent hover:bg-accent-600 text-white px-8 py-4 rounded-xl text-lg font-bold transition shadow-2xl shadow-accent/30 flex items-center gap-2">
ابدأ مجاناً لمدة 14 يوم
<ArrowLeft className="w-5 h-5" />
</a>
<a href="#features" className="text-white/80 hover:text-white transition flex items-center gap-2">
اكتشف المميزات
<ChevronDown className="w-4 h-4" />
</a>
</div>
<p className="text-sm text-gray-400 mt-4">بدون بطاقة ائتمان إلغاء أي وقت</p>
</div>
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
{NAV_ITEMS.map((item) => (
<button
key={item.id}
onClick={() => setActiveTab(item.id)}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 ${
activeTab === item.id
? "bg-primary/10 text-primary font-bold border border-primary/20 shadow-sm"
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground font-medium"
}`}
>
<item.icon className={`w-5 h-5 ${activeTab === item.id ? "text-primary" : "opacity-70"}`} />
<span>{item.label}</span>
</button>
))}
</nav>
{/* Dashboard Mockup */}
<div className="mt-16 max-w-4xl mx-auto">
<div className="bg-white/5 backdrop-blur-xl rounded-2xl border border-white/10 p-6 shadow-2xl">
<div className="flex gap-2 mb-4">
<div className="w-3 h-3 rounded-full bg-red-400"></div>
<div className="w-3 h-3 rounded-full bg-yellow-400"></div>
<div className="w-3 h-3 rounded-full bg-green-400"></div>
</div>
<div className="grid grid-cols-4 gap-4 mb-4">
{[
{ label: "عملاء جدد اليوم", value: "23", color: "text-secondary" },
{ label: "صفقات مفتوحة", value: "47", color: "text-accent" },
{ label: "إيرادات الشهر", value: "185K", color: "text-emerald-400" },
{ label: "معدل التحويل", value: "34%", color: "text-purple-400" },
].map((stat, i) => (
<div key={i} className="bg-white/5 rounded-lg p-3 text-center">
<div className={`text-2xl font-bold ${stat.color}`}>{stat.value}</div>
<div className="text-xs text-gray-400 mt-1">{stat.label}</div>
</div>
))}
</div>
<div className="grid grid-cols-5 gap-3">
{["جديد", "تم التواصل", "موعد محجوز", "عرض سعر", "تم الإغلاق"].map((stage, i) => (
<div key={i} className="bg-white/5 rounded-lg p-2">
<div className="text-xs text-gray-400 mb-2 text-center">{stage}</div>
{Array.from({ length: 3 - Math.floor(i * 0.5) }).map((_, j) => (
<div key={j} className="bg-white/10 rounded h-8 mb-1.5"></div>
))}
</div>
))}
</div>
</div>
</div>
<div className="p-4 mt-auto border-t border-border/50 bg-secondary/10">
<button className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-muted-foreground hover:bg-secondary/50 transition-all font-medium">
<Settings className="w-5 h-5" />
<span>الإعدادات المتقدمة</span>
</button>
</div>
</section>
</aside>
{/* Pain Points */}
<section className="py-16 bg-gray-50">
<div className="max-w-5xl mx-auto px-4">
<div className="grid md:grid-cols-3 gap-6">
{painPoints.map((p, i) => (
<div key={i} className="bg-white rounded-xl p-6 text-center shadow-sm border border-gray-100 hover:shadow-md transition">
<div className="text-4xl mb-3">{p.emoji}</div>
<p className="text-lg font-medium text-gray-800">{p.text}</p>
<p className="text-sm text-secondary mt-2 font-medium">Dealix يحل هذي المشكلة</p>
{/* ── Main Content ────────────────────────────────────────────── */}
<main className="flex-1 flex flex-col h-screen overflow-y-auto bg-background/50">
{/* Header */}
<header className="h-20 flex items-center justify-between px-8 border-b border-border bg-card/50 backdrop-blur-md sticky top-0 z-10 transition-all">
<div className="relative w-96">
<Search className="w-5 h-5 absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="البحث الشامل في Dealix (عميل، مسوق، صفقة)..."
className="w-full bg-secondary/50 border border-border rounded-full py-2.5 pr-12 pl-4 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all font-sans"
/>
</div>
<div className="flex items-center gap-6">
<button className="relative p-2 text-muted-foreground hover:text-foreground transition-colors">
<Bell className="w-5 h-5" />
<span className="absolute top-1.5 right-1.5 w-2.5 h-2.5 bg-primary border-2 border-background rounded-full animate-pulse" />
</button>
<div className="flex items-center gap-3 pl-4 border-l border-border">
<div className="text-left hidden md:block">
<p className="text-sm font-bold">سالم الدوسري</p>
<p className="text-xs text-muted-foreground">المدير العام (Founder)</p>
</div>
))}
</div>
</div>
</section>
{/* Features */}
<section id="features" className="py-20 px-4">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-14">
<h2 className="text-3xl sm:text-4xl font-bold mb-4">كل اللي تحتاجه لزيادة مبيعاتك</h2>
<p className="text-gray-500 text-lg max-w-2xl mx-auto">منصة متكاملة تجمع كل أدوات المبيعات في مكان واحد</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{features.map((f, i) => (
<div key={i} className="group bg-white border border-gray-100 rounded-2xl p-6 hover:shadow-xl hover:border-primary/20 transition-all duration-300">
<div className="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center mb-4 group-hover:bg-primary group-hover:text-white transition-all">
<f.icon className="w-6 h-6 text-primary group-hover:text-white" />
<div className="w-10 h-10 rounded-full bg-gradient-to-tr from-blue-500 to-primary p-[2px]">
<div className="w-full h-full rounded-full bg-card flex items-center justify-center border-2 border-background">
<span className="text-sm font-bold text-foreground">SD</span>
</div>
<h3 className="text-lg font-bold mb-1">{f.title}</h3>
<p className="text-xs text-gray-400 mb-2">{f.titleEn}</p>
<p className="text-gray-500 text-sm leading-relaxed">{f.desc}</p>
</div>
))}
</div>
</div>
</div>
</section>
</header>
{/* How It Works */}
<section id="how-it-works" className="py-20 bg-gray-50 px-4">
<div className="max-w-5xl mx-auto">
<div className="text-center mb-14">
<h2 className="text-3xl sm:text-4xl font-bold mb-4">ابدأ في 3 خطوات</h2>
<p className="text-gray-500 text-lg">من التسجيل إلى أول صفقة في دقائق</p>
</div>
<div className="grid md:grid-cols-3 gap-8">
{steps.map((s, i) => (
<div key={i} className="text-center relative">
<div className="w-20 h-20 bg-primary rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg shadow-primary/25">
<s.icon className="w-10 h-10 text-white" />
</div>
<div className="text-xs text-primary font-bold mb-2">الخطوة {s.num}</div>
<h3 className="text-xl font-bold mb-2">{s.title}</h3>
<p className="text-gray-500 text-sm">{s.desc}</p>
</div>
))}
</div>
{/* Dynamic View Injection */}
<div className="flex-1 w-full max-w-[1600px] mx-auto">
{renderContent()}
</div>
</section>
{/* Industry Templates */}
<section id="industries" className="py-20 px-4">
<div className="max-w-5xl mx-auto">
<div className="text-center mb-14">
<h2 className="text-3xl sm:text-4xl font-bold mb-4">قوالب جاهزة لقطاعك</h2>
<p className="text-gray-500 text-lg">اختر قالب قطاعك وابدأ فوراً</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-gradient-to-br from-primary to-primary-700 rounded-2xl p-6 text-white shadow-xl">
<Stethoscope className="w-10 h-10 mb-4" />
<h3 className="text-lg font-bold mb-1">العيادات والصحة</h3>
<p className="text-sm text-white/70 mb-4">إدارة المرضى والمواعيد والمتابعة</p>
<div className="text-xs bg-white/20 rounded-full px-3 py-1 inline-block">متاح الآن</div>
</div>
<div className="bg-gradient-to-br from-secondary to-secondary-700 rounded-2xl p-6 text-white shadow-xl">
<Home className="w-10 h-10 mb-4" />
<h3 className="text-lg font-bold mb-1">عقارات الرياض</h3>
<p className="text-sm text-white/70 mb-4">عقارات، جولات، عروض، أحياء الرياض</p>
<div className="text-xs bg-white/20 rounded-full px-3 py-1 inline-block">متاح الآن</div>
</div>
<div className="bg-gray-100 rounded-2xl p-6 text-gray-400">
<Building2 className="w-10 h-10 mb-4" />
<h3 className="text-lg font-bold mb-1 text-gray-500">المقاولات</h3>
<p className="text-sm mb-4">إدارة المشاريع والعملاء</p>
<div className="text-xs bg-gray-200 rounded-full px-3 py-1 inline-block">قريباً</div>
</div>
<div className="bg-gray-100 rounded-2xl p-6 text-gray-400">
<Star className="w-10 h-10 mb-4" />
<h3 className="text-lg font-bold mb-1 text-gray-500">الصالونات</h3>
<p className="text-sm mb-4">حجوزات ومتابعة العملاء</p>
<div className="text-xs bg-gray-200 rounded-full px-3 py-1 inline-block">قريباً</div>
</div>
</div>
</div>
</section>
{/* Stats */}
<section className="py-16 bg-dark text-white">
<div className="max-w-5xl mx-auto px-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
{stats.map((s, i) => (
<div key={i} className="text-center">
<div className="text-3xl sm:text-4xl font-bold text-secondary mb-1">{s.value}</div>
<div className="text-sm text-gray-400">{s.label}</div>
</div>
))}
</div>
</div>
</section>
{/* Pricing */}
<section id="pricing" className="py-20 px-4">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-14">
<h2 className="text-3xl sm:text-4xl font-bold mb-4">خطط تناسب حجم شركتك</h2>
<p className="text-gray-500 text-lg">ابدأ مجاناً 14 يوم بدون بطاقة ائتمان</p>
</div>
<div className="grid md:grid-cols-3 gap-6">
{plans.map((plan, i) => (
<div key={i} className={`rounded-2xl p-6 border-2 transition-all ${
plan.popular
? "border-primary bg-primary/5 shadow-xl scale-105 relative"
: "border-gray-100 bg-white hover:border-gray-200"
}`}>
{plan.popular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-white text-xs px-4 py-1 rounded-full font-medium">
الأكثر شعبية
</div>
)}
<div className="text-center mb-6">
<h3 className="text-lg font-bold">{plan.name}</h3>
<p className="text-xs text-gray-400">{plan.nameEn}</p>
<div className="mt-4">
<span className="text-4xl font-bold">{plan.price}</span>
<span className="text-gray-500 text-sm"> ر.س/شهر</span>
</div>
</div>
<ul className="space-y-3 mb-6">
{plan.features.map((f, j) => (
<li key={j} className="flex items-center gap-2 text-sm">
<CheckCircle2 className="w-4 h-4 text-secondary flex-shrink-0" />
{f}
</li>
))}
</ul>
<a href="/ar/register" className={`block text-center py-3 rounded-xl font-medium transition ${
plan.popular
? "bg-primary text-white hover:bg-primary-600 shadow-lg"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}>
ابدأ تجربة مجانية
</a>
</div>
))}
</div>
</div>
</section>
{/* FAQ */}
<section className="py-20 bg-gray-50 px-4">
<div className="max-w-3xl mx-auto">
<div className="text-center mb-14">
<h2 className="text-3xl font-bold mb-4">أسئلة شائعة</h2>
</div>
<div className="space-y-4">
{faqs.map((faq, i) => (
<details key={i} className="bg-white rounded-xl border border-gray-100 group">
<summary className="flex items-center justify-between p-5 cursor-pointer font-medium hover:text-primary transition">
{faq.q}
<ChevronDown className="w-5 h-5 text-gray-400 group-open:rotate-180 transition-transform" />
</summary>
<div className="px-5 pb-5 text-gray-500 text-sm leading-relaxed">{faq.a}</div>
</details>
))}
</div>
</div>
</section>
{/* Gold Guarantee */}
<section className="py-20 px-4 bg-gradient-to-br from-yellow-50 to-amber-50">
<div className="max-w-4xl mx-auto text-center">
<div className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-yellow-400 to-amber-500 rounded-full mb-6 shadow-xl shadow-amber-200">
<Award className="w-10 h-10 text-white" />
</div>
<h2 className="text-3xl sm:text-4xl font-bold mb-4">الضمان الذهبي</h2>
<p className="text-xl text-gray-600 mb-6">ما تستفيد؟ نرجع لك فلوسك كاملة</p>
<div className="bg-white rounded-2xl p-8 shadow-lg border border-amber-100 max-w-2xl mx-auto">
<p className="text-gray-700 text-lg leading-relaxed mb-6">
نحن واثقين من جودة Dealix. إذا استخدمت المنصة لمدة 30 يوم وما شفت نتائج حقيقية في مبيعاتك، نرجع لك المبلغ كامل بدون أي سؤال.
</p>
<div className="grid sm:grid-cols-3 gap-4 text-sm">
<div className="bg-amber-50 rounded-xl p-4">
<div className="font-bold text-amber-700 mb-1">30 يوم</div>
<div className="text-gray-500">فترة الضمان</div>
</div>
<div className="bg-amber-50 rounded-xl p-4">
<div className="font-bold text-amber-700 mb-1">100%</div>
<div className="text-gray-500">استرجاع كامل</div>
</div>
<div className="bg-amber-50 rounded-xl p-4">
<div className="font-bold text-amber-700 mb-1">بدون تعقيد</div>
<div className="text-gray-500">عملية سهلة وسريعة</div>
</div>
</div>
</div>
</div>
</section>
{/* Affiliate / Join Our Team */}
<section className="py-20 px-4 bg-gradient-to-br from-primary/5 to-secondary/5">
<div className="max-w-5xl mx-auto">
<div className="text-center mb-12">
<div className="inline-flex items-center gap-2 bg-primary/10 rounded-full px-4 py-1.5 text-sm mb-4 text-primary font-medium">
<Briefcase className="w-4 h-4" />
فرصة عمل حقيقية
</div>
<h2 className="text-3xl sm:text-4xl font-bold mb-4">انضم لفريق Dealix كمستشار مبيعات</h2>
<p className="text-gray-500 text-lg max-w-2xl mx-auto">اشتغل من أي مكان، في أي وقت يناسبك، واكسب عمولات شهرية متكررة. حقق التارقت وتوظف رسمياً!</p>
</div>
<div className="grid md:grid-cols-3 gap-6 mb-10">
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 text-center">
<div className="w-14 h-14 bg-green-100 rounded-xl flex items-center justify-center mx-auto mb-4">
<UserPlus className="w-7 h-7 text-green-600" />
</div>
<h3 className="text-lg font-bold mb-2">سجّل كمسوّق</h3>
<p className="text-gray-500 text-sm">تعبئة بياناتك واستلام حزمة التدريب الكاملة والأدوات الاحترافية</p>
</div>
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 text-center">
<div className="w-14 h-14 bg-blue-100 rounded-xl flex items-center justify-center mx-auto mb-4">
<Target className="w-7 h-7 text-blue-600" />
</div>
<h3 className="text-lg font-bold mb-2">استهدف واكسب</h3>
<p className="text-gray-500 text-sm">استخدم السكربتات والبرزنتيشنات الجاهزة لجذب العملاء واكسب عمولة على كل صفقة</p>
</div>
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 text-center">
<div className="w-14 h-14 bg-purple-100 rounded-xl flex items-center justify-center mx-auto mb-4">
<Award className="w-7 h-7 text-purple-600" />
</div>
<h3 className="text-lg font-bold mb-2">حقق التارقت وتوظف</h3>
<p className="text-gray-500 text-sm">10 شركات بالشهر = توظيف رسمي براتب ثابت + عمولات أعلى + تأمين صحي</p>
</div>
</div>
<div className="text-center">
<a href="/ar/affiliate" className="bg-primary hover:bg-primary-600 text-white px-8 py-4 rounded-xl text-lg font-bold transition shadow-2xl shadow-primary/25 inline-flex items-center gap-2">
انضم الآن كمستشار مبيعات
<ArrowLeft className="w-5 h-5" />
</a>
<p className="text-sm text-gray-400 mt-3">بدون رسوم انضمام تدريب مجاني أدوات احترافية مجانية</p>
</div>
</div>
</section>
{/* AI Agents Section */}
<section className="py-20 px-4 bg-dark text-white">
<div className="max-w-5xl mx-auto">
<div className="text-center mb-12">
<div className="inline-flex items-center gap-2 bg-white/10 rounded-full px-4 py-1.5 text-sm mb-4">
<Bot className="w-4 h-4 text-secondary" />
<span className="text-secondary font-medium">ذكاء اصطناعي متقدم</span>
</div>
<h2 className="text-3xl sm:text-4xl font-bold mb-4">وكلاء الذكاء الاصطناعي يشتغلون لك 24/7</h2>
<p className="text-gray-400 text-lg max-w-2xl mx-auto">ما تحتاج توظف فريق مبيعات ضخم. وكلاء Dealix الأذكياء يلقون العملاء، يتواصلون معهم، ويحجزون لك الاجتماعات تلقائياً.</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white/5 rounded-2xl p-6 border border-white/10">
<div className="text-3xl mb-3">🔍</div>
<h3 className="text-lg font-bold mb-2">البحث الذكي</h3>
<p className="text-sm text-gray-400">يبحث عن عملاء محتملين من قوقل، لينكدن، والأدلة التجارية تلقائياً</p>
</div>
<div className="bg-white/5 rounded-2xl p-6 border border-white/10">
<div className="text-3xl mb-3">💬</div>
<h3 className="text-lg font-bold mb-2">واتساب ذكي</h3>
<p className="text-sm text-gray-400">بوت واتساب متقدم يتواصل مع العملاء، يجاوب أسئلتهم، ويتابع معهم</p>
</div>
<div className="bg-white/5 rounded-2xl p-6 border border-white/10">
<div className="text-3xl mb-3">📞</div>
<h3 className="text-lg font-bold mb-2">مكالمات صوتية</h3>
<p className="text-sm text-gray-400">مكالمات آلية ذكية بالعربي تعرّف العميل بالمنصة وتحجز له موعد</p>
</div>
<div className="bg-white/5 rounded-2xl p-6 border border-white/10">
<div className="text-3xl mb-3">📅</div>
<h3 className="text-lg font-bold mb-2">حجز تلقائي</h3>
<p className="text-sm text-gray-400">يحجز اجتماعات مع فريق المبيعات تلقائياً للعملاء المؤهلين</p>
</div>
</div>
</div>
</section>
{/* Final CTA */}
<section className="py-20 bg-hero-gradient text-white px-4">
<div className="max-w-3xl mx-auto text-center">
<h2 className="text-3xl sm:text-4xl font-bold mb-4">جاهز تزيد مبيعاتك؟</h2>
<p className="text-lg text-gray-300 mb-8">انضم لمئات الشركات اللي زادت مبيعاتها مع Dealix</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<a href="/ar/register" className="bg-accent hover:bg-accent-600 text-white px-8 py-4 rounded-xl text-lg font-bold transition shadow-2xl">
ابدأ مجاناً الآن
</a>
<a href="https://wa.me/966XXXXXXXXXX" className="bg-green-500 hover:bg-green-600 text-white px-8 py-4 rounded-xl text-lg font-bold transition shadow-2xl flex items-center gap-2">
<Phone className="w-5 h-5" />
تواصل عبر واتساب
</a>
</div>
</div>
</section>
{/* Footer */}
<footer className="bg-dark text-gray-400 py-12 px-4">
<div className="max-w-7xl mx-auto">
<div className="grid md:grid-cols-4 gap-8 mb-8">
<div>
<div className="flex items-center gap-2 mb-4">
<img src="/logo.svg" alt="Dealix" className="h-8 w-8" />
<span className="text-white font-bold text-lg">Dealix</span>
</div>
<p className="text-sm mb-4 leading-relaxed">مبيعاتك تشتغل وأنت ترتاح</p>
<p className="text-sm">Sales on Autopilot</p>
</div>
<div>
<h4 className="text-white font-medium mb-4">المنصة</h4>
<ul className="space-y-2 text-sm">
<li><a href="#features" className="hover:text-white transition">المميزات</a></li>
<li><a href="#pricing" className="hover:text-white transition">الأسعار</a></li>
<li><a href="#industries" className="hover:text-white transition">القطاعات</a></li>
</ul>
</div>
<div>
<h4 className="text-white font-medium mb-4">الشركة</h4>
<ul className="space-y-2 text-sm">
<li><a href="#" className="hover:text-white transition">من نحن</a></li>
<li><a href="#" className="hover:text-white transition">تواصل معنا</a></li>
<li><a href="#" className="hover:text-white transition">سياسة الخصوصية</a></li>
</ul>
</div>
<div>
<h4 className="text-white font-medium mb-4">تواصل معنا</h4>
<ul className="space-y-2 text-sm">
<li className="flex items-center gap-2"><Phone className="w-4 h-4" /> واتساب بزنس</li>
<li className="flex items-center gap-2"><Shield className="w-4 h-4" /> دعم فني</li>
<li className="flex items-center gap-2"><Clock className="w-4 h-4" /> 24/7 متاح</li>
</ul>
</div>
</div>
<div className="border-t border-gray-800 pt-8 flex flex-col sm:flex-row justify-between items-center gap-4">
<p className="text-sm">© 2024 Dealix. جميع الحقوق محفوظة</p>
<p className="text-sm flex items-center gap-1">صنع بـ في السعودية 🇸🇦</p>
</div>
</div>
</footer>
</main>
</div>
);
}

View File

@ -0,0 +1,125 @@
import { Users, Award, TrendingUp, AlertCircle, Building2, UserPlus, Filter, Download } from "lucide-react";
export function AffiliatesView() {
const affiliates = [
{ id: "A-101", name: "أحمد عبدالله", status: "نشط", sales: 12, rev: "450K ر.س", comm: "45K ر.س", level: "Senior", eligibleForHire: true },
{ id: "A-102", name: "سارة خالد", status: "نشط", sales: 4, rev: "120K ر.س", comm: "9.6K ر.س", level: "Mid", eligibleForHire: false },
{ id: "A-103", name: "محمد ياسر", status: "إنذار", sales: 0, rev: "0 ر.س", comm: "0 ر.س", level: "New", eligibleForHire: false },
{ id: "A-104", name: "فهد عبدالرحمن", status: "نشط", sales: 8, rev: "240K ر.س", comm: "24K ر.س", level: "Mid", eligibleForHire: false },
{ id: "A-105", name: "لينا العتيبي", status: "مرشح للتعيين", sales: 15, rev: "600K ر.س", comm: "60K ر.س", level: "Senior", eligibleForHire: true },
];
return (
<div className="p-8 max-w-7xl mx-auto space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="flex justify-between items-end mb-6">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">👥 إدارة الشركاء والمسوقين (Affiliates)</h1>
<p className="text-muted-foreground">مراقبة أداء المسوقين بالعمولة ومراحلة التوظيف الآلية (Auto-Hire).</p>
</div>
<div className="flex gap-3">
<button className="flex items-center gap-2 px-5 py-2.5 rounded-xl border border-border bg-card hover:bg-secondary/50 transition-colors text-sm font-medium">
<Download className="w-4 h-4" />
تصدير
</button>
<button className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-lg shadow-primary/25 transition-all">
<UserPlus className="w-5 h-5" />
إضافة مسوق
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="glass-card p-6 border border-border/50">
<div className="flex justify-between items-center mb-4">
<div className="p-3 rounded-xl bg-blue-500/10 text-blue-500">
<Users className="w-6 h-6" />
</div>
<span className="text-sm font-medium text-emerald-500 bg-emerald-500/10 px-2 py-1 rounded">+12%</span>
</div>
<h3 className="text-2xl font-bold mb-1">124 مسوق</h3>
<p className="text-sm text-muted-foreground font-medium">المسوقين النشطين</p>
</div>
<div className="glass-card p-6 border border-border/50">
<div className="flex justify-between items-center mb-4">
<div className="p-3 rounded-xl bg-emerald-500/10 text-emerald-500">
<TrendingUp className="w-6 h-6" />
</div>
<span className="text-sm font-medium text-emerald-500 bg-emerald-500/10 px-2 py-1 rounded">+24%</span>
</div>
<h3 className="text-2xl font-bold mb-1">2.4M ر.س</h3>
<p className="text-sm text-muted-foreground font-medium">إيرادات فريق التسويق (الشهر)</p>
</div>
<div className="glass-card p-6 border border-primary/30 bg-primary/5">
<div className="flex justify-between items-center mb-4">
<div className="p-3 rounded-xl bg-primary text-primary-foreground shadow-lg">
<Building2 className="w-6 h-6" />
</div>
</div>
<h3 className="text-2xl font-bold mb-1 text-primary">3 مسوقين</h3>
<p className="text-sm text-muted-foreground font-medium">استوفوا شروط التوظيف الفوري (10+ شركات)</p>
</div>
</div>
<div className="glass-card overflow-hidden border border-border/50">
<div className="flex justify-between items-center p-6 border-b border-border/50 bg-secondary/10">
<h2 className="text-lg font-bold">قائمة المسوقين بالعمولة</h2>
<button className="flex items-center gap-2 p-2 rounded-lg text-muted-foreground hover:bg-secondary/50 transition-colors">
<Filter className="w-5 h-5" />
<span className="text-sm">تصفية</span>
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-right text-sm">
<thead className="bg-secondary/30 text-muted-foreground">
<tr>
<th className="py-4 px-6 font-medium">الرقم</th>
<th className="py-4 px-6 font-medium">الاسم</th>
<th className="py-4 px-6 font-medium">المستوى</th>
<th className="py-4 px-6 font-medium">الإغلاقات (الشهر)</th>
<th className="py-4 px-6 font-medium">المبيعات المُدخلة</th>
<th className="py-4 px-6 font-medium">العمولة المكتسبة</th>
<th className="py-4 px-6 font-medium">الإجراء</th>
</tr>
</thead>
<tbody className="divide-y divide-border/30">
{affiliates.map((aff, i) => (
<tr key={i} className="hover:bg-white/5 transition-colors group">
<td className="py-4 px-6 font-mono text-muted-foreground">{aff.id}</td>
<td className="py-4 px-6">
<div className="font-bold text-foreground">{aff.name}</div>
<div className="text-xs text-muted-foreground mt-0.5">{aff.status}</div>
</td>
<td className="py-4 px-6">
<span className={`px-2.5 py-1 rounded text-xs font-bold ${
aff.level === 'Senior' ? 'bg-primary/20 text-primary' :
aff.level === 'Mid' ? 'bg-blue-500/20 text-blue-500' : 'bg-slate-500/20 text-slate-500'
}`}>
{aff.level}
</span>
</td>
<td className="py-4 px-6 font-bold">{aff.sales}</td>
<td className="py-4 px-6 font-mono">{aff.rev}</td>
<td className="py-4 px-6 font-mono text-emerald-500">{aff.comm}</td>
<td className="py-4 px-6">
{aff.eligibleForHire ? (
<button className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-500 text-white rounded-lg text-xs font-bold hover:bg-emerald-600 transition-colors shadow-lg shadow-emerald-500/20">
<Award className="w-3.5 h-3.5" />
ترقية (توظيف)
</button>
) : (
<button className="flex items-center gap-1.5 px-3 py-1.5 bg-background border border-border rounded-lg text-xs font-bold hover:bg-secondary transition-colors text-muted-foreground group-hover:text-foreground">
عرض التفاصيل
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,99 @@
import { FileSignature, ShieldCheck, MailPlus, AlertCircle, Building2, Download } from "lucide-react";
export function AgreementsView() {
return (
<div className="p-8 max-w-7xl mx-auto space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="flex justify-between items-end">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">📋 الاتفاقيات والموارد البشرية (Legal & HR)</h1>
<p className="text-muted-foreground">توليد وإدارة عقود المسوقين بالعمولة ومسار الترقية للتوظيف الرسمي.</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Tier 1: Affiliate Agreement */}
<div className="glass-card flex flex-col group overflow-hidden">
<div className="p-6 border-b border-border/50 bg-blue-500/10 flex items-center gap-4">
<div className="p-3 rounded-xl bg-background shadow-sm text-blue-500">
<ShieldCheck className="w-8 h-8" />
</div>
<div>
<h2 className="text-xl font-bold">1. اتفاقية تسويق بالعمولة (عمل حر)</h2>
<span className="text-sm font-medium text-emerald-500">للمسوقين الجدد</span>
</div>
</div>
<div className="p-6 flex-1 flex flex-col gap-4">
<p className="text-sm text-foreground/80 leading-relaxed font-sans">
اتفاقية مبدئية تحفظ حقوق المسوق والشركة وتحدد نسب العمولة من (8% إلى 12%).
</p>
<ul className="space-y-3 mb-6">
<li className="flex items-center gap-2 text-sm">
<ShieldCheck className="w-4 h-4 text-emerald-500" />
<span>حماية الخصوصية و NDA لعدم إفشاء أسرار العملاء.</span>
</li>
<li className="flex items-center gap-2 text-sm">
<ShieldCheck className="w-4 h-4 text-emerald-500" />
<span>شروط دورة الدفع واستحقاق العمولة عند الإغلاق.</span>
</li>
<li className="flex items-center gap-2 text-sm">
<ShieldCheck className="w-4 h-4 text-emerald-500" />
<span>قواعد تمثيل الهوية التجارية لـ Dealix بأمانة.</span>
</li>
</ul>
<button className="mt-auto w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-lg shadow-primary/25 transition-all">
<MailPlus className="w-4 h-4" />
توليد وإرسال الاتفاقية (DocuSign)
</button>
<button className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-secondary text-secondary-foreground hover:bg-secondary/80 border border-border font-medium transition-all">
<Download className="w-4 h-4" />
تحميل نموذج PDF
</button>
</div>
</div>
{/* Tier 2: Formal Employment Workflow */}
<div className="glass-card flex flex-col group overflow-hidden border-2 border-primary/20">
<div className="p-6 border-b border-primary/20 bg-primary/10 flex items-center gap-4">
<div className="p-3 rounded-xl bg-primary shadow-sm text-primary-foreground shadow-primary/50 relative overflow-hidden">
<Building2 className="w-8 h-8 relative z-10" />
<div className="absolute inset-0 bg-white/20 animate-pulse" />
</div>
<div>
<h2 className="text-xl font-bold">2. مسار التوظيف الرسمي (قوى Qiwa)</h2>
<span className="text-sm font-medium text-primary">آلي بعد إغلاق 10 شركات / شهر</span>
</div>
</div>
<div className="p-6 flex-1 flex flex-col gap-4">
<div className="bg-amber-500/10 border border-amber-500/20 text-amber-500 rounded-xl p-4 flex items-start gap-3">
<AlertCircle className="w-5 h-5 shrink-0 mt-0.5" />
<p className="text-sm font-medium leading-relaxed">
يتم تفعيل هذا المسار تلقائياً عند تحقيق مستهدفات المبيعات المستمرة. النظام يقوم بأتمتة رفع تذكرة لإدارة الموارد البشرية لإنشاء عرض وظيفي رسمي عبر "قوى".
</p>
</div>
<ul className="space-y-3 mb-6 mt-2">
<li className="flex items-center gap-2 text-sm">
<Building2 className="w-4 h-4 text-primary" />
<span>تسجيل في التأمينات الاجتماعية وعقد رسمي (Qiwa).</span>
</li>
<li className="flex items-center gap-2 text-sm">
<Building2 className="w-4 h-4 text-primary" />
<span>راتب ثابت يبدأ من 5,000 ر.س + عمولة 5%.</span>
</li>
<li className="flex items-center gap-2 text-sm">
<Building2 className="w-4 h-4 text-primary" />
<span>ترقية صلاحيات في Dealix لمدير حسابات أقدم.</span>
</li>
</ul>
<button className="mt-auto w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-lg shadow-primary/25 transition-all opacity-80 cursor-progress">
<Building2 className="w-4 h-4" />
بدء مسار توظيف لمرشح (HR Trigger)
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,86 @@
import { Bot, Mic, MessageSquare, Plus, Activity, AlertCircle, Phone } from "lucide-react";
export function ChatbotView() {
const agents = [
{ name: "وكيل التأهيل (WhatsApp)", type: "Qualification", lang: "العربية (السعودية)", status: "Active", volume: 1450 },
{ name: "وكيل الاتصال الصوتي", type: "Voice Calls", lang: "العربية (السعودية)", status: "Training", volume: 320 },
{ name: "الوكيل العقاري الخاص", type: "Sector Specific", lang: "Bilingual", status: "Active", volume: 890 },
];
return (
<div className="p-8 max-w-7xl mx-auto space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="flex justify-between items-end">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">🤖 مركز تحكم وكلاء الذكاء الاصطناعي</h1>
<p className="text-muted-foreground">صناعة وتوجيه وكلاء المبيعات، المحادثة النصية (WhatsApp) والاتصال الصوتي (Voice Agents).</p>
</div>
<button className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-lg shadow-primary/25 transition-all">
<Plus className="w-5 h-5" />
بناء وكيل جديد
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{agents.map((agent, i) => (
<div key={i} className="glass-card flex flex-col group overflow-hidden border border-border/50">
<div className={`p-6 border-b border-border/50 flex justify-between items-center bg-secondary/20`}>
<div className="flex items-center gap-4">
<div className={`p-3 rounded-xl bg-background shadow-sm ${agent.type.includes('Voice') ? 'text-blue-500' : 'text-emerald-500'}`}>
{agent.type.includes('Voice') ? <Mic className="w-6 h-6" /> : <MessageSquare className="w-6 h-6" />}
</div>
<div>
<h3 className="font-bold text-lg">{agent.name}</h3>
<p className="text-xs text-muted-foreground">{agent.type}</p>
</div>
</div>
<div className={`w-3 h-3 rounded-full ${agent.status === 'Active' ? 'bg-emerald-500 animate-pulse' : 'bg-amber-500'}`} />
</div>
<div className="p-6 flex-1 flex flex-col gap-4">
<div className="flex justify-between items-center border-b border-border/50 py-2">
<span className="text-sm text-muted-foreground">اللغة المدعومة:</span>
<span className="text-sm font-medium">{agent.lang}</span>
</div>
<div className="flex justify-between items-center border-b border-border/50 py-2">
<span className="text-sm text-muted-foreground">إجمالي المحادثات:</span>
<span className="text-sm font-bold">{agent.volume}</span>
</div>
<div className="flex justify-between items-center border-b border-border/50 py-2">
<span className="text-sm text-muted-foreground">نسبة التسليم للبشر (Handoff):</span>
<span className="text-sm font-bold text-amber-500">12%</span>
</div>
<div className="mt-auto grid grid-cols-2 gap-3 pt-4">
<button className="flex items-center justify-center gap-2 py-2 rounded-lg bg-background border border-border hover:bg-secondary/50 transition-colors text-sm font-medium">
<Bot className="w-4 h-4" />
تعديل البرومبت
</button>
<button className="flex items-center justify-center gap-2 py-2 rounded-lg bg-background border border-border hover:bg-secondary/50 transition-colors text-sm font-medium">
<Activity className="w-4 h-4" />
سجل المحادثات
</button>
</div>
</div>
</div>
))}
</div>
{/* Voice Demo Panel */}
<div className="glass-card p-6 flex items-center justify-between border border-blue-500/20 bg-blue-500/5 mt-8">
<div className="flex items-center gap-4">
<div className="p-4 rounded-full bg-blue-500 text-white shadow-lg shadow-blue-500/30">
<Phone className="w-6 h-6 animate-pulse" />
</div>
<div>
<h3 className="text-lg font-bold">تجربة الوكيل الصوتي المباشر (Realtime SA)</h3>
<p className="text-sm text-muted-foreground mt-1">تحدث مباشرة مع وكيلك الذكي لتختبر اللهجة السعودية وسرعة الرد.</p>
</div>
</div>
<button className="px-6 py-3 rounded-xl bg-blue-500 hover:bg-blue-600 text-white font-bold transition-all shadow-lg flex gap-2 items-center">
<Mic className="w-5 h-5" />
بدء محاكاة المكالمة
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,131 @@
import { BarChart3, Users, Target, TrendingUp, Calendar, ArrowUpRight, BrainCircuit, Zap } from "lucide-react";
export function DashboardView() {
const stats = [
{ label: "العملاء المحتملين", value: "2,450", trend: "+12.5%", icon: Users, color: "text-blue-500", bg: "bg-blue-500/10" },
{ label: "الاجتماعات المجدولة", value: "145", trend: "+24.3%", icon: Calendar, color: "text-purple-500", bg: "bg-purple-500/10" },
{ label: "المبيعات المغلقة", value: "89", trend: "+8.2%", icon: Target, color: "text-emerald-500", bg: "bg-emerald-500/10" },
{ label: "إيرادات الشهر", value: "1.2M ر.س", trend: "+18.4%", icon: TrendingUp, color: "text-amber-500", bg: "bg-amber-500/10" },
];
const pipeline = [
{ name: "شركة الأفق التقنية", stage: "تفاوض", value: "125,000 ر.س", prob: "80%", agent: "وكيل الإغلاق" },
{ name: "مجموعة الرواد", stage: "عرض سعر", value: "450,000 ر.س", prob: "60%", agent: "متدرب الذكاء الاصطناعي" },
{ name: "مصنع الشرق الأوسط", stage: "اجتماع أولي", value: "85,000 ر.س", prob: "30%", agent: "مجدول المواعيد" },
{ name: "مؤسسة النور", stage: "تأهيل", value: "غير محدد", prob: "10%", agent: "وكيل التأهيل" },
];
return (
<div className="p-8 max-w-7xl mx-auto space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* Welcome Intro */}
<div className="flex justify-between items-end">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">أهلاً بك، سالم 👋</h1>
<p className="text-muted-foreground">نظرة عامة على أداء نظام المبيعات الذكي اليوم.</p>
</div>
<div className="flex gap-3">
<button className="px-5 py-2.5 rounded-xl border border-border bg-card hover:bg-secondary/50 transition-colors text-sm font-medium">
تصدير التقرير
</button>
<button className="px-5 py-2.5 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground transition-colors shadow-lg shadow-primary/25 text-sm font-medium flex items-center gap-2">
<Zap className="w-4 h-4" />
تفعيل وكيل جديد
</button>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{stats.map((stat, i) => (
<div key={i} className="glass-card p-6 relative overflow-hidden group">
<div className={`absolute -right-6 -top-6 w-24 h-24 rounded-full blur-2xl opacity-20 transition-all group-hover:opacity-40 group-hover:scale-150 ${stat.bg.replace('/10', '')}`} />
<div className="flex justify-between items-start mb-4">
<div className={`p-3 rounded-2xl ${stat.bg}`}>
<stat.icon className={`w-6 h-6 ${stat.color}`} />
</div>
<span className="flex items-center gap-1 text-sm font-medium text-emerald-500 bg-emerald-500/10 px-2.5 py-1 rounded-full">
<ArrowUpRight className="w-3 h-3" />
{stat.trend}
</span>
</div>
<div>
<h3 className="text-3xl font-bold tracking-tight mb-1">{stat.value}</h3>
<p className="text-sm text-muted-foreground font-medium">{stat.label}</p>
</div>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Pipeline Table */}
<div className="lg:col-span-2 glass-card p-6 border border-border/50">
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-bold">أحدث الصفقات في المسار</h2>
<button className="text-sm text-primary hover:underline">عرض الكل</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm text-right">
<thead>
<tr className="text-muted-foreground border-b border-border/50 bg-secondary/20">
<th className="py-3 px-4 font-medium">العميل</th>
<th className="py-3 px-4 font-medium">المرحلة</th>
<th className="py-3 px-4 font-medium">القيمة</th>
<th className="py-3 px-4 font-medium">احتمالية الإغلاق</th>
<th className="py-3 px-4 font-medium">الوكيل المسؤول</th>
</tr>
</thead>
<tbody className="divide-y divide-border/30">
{pipeline.map((deal, i) => (
<tr key={i} className="hover:bg-white/5 transition-colors group">
<td className="py-4 px-4 font-medium text-foreground">{deal.name}</td>
<td className="py-4 px-4">
<span className="px-3 py-1 rounded-full text-xs font-medium bg-secondary/50 text-secondary-foreground border border-border/50">
{deal.stage}
</span>
</td>
<td className="py-4 px-4 font-mono text-foreground/80">{deal.value}</td>
<td className="py-4 px-4">
<div className="flex items-center gap-2">
<div className="w-16 h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-1000"
style={{ width: deal.prob }}
/>
</div>
<span className="text-xs text-muted-foreground">{deal.prob}</span>
</div>
</td>
<td className="py-4 px-4 text-muted-foreground flex items-center gap-2">
<BrainCircuit className="w-4 h-4 opacity-50" />
{deal.agent}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Action Panel */}
<div className="glass-card p-6 flex flex-col border border-border/50">
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-bold">تنبيهات الإدارة العُليا</h2>
<span className="w-2 h-2 rounded-full bg-destructive animate-pulse" />
</div>
<div className="space-y-4 flex-1">
<div className="p-4 rounded-xl bg-destructive/10 border border-destructive/20 flex flex-col gap-2">
<span className="text-xs font-bold text-destructive">مراجعة شكوى</span>
<p className="text-sm font-medium">شركة "التطوير الذكي" تطلب تفعيل الضمان الذهبي لعدم الوصول للمستهدف التفاعلي.</p>
<button className="text-xs font-bold text-destructive underline mt-1 text-right w-full">مراجعة فحص الشرط الرابع</button>
</div>
<div className="p-4 rounded-xl bg-primary/10 border border-primary/20 flex flex-col gap-2">
<span className="text-xs font-bold text-primary">تفعيل توظيف مسوق</span>
<p className="text-sm font-medium">المسوق "أحمد عبدالله" أكمل 12 إغلاق، يحتاج لتحويل عقده إلى رسمي عبر Qiwa.</p>
<button className="text-xs font-bold text-primary underline mt-1 text-right w-full">بدء عملية HR</button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,96 @@
import { ShieldAlert, Info, AlertTriangle, FileCheck, CheckCircle2, RotateCcw } from "lucide-react";
export function GuaranteesView() {
return (
<div className="p-8 max-w-7xl mx-auto space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="flex justify-between items-end">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">🛡 الضمان الذهبي لـ Dealix (الاسترجاع)</h1>
<p className="text-muted-foreground">سياسة الضمان والشروط وإدارة المطالبات لضمان حق الشركة والعميل معاً.</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Policy Brief */}
<div className="lg:col-span-2 glass-card p-6 flex flex-col gap-6">
<div className="flex items-center gap-4 pb-4 border-b border-border/50">
<div className="p-3 rounded-xl bg-amber-500/10 text-amber-500">
<ShieldAlert className="w-6 h-6" />
</div>
<div>
<h2 className="text-xl font-bold">ملخص سياسة الضمان لمدة 30 يوماً</h2>
<p className="text-sm text-muted-foreground mt-1">يُشترط للوفاء بالضمان الالتزام بخطة تشغيل الوكيل الذكي بالكامل.</p>
</div>
</div>
<div className="space-y-4">
<h3 className="font-bold text-foreground/90">شروط الاستحقاق الرئيسية:</h3>
<ul className="space-y-3 p-4 bg-secondary/30 rounded-xl border border-secondary">
<li className="flex gap-3 text-sm">
<CheckCircle2 className="w-5 h-5 text-emerald-500 shrink-0" />
<span className="leading-relaxed">أن يكون العميل قد وفر بيانات التدريب اللازمة (منتجات، أسعار، PDF معرفي) خلال أول 3 أيام من الاشتراك.</span>
</li>
<li className="flex gap-3 text-sm">
<CheckCircle2 className="w-5 h-5 text-emerald-500 shrink-0" />
<span className="leading-relaxed">تفعيل الوكيل أو الشات بوت على قنوات حية (واتساب، انستقرام) وأن يكون قد استلم ما لا يقل عن 100 رسالة حقيقية من العملاء.</span>
</li>
<li className="flex gap-3 text-sm">
<CheckCircle2 className="w-5 h-5 text-emerald-500 shrink-0" />
<span className="leading-relaxed">عدم إيقاف تشغيل الوكيل لأكثر من 48 ساعة متواصلة خلال فترة الشهر الأولى.</span>
</li>
<li className="flex gap-3 text-sm">
<CheckCircle2 className="w-5 h-5 text-emerald-500 shrink-0" />
<span className="leading-relaxed">فشل تقني مثبت (أخطاء جسيمة في الرد، تسريب عملاء، ردود هلوسة) ولم يقم فريق الدعم بحلها.</span>
</li>
</ul>
</div>
<div className="bg-destructive/10 border border-destructive/20 text-destructive rounded-xl p-4 flex items-start gap-3 mt-auto">
<AlertTriangle className="w-5 h-5 shrink-0 mt-0.5" />
<p className="text-sm font-medium leading-relaxed">
تحذير للمسوقين: لا تقدم ضماناً قطيعاً بدون عرض هذي الشروط الـ 4 للعميل. البيع التضليلي أو المبالغ فيه قد يوقف حسابك تلقائياً في Dealix.
</p>
</div>
</div>
{/* Claim workflow & Status */}
<div className="glass-card p-6 flex flex-col gap-6">
<div className="pb-4 border-b border-border/50">
<h2 className="text-xl font-bold flex flex-col">
مركز المطالبات
<span className="text-sm text-muted-foreground font-normal mt-1">Claims Management</span>
</h2>
</div>
<div className="flex-1 space-y-4">
<div className="p-4 rounded-xl bg-secondary/30 border border-border/50 flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-muted-foreground">حالة المراجعة (قيد الانتظار)</span>
<span className="text-xs bg-amber-500/20 text-amber-500 px-2 py-1 rounded font-bold">مراجعة 3 مطالبات</span>
</div>
<p className="text-sm font-medium">شركة الأفق الطبي (رفض دفع)</p>
<div className="flex gap-2 text-xs">
<button className="flex-1 bg-background border border-border py-2 rounded-lg hover:bg-emerald-500/10 hover:border-emerald-500/50 hover:text-emerald-500 transition-colors">مقبول</button>
<button className="flex-1 bg-background border border-border py-2 rounded-lg hover:bg-destructive/10 hover:border-destructive/50 hover:text-destructive transition-colors">مرفوض</button>
</div>
</div>
<div className="p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20 flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-muted-foreground">حالة السداد (مدفوعة ومسترجعة)</span>
<span className="text-xs bg-emerald-500/20 text-emerald-500 px-2 py-1 rounded font-bold">1 مطالبة</span>
</div>
<p className="text-sm font-medium">مصنع التمور العصرية</p>
<span className="text-xs text-emerald-500 font-bold">تم إرجاع 21,500 ر.س</span>
</div>
</div>
<button className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-lg shadow-primary/25 transition-all mt-auto">
<RotateCcw className="w-4 h-4" />
فتح تذكرة استرجاع (Refund Ticket)
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,88 @@
import { BookOpen, Map, Target, Award, Rocket, FileText, Smartphone, Megaphone } from "lucide-react";
export function OnboardingView() {
const steps = [
{ num: 1, title: "فهم المنتج (Dealix)", desc: "شركة سعودية للذكاء الاصطناعي موجهة لقطاع الأعمال؛ تصنع موظفين AI للمبيعات والدعم الفني." },
{ num: 2, title: "تحديد الفئة", desc: "شركات B2B/B2C اللي تعاني من نقص في الرد السريع، أو تسرب المبيعات." },
{ num: 3, title: "اختيار القطاع", desc: "اختر قطاع تفهمه جيداً (العقارات، العيادات، أو المتاجر) واستخدم الترسانة القطاعية." },
{ num: 4, title: "الاستهداف", desc: "ابحث في LinkedIn لمعرفة صناع القرار، أو خرائط جوجل (Google Maps) للأنشطة المحلية." },
{ num: 5, title: "التواصل الأولي", desc: "استخدم سكربت 'المكالمة الباردة' أو 'الواتساب البارد' المتوفر في قسم السكربتات." },
{ num: 6, title: "حجز الديمو", desc: "هدفك الوحيد هو إقناع العميل بتجربة ديمو مجاني للـ AI عن طريق الواتساب." },
{ num: 7, title: "الإغلاق", desc: "يقوم فريقنا وخبرائنا (أو أنت إذا كنت محترفاً) بإغلاق الصفقة وتوقيع العقود، لتستلم عمولتك." },
];
return (
<div className="p-8 max-w-7xl mx-auto space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="flex justify-between items-end">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">📖 دليل المسوق الشامل (Onboarding)</h1>
<p className="text-muted-foreground">خطوتك الأولى لفهم Dealix وكيف تبدأ بتحقيق المبيعات والعمولات من اليوم الأول.</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Core Steps */}
<div className="lg:col-span-2 glass-card p-6 flex flex-col gap-6">
<div className="flex items-center gap-4 pb-4 border-b border-border/50">
<div className="p-3 rounded-xl bg-primary/10 text-primary">
<Map className="w-6 h-6" />
</div>
<div>
<h2 className="text-xl font-bold">خارطة الطريق (7 خطوات نجاح)</h2>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{steps.map((step, i) => (
<div key={i} className="p-4 bg-secondary/30 rounded-xl border border-border/50 flex gap-4 hover:bg-secondary/50 transition-colors">
<div className="w-8 h-8 rounded-full bg-primary text-primary-foreground font-bold flex items-center justify-center shrink-0">
{step.num}
</div>
<div>
<h4 className="font-bold text-sm mb-1">{step.title}</h4>
<p className="text-xs text-muted-foreground leading-relaxed">{step.desc}</p>
</div>
</div>
))}
</div>
</div>
{/* Side Panel: Targets & Strategies */}
<div className="space-y-6">
<div className="glass-card p-6 flex flex-col gap-4">
<div className="flex items-center gap-3">
<Target className="w-5 h-5 text-emerald-500" />
<h3 className="font-bold">استراتيجيات البحث</h3>
</div>
<ul className="space-y-3">
<li className="flex items-center gap-3 text-sm p-3 bg-card border border-border rounded-lg shadow-sm">
<Smartphone className="w-4 h-4 text-blue-500" /> Google Maps للشركات والعيادات
</li>
<li className="flex items-center gap-3 text-sm p-3 bg-card border border-border rounded-lg shadow-sm">
<FileText className="w-4 h-4 text-blue-500" /> LinkedIn Sales Navigator
</li>
<li className="flex items-center gap-3 text-sm p-3 bg-card border border-border rounded-lg shadow-sm">
<Megaphone className="w-4 h-4 text-blue-500" /> إعلانات إنستغرام الممولة كمخابئ للعملاء
</li>
</ul>
</div>
<div className="glass-card p-6 flex flex-col gap-4 bg-gradient-to-br from-primary/10 to-transparent border-primary/20">
<div className="flex items-center gap-3">
<Award className="w-5 h-5 text-primary" />
<h3 className="font-bold">الترقية التلقائية (Qiwa)</h3>
</div>
<p className="text-sm text-foreground/80 leading-relaxed font-sans mt-2">
إذا حققت <span className="font-bold text-primary">10 إغلاقات بمبالغ أعلى من 5,000 ريال للشهر الواحد</span>،
يتم ترقيتك فوراً لمسار "المبيعات التنفيذية" بعقد رسمي وراتب ثابت + عمولة 5%.
</p>
<div className="mt-4 p-4 rounded-xl bg-background border border-border/50 text-center">
<Rocket className="w-8 h-8 text-primary mx-auto mb-2 opacity-80" />
<span className="text-sm font-bold block">متبقي لك 10 شركات للتأهل!</span>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,117 @@
import { FileBarChart, MonitorPlay, Activity, Stethoscope, Car, Home, ShoppingBag, BookOpen } from "lucide-react";
const SECTORS = [
{
icon: Stethoscope,
name: "العيادات الطبية",
color: "text-rose-500",
bg: "bg-rose-500/10",
pain: "ضياع حجوزات بسبب التأخر في الرد على الواتساب وعدم التذكير بالمواعيد.",
solution: "حجز تلقائي وتأكيد مواعيد، إجابة عن أسئلة القسم والعيادات 24/7.",
stats: "٣٠٪ معدل فشل حضور المرضى بسبب سوء المتابعة اليدوية.",
deckUrl: "#deck-clinics"
},
{
icon: Home,
name: "العقارات وإدارة الأملاك",
color: "text-blue-500",
bg: "bg-blue-500/10",
pain: "مئات الاستفسارات عن الأسعار والمواقع والفلترة تضيع وقت الوكلاء.",
solution: "وكيل عقاري ذكي يفلتر العملاء، يسأل عن الميزانية، ويرسل عروض.",
stats: "٧٠٪ من الاستفسارات العقارية غير جادة وتضيع وقت المبيعات.",
deckUrl: "#deck-realestate"
},
{
icon: Car,
name: "قطاع السيارات وصيانتها",
color: "text-slate-500",
bg: "bg-slate-500/10",
pain: "صعوبة في جدولة مواعيد الصيانة واستفسارات قطع الغيار المملة.",
solution: "حجز مواعيد الصيانة فورياً عبر الواتساب وتذكير العميل عند الانتهاء.",
stats: "السوق يحتاج ٥٠٪ سرعة أكبر في المبيعات بعد طلب تجربة القيادة.",
deckUrl: "#deck-auto"
},
{
icon: ShoppingBag,
name: "المتاجر الإلكترونية",
color: "text-purple-500",
bg: "bg-purple-500/10",
pain: "استفسارات تتبع الطلب متكررة والسلال المتروكة تكلف أموال.",
solution: "تتبع آلي، إرسال تذكيرات ذكية للسلال المتروكة، دعم ما بعد البيع.",
stats: "٦٨٪ معدل ترك السلال الشرائية حول العالم.",
deckUrl: "#deck-ecommerce"
},
{
icon: BookOpen,
name: "التعليم والتدريب",
color: "text-emerald-500",
bg: "bg-emerald-500/10",
pain: "استفسارات عن جداول الدورات والأسعار تأخذ وقت طويل من خدمة العملاء.",
solution: "مستشار تعليمي آلي يجيب على شروط التسجيل، ويسجل الطلاب.",
stats: "الطلاب يتوقعون ردود فورية للتسجيل وإلا يذهبون لمعاهد أخرى.",
deckUrl: "#deck-education"
},
{
icon: Activity,
name: "شركات التقنية والخدمات B2B",
color: "text-amber-500",
bg: "bg-amber-500/10",
pain: "دورة المبيعات طويلة جداً واجتماعات مع أشخاص غير مؤهلين.",
solution: "تأهيل صارم للعميل (BANT) قبل حجز أي الديمو.",
stats: "٥٠٪ من اجتماعات B2B تكون مع عملاء خارج نطاق الخدمة.",
deckUrl: "#deck-b2b"
}
];
export function PresentationsView() {
return (
<div className="p-8 max-w-7xl mx-auto space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="flex justify-between items-end">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">📊 الترسانة القطاعية (Sector Sales Arsenal)</h1>
<p className="text-muted-foreground">عروض تقديمية وملفات ROI مخصصة لكل قطاع تستخدمها للإغلاق السريع.</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{SECTORS.map((sector, idx) => (
<div key={idx} className="glass-card flex flex-col group overflow-hidden">
<div className={`p-6 border-b border-border/50 ${sector.bg} flex items-center gap-4`}>
<div className={`p-3 rounded-xl bg-background shadow-sm ${sector.color}`}>
<sector.icon className="w-8 h-8" />
</div>
<h2 className="text-xl font-bold">{sector.name}</h2>
</div>
<div className="p-6 flex-1 flex flex-col gap-4">
<div>
<h4 className="text-sm font-bold text-destructive mb-1">نقاط الألم (Pain Points):</h4>
<p className="text-sm text-foreground/80 leading-relaxed font-sans">{sector.pain}</p>
</div>
<div>
<h4 className="text-sm font-bold text-emerald-500 mb-1">كيف نحل المشكلة (Dealix Solution):</h4>
<p className="text-sm text-foreground/80 leading-relaxed font-sans">{sector.solution}</p>
</div>
<div className="bg-secondary/30 rounded-xl p-3 border border-border/50 mb-4 mt-auto">
<span className="text-xs font-bold text-muted-foreground">إحصائية للإغلاق: </span>
<span className="text-sm font-medium">{sector.stats}</span>
</div>
<button className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-lg shadow-primary/25 transition-all">
<FileBarChart className="w-4 h-4" />
تحميل العرض التقديمي (Deck)
</button>
<button className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-secondary text-secondary-foreground hover:bg-secondary/80 border border-border font-medium transition-all">
<MonitorPlay className="w-4 h-4" />
استخراج حاسبة العائد ROI
</button>
</div>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,127 @@
import { useState } from "react";
import { Copy, CheckCircle2, ChevronDown, MessageCircle, Phone, FileText } from "lucide-react";
const SCRIPTS = {
"cold-call": {
title: "مكالمة باردة (Cold Call)",
icon: Phone,
color: "text-blue-500",
bg: "bg-blue-500/10",
script: `مرحباً [اسم العميل]، معك [اسمك] من منصة Dealix للذكاء الاصطناعي.
أنا أتابع قطاع [قطاع العميل] وملاحظ إن التحدي الأكبر حالياً هو تسرب العملاء المحتملين وصعوبة الرد الفوري على كل الاستفسارات.
نحن في Dealix طورنا "موظف ذكاء اصطناعي" بلهجة سعودية يتحدث مع عملائك 24/7، يفلترهم، ويحجز المواعيد لك مباشرة.
هل عندك 3 دقائق الأسبوع القادم أوريك ديمو حي كيف ممكن نضاعف مبيعاتك؟`
},
"whatsapp-intro": {
title: "تواصل واتساب (WhatsApp Intro)",
icon: MessageCircle,
color: "text-emerald-500",
bg: "bg-emerald-500/10",
script: `أهلاً [اسم العميل] 👋
معك [اسمك] من شركة Dealix للذكاء الاصطناعي.
بصفتك مدير في [قطاع العميل]، أكيد تعرف إن سرعة الرد تصنع فارق كبير في المبيعات. 🚀
صممنا لك وكيل ذكاء اصطناعي بلهجتنا السعودية 🇸🇦 يرد، يقنع، ويحجز المواعيد 24/7.
متى يناسبك أرسل لك رابط لتجربة النظام فعلياً؟ (التجربة مجانية)`
},
"follow-up": {
title: "متابعة (Follow-up)",
icon: FileText,
color: "text-purple-500",
bg: "bg-purple-500/10",
script: `أهلاً [اسم العميل]، مساك الله بالخير.
أتمنى تكون بخير. حبيت أذكرك بخصوص وكيل المبيعات الذكي من Dealix.
أرفقت لك ملف سريع يوضح كيف قدرنا نرفع مبيعات شركات في نفس مجالكم بنسبة 40% خلال أول شهر.
هل تحب نحدد موعد سريع 10 دقائق نتناقش فيه؟`
},
"objections": {
title: "الرد على الاعتراضات (Objections)",
icon: FileText,
color: "text-amber-500",
bg: "bg-amber-500/10",
script: `الاعتراض: "السعر غالي"
الرد: "أتفهم وجهة نظرك [اسم العميل]. لكن لو حسبناها، الموظف البشري يكلف راتب، تأمين، ومكتب، وإجازات، ولا يقدر يشتغل 24/7. نظام Dealix يشتغل بدون توقف وبجزء بسيط من هذي التكلفة. والأهم، عندنا (الضمان الذهبي)، إذا ما حققنا لك نتائج خلال 30 يوم نرجع فلوسك كاملة."
الاعتراض: "الذكاء الاصطناعي يخوف/مو دقيق"
الرد: "صحيح البدايات كانت كذا، لكن وكلاء Dealix مدربين على منتجاتك فقط ولا يجاوبون من راسهم مطلقاً. والأهم إنهم مبرمجين يحولون المحادثة لموظف بشري فوراً إذا السؤال كان معقد."`
},
"closing": {
title: "إغلاق البيعة (Closing)",
icon: CheckCircle2,
color: "text-rose-500",
bg: "bg-rose-500/10",
script: `ممتاز جداً [اسم العميل].
بما إن النظام ناسبك، كل اللي نحتاجه منك أرقام التواصل وروابط منتجاتكم عشان ندرب الوكيل عليها، وخلال 48 ساعة بيكون جاهز يشتغل لصالحك.
أرسلت لك رابط الدفع مع عقد التشغيل اللي يضمن حقك الكامل بالاسترجاع خلال 30 يوم في حال ما شفت القيمة المضافة اللي وعدتك فيها.
مبروك مقدماً انضمامك لـ Dealix!`
}
};
export function ScriptsView() {
const [copied, setCopied] = useState<string | null>(null);
const copyToClipboard = (id: string, text: string) => {
navigator.clipboard.writeText(text);
setCopied(id);
setTimeout(() => setCopied(null), 2000);
};
return (
<div className="p-8 max-w-7xl mx-auto space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="flex justify-between items-end">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">📞 ترسانة السكربتات (Sales Scripts)</h1>
<p className="text-muted-foreground">نماذج ومسودات بيعية مثبتة الفعالية للمسوقين لضمان أعلى نسبة تحويل.</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Object.entries(SCRIPTS).map(([id, data]) => (
<div key={id} className="glass-card flex flex-col h-full overflow-hidden group">
<div className={`p-6 border-b border-border/50 ${data.bg} transition-colors group-hover:bg-opacity-20`}>
<div className="flex items-center gap-4">
<div className={`p-3 rounded-xl bg-background shadow-sm ${data.color}`}>
<data.icon className="w-6 h-6" />
</div>
<h3 className="text-lg font-bold">{data.title}</h3>
</div>
</div>
<div className="p-6 flex-1 flex flex-col bg-card/40">
<div className="bg-background rounded-xl p-4 text-sm leading-relaxed whitespace-pre-wrap flex-1 border border-border/50 font-sans text-foreground/90">
{data.script}
</div>
<button
onClick={() => copyToClipboard(id, data.script)}
className={`w-full mt-4 flex items-center justify-center gap-2 py-3 rounded-xl text-sm font-medium transition-all ${
copied === id
? "bg-emerald-500/10 text-emerald-500 border border-emerald-500/20"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80 border border-border/50"
}`}
>
{copied === id ? (
<>
<CheckCircle2 className="w-4 h-4" /> تم النسخ بنجاح
</>
) : (
<>
<Copy className="w-4 h-4" /> نسخ النص
</>
)}
</button>
</div>
</div>
))}
</div>
</div>
);
}