mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-22 09:11:13 +00:00
Merge branch 'claude/complete-system-prompts-wqJCm' into cursor/dealix-end-to-end-fix-fea4
Co-authored-by: VoXc2 <VoXc2@users.noreply.github.com>
This commit is contained in:
commit
1b2baf6bc8
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
salesflow-saas/frontend/tsconfig.tsbuildinfo
|
||||
@ -86,3 +86,15 @@ Before writing code, classify your task:
|
||||
- Always detect dialect before processing (saudi/gulf/msa)
|
||||
- Check for Arabizi and suggest Arabic conversion
|
||||
- Check code-switching (Arabic+English mixed) for readability
|
||||
|
||||
## claude-mem (Persistent Memory)
|
||||
|
||||
Installed and active. Automatically captures every session's work and injects context into new sessions.
|
||||
|
||||
- **Worker**: `npx claude-mem start` (port 37777)
|
||||
- **Web UI**: http://localhost:37777
|
||||
- **Search**: Use `/mem-search` in Claude Code
|
||||
- **Data**: `~/.claude-mem/claude-mem.db` (SQLite + Chroma vectors)
|
||||
- **Privacy**: Wrap sensitive content in `<private>...</private>` tags
|
||||
- **Token savings**: ~95% reduction via 3-layer progressive retrieval
|
||||
- **Auto-captures**: tool executions, session summaries, decisions, bugs, patterns
|
||||
|
||||
95
salesflow-saas/backend/app/api/v1/channels.py
Normal file
95
salesflow-saas/backend/app/api/v1/channels.py
Normal file
@ -0,0 +1,95 @@
|
||||
"""
|
||||
Channel API Endpoints — Dealix AI Revenue OS
|
||||
Unified API for all communication channels: inbound routing, outreach, campaigns, timelines.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class InboundRequest(BaseModel):
|
||||
channel: str
|
||||
sender: str
|
||||
message: str
|
||||
|
||||
|
||||
class OutreachRequest(BaseModel):
|
||||
channel: str
|
||||
lead: dict
|
||||
campaign_type: str = "cold_intro"
|
||||
language: str = "ar"
|
||||
|
||||
|
||||
class CampaignRequest(BaseModel):
|
||||
lead: dict
|
||||
channels: list[str]
|
||||
campaign_type: str = "cold_outreach"
|
||||
|
||||
|
||||
class ContentRequest(BaseModel):
|
||||
platform: str
|
||||
topic: str
|
||||
language: str = "ar"
|
||||
|
||||
|
||||
@router.post("/inbound")
|
||||
async def channel_inbound(req: InboundRequest, db: AsyncSession = Depends(get_db)):
|
||||
from app.services.channel_orchestrator import channel_orchestrator
|
||||
response = await channel_orchestrator.route_inbound(req.channel, req.sender, req.message, db)
|
||||
return {"channel": req.channel, "sender": req.sender, "response": response}
|
||||
|
||||
|
||||
@router.post("/outreach")
|
||||
async def channel_outreach(req: OutreachRequest, db: AsyncSession = Depends(get_db)):
|
||||
from app.services.channel_orchestrator import channel_orchestrator
|
||||
brain = channel_orchestrator._get_brain(req.channel)
|
||||
if not brain:
|
||||
raise HTTPException(status_code=400, detail=f"Channel '{req.channel}' not supported")
|
||||
|
||||
if req.channel == "email":
|
||||
draft = await brain.generate_outreach(req.lead, req.campaign_type, req.language)
|
||||
return {"channel": req.channel, "subject": draft.subject, "body": draft.body}
|
||||
elif req.channel == "linkedin":
|
||||
name = req.lead.get("name", "")
|
||||
title = req.lead.get("title", "")
|
||||
company = req.lead.get("company", "")
|
||||
draft = await brain.draft_connection_request(name, title, company, "sales", req.language)
|
||||
return {"channel": req.channel, "draft": draft, "status": "pending_review"}
|
||||
elif req.channel in ("instagram", "tiktok", "twitter", "snapchat"):
|
||||
content = await brain.generate_content(req.channel, req.lead.get("topic", "sales_tips"), req.language)
|
||||
return {"channel": req.channel, "content": content.content, "hashtags": content.hashtags}
|
||||
|
||||
return {"channel": req.channel, "status": "unsupported_for_outreach"}
|
||||
|
||||
|
||||
@router.post("/campaign")
|
||||
async def multi_channel_campaign(req: CampaignRequest, db: AsyncSession = Depends(get_db)):
|
||||
from app.services.channel_orchestrator import channel_orchestrator
|
||||
plan = await channel_orchestrator.generate_multi_channel_campaign(
|
||||
req.lead, req.channels, req.campaign_type, db
|
||||
)
|
||||
return {"campaign_type": plan.campaign_type, "channels": plan.channels, "steps": plan.steps}
|
||||
|
||||
|
||||
@router.get("/timeline/{contact_id}")
|
||||
async def contact_timeline(contact_id: str, db: AsyncSession = Depends(get_db)):
|
||||
from app.services.channel_orchestrator import channel_orchestrator
|
||||
events = await channel_orchestrator.get_contact_timeline(contact_id, db)
|
||||
return {"contact_id": contact_id, "events": [e.model_dump() for e in events]}
|
||||
|
||||
|
||||
@router.post("/content")
|
||||
async def generate_content(req: ContentRequest):
|
||||
from app.services.social_media_brain import social_media_brain
|
||||
draft = await social_media_brain.generate_content(req.platform, req.topic, req.language)
|
||||
return {"platform": draft.platform, "content": draft.content, "hashtags": draft.hashtags, "theme": draft.theme}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def channels_health():
|
||||
from app.services.channel_orchestrator import channel_orchestrator
|
||||
return {"channels": channel_orchestrator.get_channel_health()}
|
||||
@ -17,6 +17,7 @@ from app.api.v1 import pipeline as pipeline_router
|
||||
from app.api.v1 import agent_system as agent_system_router
|
||||
from app.api.v1 import autonomous_foundation as autonomous_foundation_router
|
||||
from app.api.v1 import hermes as hermes_router
|
||||
from app.api.v1 import strategic_deals as strategic_deals_router
|
||||
from app.api.v1 import marketing_hub as marketing_hub_router
|
||||
from app.api.v1 import strategy_summary as strategy_summary_router
|
||||
from app.api.v1 import value_proposition as value_proposition_router
|
||||
@ -90,3 +91,14 @@ api_router.include_router(autonomous_foundation_router.router)
|
||||
|
||||
# ── Hermes Fusion — Orchestration Layer ──────────────────────
|
||||
api_router.include_router(hermes_router.router)
|
||||
|
||||
# ── Strategic Deals — B2B Deal Discovery & Negotiation ───────
|
||||
api_router.include_router(strategic_deals_router.router)
|
||||
|
||||
# ── WhatsApp Webhook — Incoming messages & status ────────────
|
||||
from app.api.v1 import whatsapp_webhook as whatsapp_webhook_router
|
||||
api_router.include_router(whatsapp_webhook_router.router)
|
||||
|
||||
# ── Omnichannel — Unified channel management ─────────────────
|
||||
from app.api.v1 import channels as channels_router
|
||||
api_router.include_router(channels_router.router)
|
||||
|
||||
681
salesflow-saas/backend/app/api/v1/strategic_deals.py
Normal file
681
salesflow-saas/backend/app/api/v1/strategic_deals.py
Normal file
@ -0,0 +1,681 @@
|
||||
"""
|
||||
Strategic Deals API — B2B deal discovery, matching, negotiation, and outreach.
|
||||
واجهة الصفقات الاستراتيجية: اكتشاف وتوفيق وتفاوض وتواصل الشراكات
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel as Schema, Field
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.strategic_deal import (
|
||||
CompanyProfile, StrategicDeal, DealMatch,
|
||||
DealStatus, DealType, DealChannel, MatchStatus,
|
||||
)
|
||||
from app.services.strategic_deals.company_profiler import CompanyProfiler
|
||||
from app.services.strategic_deals.deal_matcher import DealMatcher
|
||||
from app.services.strategic_deals.deal_negotiator import DealNegotiator, NegotiationStrategy
|
||||
from app.services.strategic_deals.deal_agent import DealAgent
|
||||
|
||||
router = APIRouter(prefix="/strategic-deals", tags=["Strategic Deals"])
|
||||
|
||||
|
||||
# ── Pydantic Schemas ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ProfileCreate(Schema):
|
||||
company_name: str
|
||||
company_name_ar: Optional[str] = None
|
||||
industry: Optional[str] = None
|
||||
sub_industry: Optional[str] = None
|
||||
cr_number: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
region: Optional[str] = None
|
||||
employee_count: Optional[int] = None
|
||||
annual_revenue_sar: Optional[float] = None
|
||||
capabilities: list[str] = []
|
||||
needs: list[str] = []
|
||||
deal_preferences: dict = {}
|
||||
website: Optional[str] = None
|
||||
linkedin_url: Optional[str] = None
|
||||
whatsapp_number: Optional[str] = None
|
||||
|
||||
|
||||
class ProfileResponse(Schema):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
company_name: str
|
||||
company_name_ar: Optional[str] = None
|
||||
industry: Optional[str] = None
|
||||
sub_industry: Optional[str] = None
|
||||
cr_number: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
region: Optional[str] = None
|
||||
employee_count: Optional[float] = None
|
||||
annual_revenue_sar: Optional[float] = None
|
||||
capabilities: list = []
|
||||
needs: list = []
|
||||
deal_preferences: dict = {}
|
||||
website: Optional[str] = None
|
||||
linkedin_url: Optional[str] = None
|
||||
whatsapp_number: Optional[str] = None
|
||||
trust_score: float = 0.0
|
||||
is_verified: bool = False
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class NeedsAnalysisRequest(Schema):
|
||||
description: str = Field(..., description="وصف الاحتياجات بالعربي أو الإنجليزي")
|
||||
|
||||
|
||||
class DealCreate(Schema):
|
||||
initiator_profile_id: UUID
|
||||
target_profile_id: Optional[UUID] = None
|
||||
target_company_name: Optional[str] = None
|
||||
target_contact_phone: Optional[str] = None
|
||||
target_contact_email: Optional[str] = None
|
||||
deal_type: str = "partnership"
|
||||
deal_title: str
|
||||
deal_title_ar: Optional[str] = None
|
||||
our_offer: Optional[str] = None
|
||||
our_need: Optional[str] = None
|
||||
proposed_terms: dict = {}
|
||||
estimated_value_sar: Optional[float] = None
|
||||
channel: str = "whatsapp"
|
||||
|
||||
|
||||
class DealResponse(Schema):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
initiator_profile_id: UUID
|
||||
target_profile_id: Optional[UUID] = None
|
||||
target_company_name: Optional[str] = None
|
||||
target_contact_phone: Optional[str] = None
|
||||
target_contact_email: Optional[str] = None
|
||||
deal_type: str
|
||||
deal_title: str
|
||||
deal_title_ar: Optional[str] = None
|
||||
our_offer: Optional[str] = None
|
||||
our_need: Optional[str] = None
|
||||
proposed_terms: dict = {}
|
||||
agreed_terms: dict = {}
|
||||
estimated_value_sar: Optional[float] = None
|
||||
status: str
|
||||
channel: str
|
||||
ai_confidence: float = 0.0
|
||||
negotiation_history: list = []
|
||||
notes: Optional[str] = None
|
||||
notes_ar: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
closed_at: Optional[datetime] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class MatchResponse(Schema):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
company_a_id: UUID
|
||||
company_b_id: Optional[UUID] = None
|
||||
company_b_name: Optional[str] = None
|
||||
company_b_data: dict = {}
|
||||
match_score: float = 0.0
|
||||
match_reasons: list = []
|
||||
deal_type_suggested: Optional[str] = None
|
||||
terms_suggested: dict = {}
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class NegotiateRequest(Schema):
|
||||
their_terms: Optional[dict] = None
|
||||
message: Optional[str] = None
|
||||
strategy: Optional[NegotiationStrategy] = None
|
||||
|
||||
|
||||
class OutreachRequest(Schema):
|
||||
channel: str = "whatsapp"
|
||||
style: str = "as_company"
|
||||
|
||||
|
||||
class DiscoveryScanRequest(Schema):
|
||||
profile_id: UUID
|
||||
deal_type: Optional[str] = None
|
||||
|
||||
|
||||
class BarterScanRequest(Schema):
|
||||
profile_id: UUID
|
||||
|
||||
|
||||
# ── Profile Endpoints ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/profiles", response_model=ProfileResponse, status_code=201)
|
||||
async def create_profile(
|
||||
data: ProfileCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a company profile for B2B matching. | إنشاء ملف شركة للمطابقة"""
|
||||
profiler = CompanyProfiler()
|
||||
profile = await profiler.create_profile(
|
||||
company_data=data.model_dump(),
|
||||
tenant_id=current_user.tenant_id,
|
||||
db=db,
|
||||
)
|
||||
return ProfileResponse.model_validate(profile)
|
||||
|
||||
|
||||
@router.put("/profiles/{profile_id}/enrich", response_model=ProfileResponse)
|
||||
async def enrich_profile(
|
||||
profile_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""AI-enrich a company profile. | إثراء ملف الشركة بالذكاء الاصطناعي"""
|
||||
result = await db.execute(
|
||||
select(CompanyProfile).where(
|
||||
CompanyProfile.id == profile_id,
|
||||
CompanyProfile.tenant_id == current_user.tenant_id,
|
||||
)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="الملف غير موجود | Profile not found")
|
||||
|
||||
profiler = CompanyProfiler()
|
||||
profile = await profiler.enrich_profile(profile_id, db)
|
||||
return ProfileResponse.model_validate(profile)
|
||||
|
||||
|
||||
@router.post("/profiles/{profile_id}/analyze-needs")
|
||||
async def analyze_needs(
|
||||
profile_id: UUID,
|
||||
data: NeedsAnalysisRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Analyze what a company needs (Arabic input). | تحليل احتياجات الشركة"""
|
||||
result = await db.execute(
|
||||
select(CompanyProfile).where(
|
||||
CompanyProfile.id == profile_id,
|
||||
CompanyProfile.tenant_id == current_user.tenant_id,
|
||||
)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="الملف غير موجود | Profile not found")
|
||||
|
||||
profiler = CompanyProfiler()
|
||||
analysis = await profiler.analyze_needs(profile_id, data.description, db)
|
||||
return {"status": "ok", "analysis": analysis}
|
||||
|
||||
|
||||
# ── Match Endpoints ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/matches", response_model=list[MatchResponse])
|
||||
async def list_matches(
|
||||
profile_id: UUID = Query(None, description="Filter by company profile"),
|
||||
status: str = Query(None, description="Filter by match status"),
|
||||
min_score: float = Query(None, ge=0, le=1, description="Minimum match score"),
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get AI-suggested matches. | عرض المطابقات المقترحة بالذكاء الاصطناعي"""
|
||||
query = select(DealMatch).where(DealMatch.tenant_id == current_user.tenant_id)
|
||||
if profile_id:
|
||||
query = query.where(
|
||||
(DealMatch.company_a_id == profile_id) | (DealMatch.company_b_id == profile_id)
|
||||
)
|
||||
if status:
|
||||
query = query.where(DealMatch.status == status)
|
||||
if min_score is not None:
|
||||
query = query.where(DealMatch.match_score >= min_score)
|
||||
|
||||
query = query.order_by(DealMatch.match_score.desc()).offset((page - 1) * per_page).limit(per_page)
|
||||
result = await db.execute(query)
|
||||
return [MatchResponse.model_validate(m) for m in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/matches/{match_id}/approve", response_model=MatchResponse)
|
||||
async def approve_match(
|
||||
match_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Approve a match for outreach. | الموافقة على مطابقة للتواصل"""
|
||||
result = await db.execute(
|
||||
select(DealMatch).where(
|
||||
DealMatch.id == match_id,
|
||||
DealMatch.tenant_id == current_user.tenant_id,
|
||||
)
|
||||
)
|
||||
match = result.scalar_one_or_none()
|
||||
if not match:
|
||||
raise HTTPException(status_code=404, detail="المطابقة غير موجودة | Match not found")
|
||||
if match.status != MatchStatus.SUGGESTED.value:
|
||||
raise HTTPException(status_code=400, detail="المطابقة تمت الموافقة عليها مسبقاً | Match already processed")
|
||||
|
||||
match.status = MatchStatus.APPROVED.value
|
||||
await db.flush()
|
||||
await db.refresh(match)
|
||||
return MatchResponse.model_validate(match)
|
||||
|
||||
|
||||
# ── Discovery Scan ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/scan", response_model=list[MatchResponse])
|
||||
async def run_discovery_scan(
|
||||
data: DiscoveryScanRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Run a full AI discovery scan for partners. | تشغيل فحص اكتشاف شامل"""
|
||||
result = await db.execute(
|
||||
select(CompanyProfile).where(
|
||||
CompanyProfile.id == data.profile_id,
|
||||
CompanyProfile.tenant_id == current_user.tenant_id,
|
||||
)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="الملف غير موجود | Profile not found")
|
||||
|
||||
agent = DealAgent()
|
||||
matches = await agent.run_discovery_scan(data.profile_id, data.deal_type, db)
|
||||
return [MatchResponse.model_validate(m) for m in matches]
|
||||
|
||||
|
||||
# ── Deal CRUD ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("", response_model=DealResponse, status_code=201)
|
||||
async def create_deal(
|
||||
data: DealCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a strategic deal. | إنشاء صفقة استراتيجية"""
|
||||
# Verify initiator profile belongs to tenant
|
||||
init_result = await db.execute(
|
||||
select(CompanyProfile).where(
|
||||
CompanyProfile.id == data.initiator_profile_id,
|
||||
CompanyProfile.tenant_id == current_user.tenant_id,
|
||||
)
|
||||
)
|
||||
if not init_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="ملف المبادر غير موجود | Initiator profile not found")
|
||||
|
||||
deal = StrategicDeal(
|
||||
tenant_id=current_user.tenant_id,
|
||||
initiator_profile_id=data.initiator_profile_id,
|
||||
target_profile_id=data.target_profile_id,
|
||||
target_company_name=data.target_company_name,
|
||||
target_contact_phone=data.target_contact_phone,
|
||||
target_contact_email=data.target_contact_email,
|
||||
deal_type=data.deal_type,
|
||||
deal_title=data.deal_title,
|
||||
deal_title_ar=data.deal_title_ar,
|
||||
our_offer=data.our_offer,
|
||||
our_need=data.our_need,
|
||||
proposed_terms=data.proposed_terms,
|
||||
estimated_value_sar=Decimal(str(data.estimated_value_sar)) if data.estimated_value_sar else None,
|
||||
status=DealStatus.DISCOVERY.value,
|
||||
channel=data.channel,
|
||||
ai_confidence=0.0,
|
||||
negotiation_history=[],
|
||||
)
|
||||
db.add(deal)
|
||||
await db.flush()
|
||||
await db.refresh(deal)
|
||||
return DealResponse.model_validate(deal)
|
||||
|
||||
|
||||
@router.get("", response_model=list[DealResponse])
|
||||
async def list_deals(
|
||||
status: str = Query(None),
|
||||
deal_type: str = Query(None),
|
||||
profile_id: UUID = Query(None, description="Filter by initiator or target profile"),
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List strategic deals with filters. | عرض الصفقات الاستراتيجية"""
|
||||
query = select(StrategicDeal).where(StrategicDeal.tenant_id == current_user.tenant_id)
|
||||
if status:
|
||||
query = query.where(StrategicDeal.status == status)
|
||||
if deal_type:
|
||||
query = query.where(StrategicDeal.deal_type == deal_type)
|
||||
if profile_id:
|
||||
query = query.where(
|
||||
(StrategicDeal.initiator_profile_id == profile_id)
|
||||
| (StrategicDeal.target_profile_id == profile_id)
|
||||
)
|
||||
|
||||
query = query.order_by(StrategicDeal.created_at.desc()).offset((page - 1) * per_page).limit(per_page)
|
||||
result = await db.execute(query)
|
||||
return [DealResponse.model_validate(d) for d in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/{deal_id}", response_model=DealResponse)
|
||||
async def get_deal(
|
||||
deal_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get deal details with negotiation history. | تفاصيل الصفقة مع سجل التفاوض"""
|
||||
result = await db.execute(
|
||||
select(StrategicDeal).where(
|
||||
StrategicDeal.id == deal_id,
|
||||
StrategicDeal.tenant_id == current_user.tenant_id,
|
||||
)
|
||||
)
|
||||
deal = result.scalar_one_or_none()
|
||||
if not deal:
|
||||
raise HTTPException(status_code=404, detail="الصفقة غير موجودة | Deal not found")
|
||||
return DealResponse.model_validate(deal)
|
||||
|
||||
|
||||
# ── Negotiation ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.put("/{deal_id}/negotiate")
|
||||
async def negotiate_deal(
|
||||
deal_id: UUID,
|
||||
data: NegotiateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Submit terms, counter-offer, or free-text message. | تقديم شروط أو عرض مضاد"""
|
||||
result = await db.execute(
|
||||
select(StrategicDeal).where(
|
||||
StrategicDeal.id == deal_id,
|
||||
StrategicDeal.tenant_id == current_user.tenant_id,
|
||||
)
|
||||
)
|
||||
deal = result.scalar_one_or_none()
|
||||
if not deal:
|
||||
raise HTTPException(status_code=404, detail="الصفقة غير موجودة | Deal not found")
|
||||
|
||||
negotiator = DealNegotiator()
|
||||
|
||||
# Check if we should escalate
|
||||
should_escalate = await negotiator.should_escalate(deal_id, db)
|
||||
if should_escalate:
|
||||
return {
|
||||
"status": "escalation_required",
|
||||
"message_ar": "هذه الصفقة تحتاج تدخل بشري. يرجى التواصل مع مدير الحساب.",
|
||||
"message_en": "This deal requires human intervention. Please contact the account manager.",
|
||||
}
|
||||
|
||||
# Start new negotiation
|
||||
if data.strategy and not deal.negotiation_history:
|
||||
round_data = await negotiator.start_negotiation(deal_id, data.strategy, db)
|
||||
return {
|
||||
"status": "negotiation_started",
|
||||
"round": round_data.round_number,
|
||||
"action": round_data.action,
|
||||
"our_terms": round_data.our_terms,
|
||||
"message_ar": round_data.message_ar,
|
||||
"message_en": round_data.message_en,
|
||||
"confidence": round_data.confidence,
|
||||
}
|
||||
|
||||
# Handle counter-offer
|
||||
if data.their_terms:
|
||||
round_data = await negotiator.handle_counter_offer(deal_id, data.their_terms, db)
|
||||
return {
|
||||
"status": "counter_processed",
|
||||
"round": round_data.round_number,
|
||||
"action": round_data.action,
|
||||
"our_terms": round_data.our_terms,
|
||||
"message_ar": round_data.message_ar,
|
||||
"message_en": round_data.message_en,
|
||||
"within_range": round_data.within_range,
|
||||
"confidence": round_data.confidence,
|
||||
}
|
||||
|
||||
# Handle free-text message
|
||||
if data.message:
|
||||
response = await negotiator.generate_response(deal_id, data.message, db)
|
||||
return {
|
||||
"status": "response_generated",
|
||||
"response": response,
|
||||
}
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="يرجى تقديم شروط أو رسالة أو استراتيجية | Provide terms, message, or strategy",
|
||||
)
|
||||
|
||||
|
||||
# ── Outreach ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/{deal_id}/outreach")
|
||||
async def send_outreach(
|
||||
deal_id: UUID,
|
||||
data: OutreachRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Send outreach via channel. | إرسال تواصل عبر قناة"""
|
||||
# Find the deal and verify ownership
|
||||
deal_result = await db.execute(
|
||||
select(StrategicDeal).where(
|
||||
StrategicDeal.id == deal_id,
|
||||
StrategicDeal.tenant_id == current_user.tenant_id,
|
||||
)
|
||||
)
|
||||
deal = deal_result.scalar_one_or_none()
|
||||
if not deal:
|
||||
raise HTTPException(status_code=404, detail="الصفقة غير موجودة | Deal not found")
|
||||
|
||||
# Find or create a match for this deal to run outreach
|
||||
match_result = await db.execute(
|
||||
select(DealMatch).where(
|
||||
DealMatch.company_a_id == deal.initiator_profile_id,
|
||||
DealMatch.tenant_id == current_user.tenant_id,
|
||||
).order_by(DealMatch.match_score.desc()).limit(1)
|
||||
)
|
||||
match = match_result.scalar_one_or_none()
|
||||
|
||||
if not match:
|
||||
# Create a placeholder match for outreach
|
||||
match = DealMatch(
|
||||
tenant_id=current_user.tenant_id,
|
||||
company_a_id=deal.initiator_profile_id,
|
||||
company_b_id=deal.target_profile_id,
|
||||
company_b_name=deal.target_company_name,
|
||||
match_score=deal.ai_confidence or 0.5,
|
||||
match_reasons=["تواصل مباشر من المستخدم"],
|
||||
deal_type_suggested=deal.deal_type,
|
||||
status=MatchStatus.APPROVED.value,
|
||||
)
|
||||
db.add(match)
|
||||
await db.flush()
|
||||
|
||||
agent = DealAgent()
|
||||
result = await agent.run_outreach_campaign(match.id, data.channel, db)
|
||||
|
||||
return {
|
||||
"status": "sent" if result.success else "failed",
|
||||
"channel": result.channel,
|
||||
"message_sent": result.message_sent,
|
||||
"next_action_ar": result.next_action_ar,
|
||||
"error": result.error,
|
||||
}
|
||||
|
||||
|
||||
# ── Proposal & Term Sheet ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/{deal_id}/proposal")
|
||||
async def generate_proposal(
|
||||
deal_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Generate an Arabic business proposal. | إنشاء مقترح أعمال بالعربي"""
|
||||
result = await db.execute(
|
||||
select(StrategicDeal).where(
|
||||
StrategicDeal.id == deal_id,
|
||||
StrategicDeal.tenant_id == current_user.tenant_id,
|
||||
)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="الصفقة غير موجودة | Deal not found")
|
||||
|
||||
agent = DealAgent()
|
||||
proposal = await agent.generate_proposal(deal_id, db)
|
||||
return {"status": "ok", "proposal": proposal}
|
||||
|
||||
|
||||
@router.post("/{deal_id}/term-sheet")
|
||||
async def generate_term_sheet(
|
||||
deal_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Generate an Arabic term sheet. | إنشاء ورقة شروط بالعربي"""
|
||||
result = await db.execute(
|
||||
select(StrategicDeal).where(
|
||||
StrategicDeal.id == deal_id,
|
||||
StrategicDeal.tenant_id == current_user.tenant_id,
|
||||
)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="الصفقة غير موجودة | Deal not found")
|
||||
|
||||
negotiator = DealNegotiator()
|
||||
term_sheet = await negotiator.generate_term_sheet(deal_id, db)
|
||||
return {"status": "ok", "term_sheet": term_sheet}
|
||||
|
||||
|
||||
# ── Analytics ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/analytics/overview")
|
||||
async def deal_analytics(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Deal flow analytics: match rate, close rate, avg deal value. | تحليلات الصفقات"""
|
||||
tenant_id = current_user.tenant_id
|
||||
|
||||
# Total deals
|
||||
total_q = select(func.count()).select_from(StrategicDeal).where(
|
||||
StrategicDeal.tenant_id == tenant_id,
|
||||
)
|
||||
total_deals = (await db.execute(total_q)).scalar() or 0
|
||||
|
||||
# By status
|
||||
status_q = select(
|
||||
StrategicDeal.status, func.count()
|
||||
).where(
|
||||
StrategicDeal.tenant_id == tenant_id,
|
||||
).group_by(StrategicDeal.status)
|
||||
status_rows = (await db.execute(status_q)).all()
|
||||
by_status = {row[0]: row[1] for row in status_rows}
|
||||
|
||||
won = by_status.get(DealStatus.CLOSED_WON.value, 0)
|
||||
lost = by_status.get(DealStatus.CLOSED_LOST.value, 0)
|
||||
closed = won + lost
|
||||
close_rate = (won / closed * 100) if closed > 0 else 0.0
|
||||
|
||||
# Average deal value (closed won)
|
||||
avg_val_q = select(func.avg(StrategicDeal.estimated_value_sar)).where(
|
||||
StrategicDeal.tenant_id == tenant_id,
|
||||
StrategicDeal.status == DealStatus.CLOSED_WON.value,
|
||||
)
|
||||
avg_value = (await db.execute(avg_val_q)).scalar()
|
||||
avg_value_float = float(avg_value) if avg_value else 0.0
|
||||
|
||||
# Total matches and conversion
|
||||
total_matches_q = select(func.count()).select_from(DealMatch).where(
|
||||
DealMatch.tenant_id == tenant_id,
|
||||
)
|
||||
total_matches = (await db.execute(total_matches_q)).scalar() or 0
|
||||
|
||||
converted_q = select(func.count()).select_from(DealMatch).where(
|
||||
DealMatch.tenant_id == tenant_id,
|
||||
DealMatch.status == MatchStatus.CONVERTED.value,
|
||||
)
|
||||
converted_matches = (await db.execute(converted_q)).scalar() or 0
|
||||
match_rate = (converted_matches / total_matches * 100) if total_matches > 0 else 0.0
|
||||
|
||||
# By deal type
|
||||
type_q = select(
|
||||
StrategicDeal.deal_type, func.count()
|
||||
).where(
|
||||
StrategicDeal.tenant_id == tenant_id,
|
||||
).group_by(StrategicDeal.deal_type)
|
||||
type_rows = (await db.execute(type_q)).all()
|
||||
by_type = {row[0]: row[1] for row in type_rows}
|
||||
|
||||
return {
|
||||
"total_deals": total_deals,
|
||||
"by_status": by_status,
|
||||
"close_rate_percent": round(close_rate, 1),
|
||||
"avg_deal_value_sar": round(avg_value_float, 2),
|
||||
"total_matches": total_matches,
|
||||
"converted_matches": converted_matches,
|
||||
"match_conversion_rate_percent": round(match_rate, 1),
|
||||
"by_deal_type": by_type,
|
||||
"labels_ar": {
|
||||
"total_deals": "إجمالي الصفقات",
|
||||
"close_rate": "نسبة الإغلاق",
|
||||
"avg_value": "متوسط قيمة الصفقة",
|
||||
"match_rate": "نسبة تحول المطابقات",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Barter Scan ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/barter-scan")
|
||||
async def barter_scan(
|
||||
data: BarterScanRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Find multi-party barter opportunities. | اكتشاف فرص المقايضة المتعددة"""
|
||||
result = await db.execute(
|
||||
select(CompanyProfile).where(
|
||||
CompanyProfile.id == data.profile_id,
|
||||
CompanyProfile.tenant_id == current_user.tenant_id,
|
||||
)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="الملف غير موجود | Profile not found")
|
||||
|
||||
matcher = DealMatcher()
|
||||
chains = await matcher.find_barter_chains(data.profile_id, db)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"chains_found": len(chains),
|
||||
"chains": chains,
|
||||
"summary_ar": (
|
||||
f"تم العثور على {len(chains)} سلسلة مقايضة محتملة"
|
||||
if chains
|
||||
else "لم يتم العثور على فرص مقايضة. حاول إضافة المزيد من القدرات والاحتياجات في ملفك."
|
||||
),
|
||||
}
|
||||
135
salesflow-saas/backend/app/api/v1/whatsapp_webhook.py
Normal file
135
salesflow-saas/backend/app/api/v1/whatsapp_webhook.py
Normal file
@ -0,0 +1,135 @@
|
||||
"""
|
||||
WhatsApp Webhook — Dealix AI Revenue OS
|
||||
Handles incoming WhatsApp messages, verification, and delivery status.
|
||||
"""
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from app.database import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/webhooks/whatsapp", tags=["WhatsApp Webhook"])
|
||||
|
||||
|
||||
@router.post("/incoming")
|
||||
async def handle_incoming(request: Request, db=Depends(get_db)):
|
||||
"""Handle incoming WhatsApp messages from Meta Cloud API or Twilio."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||
|
||||
phone = ""
|
||||
message = ""
|
||||
|
||||
# Meta Cloud API format
|
||||
if "entry" in body:
|
||||
try:
|
||||
entry = body["entry"][0]
|
||||
changes = entry.get("changes", [{}])[0]
|
||||
value = changes.get("value", {})
|
||||
messages = value.get("messages", [])
|
||||
if messages:
|
||||
msg = messages[0]
|
||||
phone = msg.get("from", "")
|
||||
if msg.get("type") == "text":
|
||||
message = msg.get("text", {}).get("body", "")
|
||||
elif msg.get("type") == "interactive":
|
||||
interactive = msg.get("interactive", {})
|
||||
if "button_reply" in interactive:
|
||||
message = interactive["button_reply"].get("title", "")
|
||||
elif "list_reply" in interactive:
|
||||
message = interactive["list_reply"].get("title", "")
|
||||
else:
|
||||
message = f"[{msg.get('type', 'unknown')} message]"
|
||||
except (IndexError, KeyError) as e:
|
||||
logger.warning(f"Failed to parse Meta webhook: {e}")
|
||||
return {"status": "ok"}
|
||||
|
||||
# Twilio format
|
||||
elif "From" in body or "from" in body:
|
||||
phone = body.get("From", body.get("from", "")).replace("whatsapp:", "")
|
||||
message = body.get("Body", body.get("body", ""))
|
||||
|
||||
if not phone or not message:
|
||||
logger.debug("Webhook received but no actionable message")
|
||||
return {"status": "ok"}
|
||||
|
||||
# Process through WhatsApp Brain
|
||||
from app.services.whatsapp_brain import whatsapp_brain
|
||||
|
||||
try:
|
||||
response = await whatsapp_brain.handle_incoming(phone, message, db)
|
||||
except Exception as e:
|
||||
logger.error(f"WhatsApp brain error for {phone}: {e}")
|
||||
response = "عذراً، حدث خطأ. حاول مرة أخرى أو تواصل مع support@dealix.sa"
|
||||
|
||||
# Send response via WhatsApp API
|
||||
try:
|
||||
from app.integrations.whatsapp import send_whatsapp_message
|
||||
await send_whatsapp_message(phone, response)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send WhatsApp response to {phone}: {e}")
|
||||
|
||||
logger.info(f"[WhatsApp] {phone}: '{message[:50]}...' → response sent")
|
||||
return {"status": "ok", "phone": phone, "response_length": len(response)}
|
||||
|
||||
|
||||
@router.get("/verify")
|
||||
async def verify_webhook(request: Request):
|
||||
"""Meta webhook verification challenge."""
|
||||
params = request.query_params
|
||||
mode = params.get("hub.mode")
|
||||
token = params.get("hub.verify_token")
|
||||
challenge = params.get("hub.challenge")
|
||||
|
||||
import os
|
||||
verify_token = os.environ.get("WHATSAPP_VERIFY_TOKEN", "dealix-whatsapp-verify-2026")
|
||||
|
||||
if mode == "subscribe" and token == verify_token:
|
||||
logger.info("WhatsApp webhook verified successfully")
|
||||
return PlainTextResponse(content=challenge or "", status_code=200)
|
||||
|
||||
logger.warning(f"WhatsApp webhook verification failed: mode={mode}, token={token}")
|
||||
raise HTTPException(status_code=403, detail="Verification failed")
|
||||
|
||||
|
||||
@router.post("/status")
|
||||
async def delivery_status(request: Request):
|
||||
"""Handle delivery/read status updates from WhatsApp."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return {"status": "ok"}
|
||||
|
||||
# Meta format
|
||||
if "entry" in body:
|
||||
try:
|
||||
entry = body["entry"][0]
|
||||
changes = entry.get("changes", [{}])[0]
|
||||
value = changes.get("value", {})
|
||||
statuses = value.get("statuses", [])
|
||||
|
||||
for status in statuses:
|
||||
recipient = status.get("recipient_id", "")
|
||||
status_type = status.get("status", "") # sent, delivered, read, failed
|
||||
timestamp = status.get("timestamp", "")
|
||||
logger.debug(
|
||||
f"[WhatsApp Status] {recipient}: {status_type} at {timestamp}"
|
||||
)
|
||||
|
||||
# Update message status in database if needed
|
||||
if status_type == "failed":
|
||||
errors = status.get("errors", [])
|
||||
error_msg = errors[0].get("title", "Unknown") if errors else "Unknown"
|
||||
logger.error(
|
||||
f"[WhatsApp] Message to {recipient} FAILED: {error_msg}"
|
||||
)
|
||||
except (IndexError, KeyError) as e:
|
||||
logger.warning(f"Failed to parse status webhook: {e}")
|
||||
|
||||
return {"status": "ok"}
|
||||
@ -39,6 +39,10 @@ else:
|
||||
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
# Aliases for backward compatibility with workers
|
||||
SessionLocal = async_session
|
||||
async_session_factory = async_session
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
@ -25,6 +25,8 @@ from app.models.knowledge import KnowledgeArticle, SectorAsset
|
||||
from app.models.advanced import TrustScore, Prospect, Scorecard, AIRehearsal
|
||||
from app.models.consent import PDPLConsent, PDPLConsentAudit, DataRequest
|
||||
from app.models.sequence import Sequence, SequenceStep, SequenceEnrollment, SequenceEvent
|
||||
from app.models.strategic_deal import CompanyProfile, StrategicDeal, DealMatch
|
||||
from app.models.api_key import APIKey, AppSetting
|
||||
|
||||
__all__ = [
|
||||
"BaseModel", "TenantModel", "Tenant", "User", "Lead", "Customer",
|
||||
@ -39,4 +41,5 @@ __all__ = [
|
||||
"TrustScore", "Prospect", "Scorecard", "AIRehearsal",
|
||||
"PDPLConsent", "PDPLConsentAudit", "DataRequest",
|
||||
"Sequence", "SequenceStep", "SequenceEnrollment", "SequenceEvent",
|
||||
"CompanyProfile", "StrategicDeal", "DealMatch",
|
||||
]
|
||||
|
||||
44
salesflow-saas/backend/app/models/api_key.py
Normal file
44
salesflow-saas/backend/app/models/api_key.py
Normal file
@ -0,0 +1,44 @@
|
||||
"""
|
||||
API Key Model — Dealix AI Revenue OS
|
||||
Manages API keys for external integrations and developer access.
|
||||
Adapted from VoXc2/dealix repository.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import TenantModel
|
||||
|
||||
|
||||
class APIKey(TenantModel):
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
name_ar: Mapped[str | None] = mapped_column(String(100))
|
||||
key_hash: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
key_prefix: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
permissions: Mapped[str | None] = mapped_column(Text) # JSON: ["read_leads", "write_deals"]
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
request_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
rate_limit: Mapped[int] = mapped_column(Integer, default=1000) # per hour
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
|
||||
|
||||
|
||||
class AppSetting(TenantModel):
|
||||
__tablename__ = "app_settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||
value: Mapped[str | None] = mapped_column(Text)
|
||||
value_type: Mapped[str] = mapped_column(String(20), default="string") # string, int, bool, json
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
description_ar: Mapped[str | None] = mapped_column(Text)
|
||||
is_public: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow,
|
||||
)
|
||||
238
salesflow-saas/backend/app/models/strategic_deal.py
Normal file
238
salesflow-saas/backend/app/models/strategic_deal.py
Normal file
@ -0,0 +1,238 @@
|
||||
"""
|
||||
Strategic Deal Models — B2B deal discovery, matching, and negotiation.
|
||||
نماذج الصفقات الاستراتيجية: اكتشاف وتوفيق وتفاوض الشراكات بين الشركات
|
||||
"""
|
||||
|
||||
import enum
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, String, Text, DateTime, Boolean, Float, Numeric, ForeignKey, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.models.base import TenantModel
|
||||
from app.models.compat import UUID, JSONB, default_uuid
|
||||
|
||||
|
||||
# ── Enums ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class DealType(str, enum.Enum):
|
||||
PARTNERSHIP = "partnership"
|
||||
DISTRIBUTION = "distribution"
|
||||
FRANCHISE = "franchise"
|
||||
JOINT_VENTURE = "jv"
|
||||
REFERRAL = "referral"
|
||||
ACQUISITION = "acquisition"
|
||||
BARTER = "barter"
|
||||
|
||||
|
||||
class DealStatus(str, enum.Enum):
|
||||
DISCOVERY = "discovery"
|
||||
OUTREACH = "outreach"
|
||||
NEGOTIATING = "negotiating"
|
||||
TERM_SHEET = "term_sheet"
|
||||
DUE_DILIGENCE = "due_diligence"
|
||||
CLOSED_WON = "closed_won"
|
||||
CLOSED_LOST = "closed_lost"
|
||||
|
||||
|
||||
class DealChannel(str, enum.Enum):
|
||||
WHATSAPP = "whatsapp"
|
||||
LINKEDIN = "linkedin"
|
||||
EMAIL = "email"
|
||||
IN_PERSON = "in_person"
|
||||
|
||||
|
||||
class MatchStatus(str, enum.Enum):
|
||||
SUGGESTED = "suggested"
|
||||
APPROVED = "approved"
|
||||
OUTREACH_SENT = "outreach_sent"
|
||||
IN_PROGRESS = "in_progress"
|
||||
CONVERTED = "converted"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
# ── Company Profile ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CompanyProfile(TenantModel):
|
||||
"""
|
||||
Rich company profile for B2B matching.
|
||||
ملف الشركة الغني للمطابقة بين الشركات
|
||||
"""
|
||||
__tablename__ = "company_profiles"
|
||||
__table_args__ = (
|
||||
Index("ix_company_profiles_industry", "industry"),
|
||||
Index("ix_company_profiles_region", "region"),
|
||||
Index("ix_company_profiles_verified", "is_verified"),
|
||||
)
|
||||
|
||||
company_name = Column(String(255), nullable=False, index=True)
|
||||
company_name_ar = Column(String(255), nullable=True)
|
||||
|
||||
# Industry classification (ISIC codes)
|
||||
industry = Column(String(100), nullable=True)
|
||||
sub_industry = Column(String(100), nullable=True)
|
||||
|
||||
# Saudi Commercial Registration
|
||||
cr_number = Column(String(20), nullable=True, unique=True)
|
||||
|
||||
# Location
|
||||
city = Column(String(100), nullable=True)
|
||||
region = Column(String(100), nullable=True) # Saudi administrative regions
|
||||
|
||||
# Size indicators
|
||||
employee_count = Column(Numeric(10, 0), nullable=True)
|
||||
annual_revenue_sar = Column(Numeric(15, 2), nullable=True)
|
||||
|
||||
# AI-enriched capability/need vectors (JSONB arrays)
|
||||
capabilities = Column(JSONB, default=list) # What this company can offer
|
||||
needs = Column(JSONB, default=list) # What this company needs
|
||||
|
||||
# Deal preferences: partnership, acquisition, distribution, referral, barter weights
|
||||
deal_preferences = Column(JSONB, default=dict)
|
||||
|
||||
# Contact & web
|
||||
website = Column(String(500), nullable=True)
|
||||
linkedin_url = Column(String(500), nullable=True)
|
||||
whatsapp_number = Column(String(20), nullable=True)
|
||||
|
||||
# Trust & verification
|
||||
trust_score = Column(Float, default=0.0) # 0-1 from KYB verification
|
||||
is_verified = Column(Boolean, default=False)
|
||||
|
||||
# Relationships
|
||||
initiated_deals = relationship(
|
||||
"StrategicDeal",
|
||||
back_populates="initiator_profile",
|
||||
foreign_keys="StrategicDeal.initiator_profile_id",
|
||||
)
|
||||
targeted_deals = relationship(
|
||||
"StrategicDeal",
|
||||
back_populates="target_profile",
|
||||
foreign_keys="StrategicDeal.target_profile_id",
|
||||
)
|
||||
matches_as_a = relationship(
|
||||
"DealMatch",
|
||||
back_populates="company_a",
|
||||
foreign_keys="DealMatch.company_a_id",
|
||||
)
|
||||
matches_as_b = relationship(
|
||||
"DealMatch",
|
||||
back_populates="company_b",
|
||||
foreign_keys="DealMatch.company_b_id",
|
||||
)
|
||||
|
||||
|
||||
# ── Strategic Deal ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class StrategicDeal(TenantModel):
|
||||
"""
|
||||
A B2B deal between two companies.
|
||||
صفقة بين شركتين
|
||||
"""
|
||||
__tablename__ = "strategic_deals"
|
||||
__table_args__ = (
|
||||
Index("ix_strategic_deals_status", "status"),
|
||||
Index("ix_strategic_deals_type", "deal_type"),
|
||||
)
|
||||
|
||||
# Parties
|
||||
initiator_profile_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("company_profiles.id"), nullable=False, index=True,
|
||||
)
|
||||
target_profile_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("company_profiles.id"), nullable=True, index=True,
|
||||
)
|
||||
|
||||
# Target info (when profile doesn't exist yet)
|
||||
target_company_name = Column(String(255), nullable=True)
|
||||
target_contact_phone = Column(String(20), nullable=True)
|
||||
target_contact_email = Column(String(255), nullable=True)
|
||||
|
||||
# Deal classification
|
||||
deal_type = Column(String(30), default=DealType.PARTNERSHIP.value)
|
||||
deal_title = Column(String(500), nullable=False)
|
||||
deal_title_ar = Column(String(500), nullable=True)
|
||||
|
||||
# Value proposition
|
||||
our_offer = Column(Text, nullable=True) # What we're offering
|
||||
our_need = Column(Text, nullable=True) # What we need from them
|
||||
|
||||
# Terms
|
||||
proposed_terms = Column(JSONB, default=dict) # equity_split, revenue_share, territory, exclusivity
|
||||
agreed_terms = Column(JSONB, default=dict) # Final agreed terms
|
||||
|
||||
estimated_value_sar = Column(Numeric(15, 2), nullable=True)
|
||||
|
||||
# Status & channel
|
||||
status = Column(String(30), default=DealStatus.DISCOVERY.value)
|
||||
channel = Column(String(20), default=DealChannel.WHATSAPP.value)
|
||||
|
||||
# AI signals
|
||||
ai_confidence = Column(Float, default=0.0) # 0-1
|
||||
|
||||
# Negotiation audit trail
|
||||
negotiation_history = Column(JSONB, default=list) # list of round dicts
|
||||
|
||||
# Notes
|
||||
notes = Column(Text, nullable=True)
|
||||
notes_ar = Column(Text, nullable=True)
|
||||
|
||||
closed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
initiator_profile = relationship(
|
||||
"CompanyProfile", back_populates="initiated_deals",
|
||||
foreign_keys=[initiator_profile_id],
|
||||
)
|
||||
target_profile = relationship(
|
||||
"CompanyProfile", back_populates="targeted_deals",
|
||||
foreign_keys=[target_profile_id],
|
||||
)
|
||||
|
||||
|
||||
# ── Deal Match ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class DealMatch(TenantModel):
|
||||
"""
|
||||
AI-generated match between two companies.
|
||||
مطابقة بالذكاء الاصطناعي بين شركتين
|
||||
"""
|
||||
__tablename__ = "deal_matches"
|
||||
__table_args__ = (
|
||||
Index("ix_deal_matches_score", "match_score"),
|
||||
Index("ix_deal_matches_status", "status"),
|
||||
)
|
||||
|
||||
company_a_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("company_profiles.id"), nullable=False, index=True,
|
||||
)
|
||||
company_b_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("company_profiles.id"), nullable=True, index=True,
|
||||
)
|
||||
|
||||
# External company data (when company_b has no profile)
|
||||
company_b_name = Column(String(255), nullable=True)
|
||||
company_b_data = Column(JSONB, default=dict)
|
||||
|
||||
# Scoring
|
||||
match_score = Column(Float, default=0.0) # 0-1
|
||||
match_reasons = Column(JSONB, default=list) # Arabic explanations
|
||||
|
||||
# AI suggestions
|
||||
deal_type_suggested = Column(String(30), nullable=True)
|
||||
terms_suggested = Column(JSONB, default=dict)
|
||||
|
||||
# Status
|
||||
status = Column(String(30), default=MatchStatus.SUGGESTED.value)
|
||||
|
||||
# Relationships
|
||||
company_a = relationship(
|
||||
"CompanyProfile", back_populates="matches_as_a", foreign_keys=[company_a_id],
|
||||
)
|
||||
company_b = relationship(
|
||||
"CompanyProfile", back_populates="matches_as_b", foreign_keys=[company_b_id],
|
||||
)
|
||||
@ -29,6 +29,24 @@ from app.services.memory_engine import (
|
||||
create_memory_adapter,
|
||||
)
|
||||
from app.services.session_continuity import SessionContinuity, session_continuity
|
||||
from app.services.strategic_deals import (
|
||||
CompanyProfiler, DealMatcher, DealNegotiator, NegotiationStrategy, DealAgent,
|
||||
CompanyTwin, CompanyTwinBuilder,
|
||||
DealRoom, DealRoomService,
|
||||
DealTaxonomyService, DEAL_TAXONOMY,
|
||||
OperatingMode, ModeEnforcer, MODE_POLICIES,
|
||||
ChannelRules, ConsentLedger,
|
||||
)
|
||||
from app.services.hermes_orchestrator import hermes_orchestrator
|
||||
from app.services.execution_router import execution_router
|
||||
from app.services.shannon_security import shannon_security
|
||||
from app.services.observability import observability_service
|
||||
from app.services.self_improvement import self_improvement_engine
|
||||
from app.services.feature_flags import feature_flags
|
||||
from app.services.local_inference import local_inference
|
||||
from app.services.gstack_discipline import gstack
|
||||
from app.services.skill_governance import skill_governance
|
||||
from app.services.arabic_ops import arabic_ops
|
||||
|
||||
__all__ = [
|
||||
"AuthService",
|
||||
@ -65,4 +83,30 @@ __all__ = [
|
||||
"create_memory_adapter",
|
||||
"SessionContinuity",
|
||||
"session_continuity",
|
||||
"CompanyProfiler",
|
||||
"DealMatcher",
|
||||
"DealNegotiator",
|
||||
"NegotiationStrategy",
|
||||
"DealAgent",
|
||||
"CompanyTwin",
|
||||
"CompanyTwinBuilder",
|
||||
"DealRoom",
|
||||
"DealRoomService",
|
||||
"DealTaxonomyService",
|
||||
"DEAL_TAXONOMY",
|
||||
"OperatingMode",
|
||||
"ModeEnforcer",
|
||||
"MODE_POLICIES",
|
||||
"ChannelRules",
|
||||
"ConsentLedger",
|
||||
"hermes_orchestrator",
|
||||
"execution_router",
|
||||
"shannon_security",
|
||||
"observability_service",
|
||||
"self_improvement_engine",
|
||||
"feature_flags",
|
||||
"local_inference",
|
||||
"gstack",
|
||||
"skill_governance",
|
||||
"arabic_ops",
|
||||
]
|
||||
|
||||
167
salesflow-saas/backend/app/services/channel_orchestrator.py
Normal file
167
salesflow-saas/backend/app/services/channel_orchestrator.py
Normal file
@ -0,0 +1,167 @@
|
||||
"""
|
||||
Channel Orchestrator — Dealix AI Revenue OS
|
||||
Unified coordinator across all communication channels.
|
||||
Routes inbound messages, generates multi-channel campaigns, and provides unified timelines.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHANNEL_PRIORITY = ["whatsapp", "email", "instagram", "twitter", "linkedin", "tiktok"]
|
||||
|
||||
CHANNEL_REGISTRY = {
|
||||
"whatsapp": {"name_ar": "واتساب", "auto_send": True, "max_daily": 1000},
|
||||
"email": {"name_ar": "إيميل", "auto_send": True, "max_daily": 500},
|
||||
"instagram": {"name_ar": "إنستغرام", "auto_send": True, "max_daily": 200},
|
||||
"twitter": {"name_ar": "تويتر", "auto_send": True, "max_daily": 100},
|
||||
"linkedin": {"name_ar": "لينكدإن", "auto_send": False, "max_daily": 50},
|
||||
"tiktok": {"name_ar": "تيك توك", "auto_send": True, "max_daily": 100},
|
||||
"snapchat": {"name_ar": "سناب شات", "auto_send": True, "max_daily": 100},
|
||||
}
|
||||
|
||||
|
||||
class TimelineEvent(BaseModel):
|
||||
channel: str
|
||||
direction: str # inbound, outbound
|
||||
content_preview: str
|
||||
timestamp: datetime
|
||||
event_type: str = "message" # message, campaign, note
|
||||
|
||||
|
||||
class CampaignPlan(BaseModel):
|
||||
lead: dict
|
||||
channels: list[str]
|
||||
campaign_type: str
|
||||
steps: list[dict]
|
||||
created_at: datetime = None
|
||||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
if self.created_at is None:
|
||||
self.created_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class ChannelOrchestrator:
|
||||
"""Unified coordinator routing messages to the correct channel brain."""
|
||||
|
||||
def __init__(self):
|
||||
self._brains = {}
|
||||
|
||||
def _get_brain(self, channel: str):
|
||||
if channel not in self._brains:
|
||||
if channel == "whatsapp":
|
||||
from app.services.whatsapp_brain import whatsapp_brain
|
||||
self._brains[channel] = whatsapp_brain
|
||||
elif channel == "email":
|
||||
from app.services.email_brain import email_brain
|
||||
self._brains[channel] = email_brain
|
||||
elif channel == "linkedin":
|
||||
from app.services.linkedin_brain import linkedin_brain
|
||||
self._brains[channel] = linkedin_brain
|
||||
elif channel in ("instagram", "tiktok", "twitter", "snapchat"):
|
||||
from app.services.social_media_brain import social_media_brain
|
||||
self._brains[channel] = social_media_brain
|
||||
return self._brains.get(channel)
|
||||
|
||||
async def route_inbound(
|
||||
self, channel: str, sender: str, message: str, db: Any = None
|
||||
) -> str:
|
||||
brain = self._get_brain(channel)
|
||||
if not brain:
|
||||
logger.warning(f"[Orchestrator] no brain for channel={channel}")
|
||||
return "شكراً لتواصلك! سيتم تحويلك لفريق الدعم."
|
||||
|
||||
logger.info(f"[Orchestrator] routing {channel} from={sender}")
|
||||
|
||||
if channel == "whatsapp":
|
||||
return await brain.handle_incoming(sender, message, db)
|
||||
elif channel == "email":
|
||||
draft = await brain.handle_inbound(sender, message[:50], message, db)
|
||||
return draft.body
|
||||
elif channel in ("instagram", "tiktok", "twitter", "snapchat"):
|
||||
return await brain.handle_inbound_dm(channel, sender, message, db)
|
||||
elif channel == "linkedin":
|
||||
return "تم استلام رسالتك عبر لينكدإن. فريق المبيعات بيتواصل معك قريباً."
|
||||
|
||||
return "شكراً لتواصلك!"
|
||||
|
||||
async def generate_multi_channel_campaign(
|
||||
self, lead: dict, channels: list[str], campaign_type: str = "cold_outreach", db: Any = None
|
||||
) -> CampaignPlan:
|
||||
sorted_channels = sorted(channels, key=lambda c: CHANNEL_PRIORITY.index(c) if c in CHANNEL_PRIORITY else 99)
|
||||
steps = []
|
||||
day = 0
|
||||
|
||||
for i, channel in enumerate(sorted_channels):
|
||||
brain = self._get_brain(channel)
|
||||
if not brain:
|
||||
continue
|
||||
|
||||
if channel == "whatsapp":
|
||||
content = f"أهلاً {lead.get('name', '')}! أنا من Dealix — نظام المبيعات الذكي. تبي تعرف أكثر؟"
|
||||
steps.append({"day": day, "channel": channel, "action": "send_message", "content": content, "auto": True})
|
||||
elif channel == "email":
|
||||
draft = await brain.generate_outreach(lead, "cold_intro")
|
||||
steps.append({"day": day, "channel": channel, "action": "send_email", "subject": draft.subject, "content": draft.body, "auto": True})
|
||||
elif channel == "linkedin":
|
||||
name = lead.get("name", "")
|
||||
title = lead.get("title", "")
|
||||
company = lead.get("company", "")
|
||||
draft_text = await brain.draft_connection_request(name, title, company)
|
||||
steps.append({"day": day, "channel": channel, "action": "send_connection", "content": draft_text, "auto": False})
|
||||
elif channel in ("instagram", "tiktok", "twitter", "snapchat"):
|
||||
content = f"أهلاً! شكراً لمتابعتك. Dealix يساعد الشركات السعودية في المبيعات. تبي تعرف أكثر؟"
|
||||
steps.append({"day": day, "channel": channel, "action": "send_dm", "content": content, "auto": True})
|
||||
|
||||
day += 2 # 2-day gap between channels
|
||||
|
||||
plan = CampaignPlan(lead=lead, channels=sorted_channels, campaign_type=campaign_type, steps=steps)
|
||||
logger.info(f"[Orchestrator] campaign planned: {len(steps)} steps across {len(sorted_channels)} channels")
|
||||
return plan
|
||||
|
||||
async def get_contact_timeline(
|
||||
self, contact_id: str, db: Any = None
|
||||
) -> list[TimelineEvent]:
|
||||
events = []
|
||||
if not db:
|
||||
return events
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
from app.models.message import Message
|
||||
|
||||
result = await db.execute(
|
||||
select(Message).where(Message.contact_id == contact_id).order_by(Message.created_at.desc()).limit(100)
|
||||
)
|
||||
messages = result.scalars().all()
|
||||
for msg in messages:
|
||||
events.append(TimelineEvent(
|
||||
channel=msg.channel or "whatsapp",
|
||||
direction=msg.direction or "inbound",
|
||||
content_preview=msg.body[:120] if msg.body else "",
|
||||
timestamp=msg.created_at,
|
||||
event_type="message",
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning(f"[Orchestrator] timeline error for {contact_id}: {e}")
|
||||
|
||||
return sorted(events, key=lambda e: e.timestamp, reverse=True)
|
||||
|
||||
def get_channel_health(self) -> dict:
|
||||
health = {}
|
||||
for channel, config in CHANNEL_REGISTRY.items():
|
||||
brain = self._get_brain(channel)
|
||||
health[channel] = {
|
||||
"name_ar": config["name_ar"],
|
||||
"active": brain is not None,
|
||||
"auto_send": config["auto_send"],
|
||||
"max_daily": config["max_daily"],
|
||||
}
|
||||
return health
|
||||
|
||||
|
||||
# Global singleton
|
||||
channel_orchestrator = ChannelOrchestrator()
|
||||
183
salesflow-saas/backend/app/services/comparison_engine.py
Normal file
183
salesflow-saas/backend/app/services/comparison_engine.py
Normal file
@ -0,0 +1,183 @@
|
||||
"""
|
||||
Comparison Engine — Dealix AI Revenue OS
|
||||
Competitive comparison data for charts, WhatsApp responses, and sales tools.
|
||||
"""
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Score scale: 0-10 per dimension
|
||||
COMPETITORS = {
|
||||
"dealix": {
|
||||
"name": "Dealix", "name_ar": "ديلكس",
|
||||
"scores": {
|
||||
"arabic_support": 10, "whatsapp_native": 10, "ai_scoring": 9,
|
||||
"pdpl_compliance": 10, "pricing_value": 9, "ease_of_use": 9,
|
||||
"saudi_market_fit": 10, "deal_exchange": 10, "strategic_deals": 10,
|
||||
"multi_channel": 9, "reporting": 8, "integrations": 7,
|
||||
},
|
||||
},
|
||||
"zoho": {
|
||||
"name": "Zoho CRM", "name_ar": "زوهو",
|
||||
"scores": {
|
||||
"arabic_support": 7, "whatsapp_native": 6, "ai_scoring": 6,
|
||||
"pdpl_compliance": 5, "pricing_value": 8, "ease_of_use": 7,
|
||||
"saudi_market_fit": 6, "deal_exchange": 2, "strategic_deals": 1,
|
||||
"multi_channel": 7, "reporting": 8, "integrations": 9,
|
||||
},
|
||||
},
|
||||
"salesforce": {
|
||||
"name": "Salesforce", "name_ar": "سيلزفورس",
|
||||
"scores": {
|
||||
"arabic_support": 3, "whatsapp_native": 2, "ai_scoring": 8,
|
||||
"pdpl_compliance": 4, "pricing_value": 3, "ease_of_use": 4,
|
||||
"saudi_market_fit": 4, "deal_exchange": 1, "strategic_deals": 2,
|
||||
"multi_channel": 7, "reporting": 10, "integrations": 10,
|
||||
},
|
||||
},
|
||||
"hubspot": {
|
||||
"name": "HubSpot", "name_ar": "هب سبوت",
|
||||
"scores": {
|
||||
"arabic_support": 2, "whatsapp_native": 3, "ai_scoring": 7,
|
||||
"pdpl_compliance": 3, "pricing_value": 5, "ease_of_use": 8,
|
||||
"saudi_market_fit": 3, "deal_exchange": 1, "strategic_deals": 1,
|
||||
"multi_channel": 8, "reporting": 8, "integrations": 9,
|
||||
},
|
||||
},
|
||||
"pipedrive": {
|
||||
"name": "Pipedrive", "name_ar": "بايب درايف",
|
||||
"scores": {
|
||||
"arabic_support": 2, "whatsapp_native": 1, "ai_scoring": 5,
|
||||
"pdpl_compliance": 2, "pricing_value": 7, "ease_of_use": 9,
|
||||
"saudi_market_fit": 2, "deal_exchange": 0, "strategic_deals": 0,
|
||||
"multi_channel": 4, "reporting": 6, "integrations": 6,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
DIMENSION_LABELS = {
|
||||
"arabic_support": {"ar": "دعم العربي", "en": "Arabic Support"},
|
||||
"whatsapp_native": {"ar": "واتساب مدمج", "en": "WhatsApp Native"},
|
||||
"ai_scoring": {"ar": "ذكاء اصطناعي", "en": "AI Scoring"},
|
||||
"pdpl_compliance": {"ar": "حماية البيانات", "en": "PDPL Compliance"},
|
||||
"pricing_value": {"ar": "القيمة مقابل السعر", "en": "Pricing Value"},
|
||||
"ease_of_use": {"ar": "سهولة الاستخدام", "en": "Ease of Use"},
|
||||
"saudi_market_fit": {"ar": "مناسب للسعودية", "en": "Saudi Market Fit"},
|
||||
"deal_exchange": {"ar": "تبادل صفقات", "en": "Deal Exchange"},
|
||||
"strategic_deals": {"ar": "صفقات استراتيجية", "en": "Strategic Deals"},
|
||||
"multi_channel": {"ar": "تعدد القنوات", "en": "Multi-Channel"},
|
||||
"reporting": {"ar": "التقارير", "en": "Reporting"},
|
||||
"integrations": {"ar": "التكاملات", "en": "Integrations"},
|
||||
}
|
||||
|
||||
|
||||
class ComparisonEngine:
|
||||
"""Generate comparison data for charts and sales responses."""
|
||||
|
||||
@staticmethod
|
||||
def get_chart_data(language: str = "ar") -> dict[str, Any]:
|
||||
"""Data formatted for radar/bar charts on frontend."""
|
||||
labels = [
|
||||
DIMENSION_LABELS[dim][language]
|
||||
for dim in DIMENSION_LABELS
|
||||
]
|
||||
datasets = []
|
||||
for key, comp in COMPETITORS.items():
|
||||
datasets.append({
|
||||
"label": comp[f"name_{language}" if f"name_{language}" in comp else "name"],
|
||||
"data": list(comp["scores"].values()),
|
||||
"highlight": key == "dealix",
|
||||
})
|
||||
return {"labels": labels, "datasets": datasets, "dimensions": list(DIMENSION_LABELS.keys())}
|
||||
|
||||
@staticmethod
|
||||
def get_feature_matrix(language: str = "ar") -> dict[str, Any]:
|
||||
"""Feature comparison table data."""
|
||||
features = [
|
||||
{"key": "arabic_first", "ar": "عربي أولاً (مو ترجمة)", "en": "Arabic-First (not translation)",
|
||||
"dealix": True, "zoho": False, "salesforce": False, "hubspot": False, "pipedrive": False},
|
||||
{"key": "whatsapp_built_in", "ar": "واتساب مدمج بالنظام", "en": "Built-in WhatsApp",
|
||||
"dealix": True, "zoho": False, "salesforce": False, "hubspot": False, "pipedrive": False},
|
||||
{"key": "ai_arabic", "ar": "AI يفهم العربي والسعودي", "en": "Arabic-Aware AI",
|
||||
"dealix": True, "zoho": False, "salesforce": False, "hubspot": False, "pipedrive": False},
|
||||
{"key": "pdpl_native", "ar": "PDPL مدمج", "en": "Built-in PDPL",
|
||||
"dealix": True, "zoho": False, "salesforce": False, "hubspot": False, "pipedrive": False},
|
||||
{"key": "deal_exchange", "ar": "صفقات استراتيجية وتبادل", "en": "Strategic Deal Exchange",
|
||||
"dealix": True, "zoho": False, "salesforce": False, "hubspot": False, "pipedrive": False},
|
||||
{"key": "lead_scoring", "ar": "تقييم عملاء ذكي", "en": "AI Lead Scoring",
|
||||
"dealix": True, "zoho": True, "salesforce": True, "hubspot": True, "pipedrive": True},
|
||||
{"key": "pipeline", "ar": "مسار صفقات بصري", "en": "Visual Pipeline",
|
||||
"dealix": True, "zoho": True, "salesforce": True, "hubspot": True, "pipedrive": True},
|
||||
{"key": "cpq", "ar": "عروض أسعار", "en": "Quotes (CPQ)",
|
||||
"dealix": True, "zoho": True, "salesforce": True, "hubspot": False, "pipedrive": False},
|
||||
]
|
||||
return {"features": features, "competitors": list(COMPETITORS.keys())}
|
||||
|
||||
@staticmethod
|
||||
def get_total_scores() -> dict[str, int]:
|
||||
"""Total score per competitor (out of 120)."""
|
||||
return {
|
||||
key: sum(comp["scores"].values())
|
||||
for key, comp in COMPETITORS.items()
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_why_dealix_wins(language: str = "ar") -> list[str]:
|
||||
"""Top reasons Dealix wins."""
|
||||
reasons = {
|
||||
"ar": [
|
||||
"الوحيد المصمم من الأساس للسوق السعودي",
|
||||
"واتساب مدمج — مو إضافة من طرف ثالث",
|
||||
"ذكاء اصطناعي يفهم اللهجة السعودية",
|
||||
"حماية بيانات PDPL مدمجة بالنظام",
|
||||
"نظام صفقات استراتيجية — لا يوجد عند أي منافس",
|
||||
"سعر يبدأ من ٥٩ ر.س — أرخص ١٠ مرات من Salesforce",
|
||||
"ثنائي اللغة (عربي/إنجليزي) بتبديل فوري",
|
||||
],
|
||||
"en": [
|
||||
"Only CRM built from scratch for the Saudi market",
|
||||
"Built-in WhatsApp — not a third-party add-on",
|
||||
"AI that understands Saudi Arabic dialect",
|
||||
"PDPL data protection built into the core",
|
||||
"Strategic Deal Exchange — no competitor has this",
|
||||
"Starting at 59 SAR — 10x cheaper than Salesforce",
|
||||
"Bilingual (Arabic/English) with instant switching",
|
||||
],
|
||||
}
|
||||
return reasons.get(language, reasons["ar"])
|
||||
|
||||
@staticmethod
|
||||
def get_comparison_summary(competitor: str, language: str = "ar") -> str:
|
||||
"""Summary comparing Dealix vs a specific competitor."""
|
||||
comp = COMPETITORS.get(competitor.lower())
|
||||
dealix = COMPETITORS["dealix"]
|
||||
if not comp:
|
||||
return "المنافس غير موجود" if language == "ar" else "Competitor not found"
|
||||
|
||||
dealix_total = sum(dealix["scores"].values())
|
||||
comp_total = sum(comp["scores"].values())
|
||||
diff = dealix_total - comp_total
|
||||
|
||||
if language == "ar":
|
||||
return (
|
||||
f"مقارنة Dealix مع {comp['name_ar']}:\n\n"
|
||||
f"النتيجة الإجمالية:\n"
|
||||
f"• Dealix: {dealix_total}/120\n"
|
||||
f"• {comp['name_ar']}: {comp_total}/120\n\n"
|
||||
f"Dealix يتفوق بـ {diff} نقطة.\n\n"
|
||||
f"أهم نقاط التفوق:\n"
|
||||
+ "\n".join(
|
||||
f"• {DIMENSION_LABELS[dim]['ar']}: Dealix {dealix['scores'][dim]} vs {comp['scores'][dim]}"
|
||||
for dim in dealix["scores"]
|
||||
if dealix["scores"][dim] > comp["scores"].get(dim, 0) + 2
|
||||
)
|
||||
)
|
||||
return (
|
||||
f"Dealix vs {comp['name']}:\n\n"
|
||||
f"Total Score:\n• Dealix: {dealix_total}/120\n• {comp['name']}: {comp_total}/120\n\n"
|
||||
f"Dealix leads by {diff} points."
|
||||
)
|
||||
|
||||
|
||||
comparison_engine = ComparisonEngine()
|
||||
194
salesflow-saas/backend/app/services/email_brain.py
Normal file
194
salesflow-saas/backend/app/services/email_brain.py
Normal file
@ -0,0 +1,194 @@
|
||||
"""
|
||||
Email AI Brain — Dealix AI Revenue OS
|
||||
Handles inbound email classification, outreach generation, and nurture sequences.
|
||||
Arabic-first with full bilingual support.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailIntent(str, Enum):
|
||||
INQUIRY = "inquiry"
|
||||
SUPPORT = "support"
|
||||
COMPLAINT = "complaint"
|
||||
PARTNERSHIP = "partnership"
|
||||
UNSUBSCRIBE = "unsubscribe"
|
||||
REPLY = "reply"
|
||||
SPAM = "spam"
|
||||
GENERAL = "general"
|
||||
|
||||
|
||||
class EmailDraft(BaseModel):
|
||||
subject: str
|
||||
body: str
|
||||
language: str = "ar"
|
||||
campaign_type: str = ""
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
INTENT_SIGNALS = {
|
||||
"inquiry": ["أبي أعرف", "استفسار", "سعر", "باقة", "pricing", "interested", "demo"],
|
||||
"support": ["مشكلة", "مساعدة", "خطأ", "bug", "help", "not working", "error"],
|
||||
"complaint": ["شكوى", "زعلان", "سيء", "complaint", "terrible", "disappointed"],
|
||||
"partnership": ["شراكة", "تعاون", "partner", "collaboration", "reseller"],
|
||||
"unsubscribe": ["إلغاء", "unsubscribe", "أوقف", "remove", "stop"],
|
||||
}
|
||||
|
||||
ARABIC_TEMPLATES = {
|
||||
"cold_intro": EmailDraft(
|
||||
subject="Dealix — نظام المبيعات الذكي للسوق السعودي",
|
||||
body=(
|
||||
"السلام عليكم {name}،\n\nأنا {sender_name} من فريق Dealix.\n\n"
|
||||
"لاحظنا أن {company} تعمل في قطاع {sector} — وهو بالضبط القطاع اللي نخدمه.\n\n"
|
||||
"Dealix نظام مبيعات ذكي مصمم للسعودية: واتساب مدمج، ذكاء اصطناعي يفهم عربي، "
|
||||
"وحماية بيانات PDPL.\n\nتبي نعطيك عرض سريع ١٥ دقيقة؟\n\nمع التحية،\n{sender_name}\nفريق Dealix"
|
||||
),
|
||||
),
|
||||
"follow_up_1": EmailDraft(
|
||||
subject="متابعة — هل شفت رسالتنا الأولى؟",
|
||||
body=(
|
||||
"أهلاً {name}،\n\nأرسلت لك قبل كم يوم عن Dealix. حبيت أتابع معك.\n\n"
|
||||
"عملاؤنا في {sector} حققوا:\n• زيادة ٤٠٪ في معدل الإغلاق\n"
|
||||
"• توفير ١٠ ساعات أسبوعياً\n• تحسين متابعة العملاء ١٠٠٪\n\n"
|
||||
"تقدر تجرب مجاناً ١٤ يوم بدون بطاقة.\n\nمع التحية،\n{sender_name}"
|
||||
),
|
||||
),
|
||||
"follow_up_2": EmailDraft(
|
||||
subject="آخر متابعة — فرصة مجانية لتجربة Dealix",
|
||||
body=(
|
||||
"أهلاً {name}،\n\nأعرف إنك مشغول. بس حبيت أذكرك إن التجربة المجانية متاحة.\n\n"
|
||||
"رابط التسجيل: dealix.sa/trial\nيأخذ أقل من دقيقة.\n\n"
|
||||
"لو ما يناسبك الوقت الحين، رد بـ 'لاحقاً' وبأتواصل معك الشهر الجاي.\n\nمع التحية،\n{sender_name}"
|
||||
),
|
||||
),
|
||||
"demo_invite": EmailDraft(
|
||||
subject="موعد العرض التوضيحي لـ Dealix",
|
||||
body=(
|
||||
"أهلاً {name}،\n\nشكراً لاهتمامك بـ Dealix!\n\n"
|
||||
"حجزنا لك عرض توضيحي:\n📅 {demo_date}\n⏰ {demo_time}\n🔗 {demo_link}\n\n"
|
||||
"العرض يستغرق ١٥ دقيقة ويغطي:\n• إدارة العملاء عبر الواتساب\n"
|
||||
"• تقييم العملاء بالذكاء الاصطناعي\n• عروض الأسعار التلقائية\n\nنتطلع لمقابلتك!\n{sender_name}"
|
||||
),
|
||||
),
|
||||
"proposal": EmailDraft(
|
||||
subject="عرض Dealix المخصص لـ {company}",
|
||||
body=(
|
||||
"أستاذ/ة {name}،\n\nبناءً على محادثتنا، حضّرنا لكم عرض مخصص:\n\n"
|
||||
"الباقة: {plan_name}\nالسعر: {price} ر.س/شهر\nعدد المستخدمين: {users}\n\n"
|
||||
"المميزات المشمولة:\n{features}\n\nالعرض صالح لمدة ٧ أيام.\n"
|
||||
"للموافقة: {approval_link}\n\nمع التحية،\n{sender_name}"
|
||||
),
|
||||
),
|
||||
"welcome": EmailDraft(
|
||||
subject="مرحباً بك في Dealix!",
|
||||
body=(
|
||||
"أهلاً {name}،\n\nمبروك! حسابك جاهز على Dealix.\n\n"
|
||||
"خطواتك الأولى:\n١. ادخل: dealix.sa/dashboard\n٢. أضف أول عميل\n"
|
||||
"٣. ربط الواتساب\n٤. أرسل أول رسالة ذكية\n\n"
|
||||
"لو تحتاج مساعدة، كلمنا واتساب أو إيميل support@dealix.sa.\n\nيلا نبدأ!\nفريق Dealix"
|
||||
),
|
||||
),
|
||||
"commission_report": EmailDraft(
|
||||
subject="تقرير عمولاتك الأسبوعي — {period}",
|
||||
body=(
|
||||
"أهلاً {name}،\n\nهذا تقرير عمولاتك لهذا الأسبوع:\n\n"
|
||||
"إجمالي العمولة: {total_commission} ر.س\nعملاء جدد: {new_clients}\n"
|
||||
"مستواك: {tier}\nترتيبك: #{rank}\n\n"
|
||||
"تفاصيل كاملة: dealix.sa/dashboard/commissions\n\nاستمر!\nفريق Dealix"
|
||||
),
|
||||
),
|
||||
"partnership_intro": EmailDraft(
|
||||
subject="فرصة شراكة مع Dealix — {partnership_type}",
|
||||
body=(
|
||||
"السلام عليكم {name}،\n\nنحن في Dealix نبحث عن شركاء استراتيجيين في {sector}.\n\n"
|
||||
"نقدم:\n• عمولات تنافسية تبدأ من ١٥٪\n• دعم تقني ومبيعاتي كامل\n"
|
||||
"• لوحة تحكم شريك مخصصة\n• مواد تسويقية جاهزة\n\n"
|
||||
"هل عندك وقت لمكالمة ١٥ دقيقة هذا الأسبوع؟\n\nمع التحية،\n{sender_name}\nمدير الشراكات — Dealix"
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class EmailBrain:
|
||||
"""Central brain for Dealix email — classifies inbound and generates outreach."""
|
||||
|
||||
def __init__(self):
|
||||
from app.services.whatsapp_knowledge import DealixKnowledge
|
||||
self.knowledge = DealixKnowledge
|
||||
|
||||
def _detect_intent(self, subject: str, body: str) -> EmailIntent:
|
||||
combined = f"{subject} {body}".lower()
|
||||
for intent, keywords in INTENT_SIGNALS.items():
|
||||
if any(kw in combined for kw in keywords):
|
||||
return EmailIntent(intent)
|
||||
return EmailIntent.GENERAL
|
||||
|
||||
async def handle_inbound(
|
||||
self, email_from: str, subject: str, body: str, db: Any = None
|
||||
) -> EmailDraft:
|
||||
intent = self._detect_intent(subject, body)
|
||||
logger.info(f"[EmailBrain] inbound from={email_from} intent={intent.value}")
|
||||
|
||||
if intent == EmailIntent.UNSUBSCRIBE:
|
||||
return EmailDraft(
|
||||
subject="تأكيد إلغاء الاشتراك",
|
||||
body="أهلاً،\n\nتم إلغاء اشتراكك في رسائل Dealix البريدية.\nلو غيّرت رأيك، تقدر تشترك مرة ثانية من dealix.sa.\n\nمع التحية،\nفريق Dealix",
|
||||
)
|
||||
if intent == EmailIntent.COMPLAINT:
|
||||
ticket = f"TKT-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M')}"
|
||||
return EmailDraft(
|
||||
subject="استلمنا شكواك — سنتابع فوراً",
|
||||
body=f"أهلاً،\n\nشكراً لتواصلك. نعتذر عن أي إزعاج.\nفريقنا سيتابع شكواك خلال ٤ ساعات عمل.\nرقم التذكرة: {ticket}\n\nمع التحية،\nفريق دعم Dealix",
|
||||
)
|
||||
if intent == EmailIntent.INQUIRY:
|
||||
pricing = self.knowledge.get_pricing_text("ar")
|
||||
return EmailDraft(
|
||||
subject="مرحباً — هذي تفاصيل Dealix",
|
||||
body=f"أهلاً،\n\nشكراً لاهتمامك بـ Dealix!\n\nالباقات المتاحة:\n{pricing}\n\nكل الباقات فيها تجربة مجانية ١٤ يوم.\nتبي نحجز لك عرض توضيحي؟\n\nمع التحية،\nفريق Dealix",
|
||||
)
|
||||
if intent == EmailIntent.PARTNERSHIP:
|
||||
return EmailDraft(
|
||||
subject="شكراً لاهتمامك بالشراكة مع Dealix",
|
||||
body="أهلاً،\n\nشكراً لتواصلك بخصوص الشراكة.\nفريق الشراكات سيتواصل معك خلال ٢٤ ساعة لمناقشة الفرص.\n\nمع التحية،\nفريق Dealix",
|
||||
)
|
||||
if intent == EmailIntent.SUPPORT:
|
||||
return EmailDraft(
|
||||
subject="استلمنا طلب الدعم — سنرد قريباً",
|
||||
body="أهلاً،\n\nشكراً لتواصلك. فريق الدعم سيرد خلال ٤ ساعات عمل.\nللدعم العاجل: واتساب support@dealix.sa\n\nمع التحية،\nفريق دعم Dealix",
|
||||
)
|
||||
return EmailDraft(
|
||||
subject="شكراً لتواصلك مع Dealix",
|
||||
body="أهلاً،\n\nشكراً لرسالتك! فريقنا سيرد عليك قريباً.\nلو تحتاج رد أسرع، كلمنا واتساب.\n\nمع التحية،\nفريق Dealix",
|
||||
)
|
||||
|
||||
async def generate_outreach(
|
||||
self, lead: dict, campaign_type: str = "cold_intro", language: str = "ar"
|
||||
) -> EmailDraft:
|
||||
template = ARABIC_TEMPLATES.get(campaign_type, ARABIC_TEMPLATES["cold_intro"])
|
||||
filled_body = template.body
|
||||
for key, val in lead.items():
|
||||
filled_body = filled_body.replace("{" + key + "}", str(val))
|
||||
filled_subject = template.subject
|
||||
for key, val in lead.items():
|
||||
filled_subject = filled_subject.replace("{" + key + "}", str(val))
|
||||
return EmailDraft(subject=filled_subject, body=filled_body, language=language, campaign_type=campaign_type)
|
||||
|
||||
async def generate_nurture_sequence(self, lead: dict, db: Any = None) -> list[EmailDraft]:
|
||||
sequence_keys = ["cold_intro", "follow_up_1", "follow_up_2", "demo_invite", "proposal"]
|
||||
return [await self.generate_outreach(lead, key) for key in sequence_keys]
|
||||
|
||||
def get_template(self, template_name: str) -> Optional[EmailDraft]:
|
||||
return ARABIC_TEMPLATES.get(template_name)
|
||||
|
||||
def list_templates(self) -> list[str]:
|
||||
return list(ARABIC_TEMPLATES.keys())
|
||||
|
||||
|
||||
# Global singleton
|
||||
email_brain = EmailBrain()
|
||||
147
salesflow-saas/backend/app/services/linkedin_brain.py
Normal file
147
salesflow-saas/backend/app/services/linkedin_brain.py
Normal file
@ -0,0 +1,147 @@
|
||||
"""
|
||||
LinkedIn AI Brain — Dealix AI Revenue OS
|
||||
ASSIST MODE ONLY: generates drafts for human review, never auto-sends.
|
||||
All outputs are suggestions — the operator approves before sending.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_CONNECTION_REQUEST = 300
|
||||
MAX_INMAIL = 1900
|
||||
|
||||
|
||||
class LinkedInDraft(BaseModel):
|
||||
draft_type: str # connection_request, inmail, post, comment
|
||||
content: str
|
||||
target_name: str = ""
|
||||
target_company: str = ""
|
||||
language: str = "ar"
|
||||
status: str = "pending_review" # always starts as pending
|
||||
created_at: datetime = None
|
||||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
if self.created_at is None:
|
||||
self.created_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class OutreachTask(BaseModel):
|
||||
task_type: str # send_connection, send_inmail, engage_post
|
||||
target: dict
|
||||
draft: LinkedInDraft
|
||||
priority: int = 0
|
||||
status: str = "queued"
|
||||
|
||||
|
||||
ARABIC_PURPOSES = {
|
||||
"sales": "نبي نعرفك على Dealix — نظام مبيعات ذكي للسوق السعودي",
|
||||
"partnership": "نبحث عن شراكة استراتيجية مع {company}",
|
||||
"hiring": "عندنا فرصة في Dealix ممكن تناسب خبرتك",
|
||||
"networking": "يسعدني التواصل مع محترفين في مجال {title}",
|
||||
}
|
||||
|
||||
POST_TOPICS_AR = {
|
||||
"saudi_digital": "التحول الرقمي في السعودية",
|
||||
"ai_sales": "الذكاء الاصطناعي في المبيعات",
|
||||
"crm_tips": "نصائح إدارة علاقات العملاء",
|
||||
"startup_growth": "نمو الشركات الناشئة السعودية",
|
||||
"vision_2030": "رؤية ٢٠٣٠ والتقنية",
|
||||
}
|
||||
|
||||
|
||||
class LinkedInBrain:
|
||||
"""Assist-mode LinkedIn brain — drafts only, never auto-sends."""
|
||||
|
||||
def __init__(self):
|
||||
from app.services.whatsapp_knowledge import DealixKnowledge
|
||||
self.knowledge = DealixKnowledge
|
||||
|
||||
async def draft_connection_request(
|
||||
self, name: str, title: str, company: str, purpose: str = "sales", lang: str = "ar"
|
||||
) -> str:
|
||||
purpose_text = ARABIC_PURPOSES.get(purpose, ARABIC_PURPOSES["networking"])
|
||||
purpose_text = purpose_text.format(company=company, title=title)
|
||||
|
||||
if lang == "ar":
|
||||
draft = f"أهلاً {name}! {purpose_text}. يسعدني نتواصل ونتبادل الأفكار."
|
||||
else:
|
||||
draft = f"Hi {name}! I'd love to connect — {purpose_text.replace(company, company)}. Looking forward to exchanging ideas."
|
||||
|
||||
if len(draft) > MAX_CONNECTION_REQUEST:
|
||||
draft = draft[:MAX_CONNECTION_REQUEST - 3] + "..."
|
||||
logger.info(f"[LinkedInBrain] drafted connection request for {name} @ {company}")
|
||||
return draft
|
||||
|
||||
async def draft_inmail(self, profile: dict, deal_type: str = "sales", lang: str = "ar") -> str:
|
||||
name = profile.get("name", "")
|
||||
title = profile.get("title", "")
|
||||
company = profile.get("company", "")
|
||||
|
||||
if deal_type == "partnership":
|
||||
template = ARABIC_PURPOSES["partnership"].format(company=company, title=title)
|
||||
body = f"السلام عليكم {name},\n\n{template}.\n\nDealix يدعم ١٥ نوع صفقة استراتيجية — من تبادل خدمات للتوزيع والشراكات التقنية.\n\nهل عندك ١٠ دقائق نتكلم؟\n\nمع التحية"
|
||||
elif deal_type == "hiring":
|
||||
body = f"أهلاً {name},\n\nشفت بروفايلك وخبرتك في {title} — عندنا فرصة في Dealix ممكن تناسبك.\n\nنبني نظام مبيعات ذكي للسوق السعودي ونبحث عن كفاءات مميزة.\n\nتحب نتكلم أكثر؟\n\nمع التحية"
|
||||
else:
|
||||
pricing = "يبدأ من ٢٩٩ ر.س/شهر"
|
||||
body = f"السلام عليكم {name},\n\nأتواصل معك لأن {company} ممكن تستفيد من Dealix — نظام المبيعات الذكي للسوق السعودي.\n\n• واتساب CRM مدمج\n• ذكاء اصطناعي يفهم عربي\n• {pricing}\n\nتبي عرض سريع ١٥ دقيقة؟\n\nمع التحية"
|
||||
|
||||
if lang != "ar":
|
||||
body = f"Hi {name},\n\nI'm reaching out because {company} could benefit from Dealix — the smart CRM built for Saudi Arabia.\n\n• WhatsApp-native CRM\n• Arabic AI\n• Starts at 299 SAR/mo\n\nWould you have 15 minutes for a quick demo?\n\nBest regards"
|
||||
|
||||
return body[:MAX_INMAIL]
|
||||
|
||||
async def draft_post(self, topic: str, audience: str = "business", lang: str = "ar") -> str:
|
||||
topic_ar = POST_TOPICS_AR.get(topic, topic)
|
||||
|
||||
if lang == "ar":
|
||||
return (
|
||||
f"موضوع اليوم: {topic_ar}\n\n"
|
||||
f"في السوق السعودي، الشركات اللي تستخدم أدوات ذكية تحقق نتائج أفضل بـ ٤٠٪.\n\n"
|
||||
f"ثلاث نصائح سريعة:\n"
|
||||
f"١. استخدم الواتساب كقناة بيع رئيسية\n"
|
||||
f"٢. فعّل الذكاء الاصطناعي للتقييم التلقائي\n"
|
||||
f"٣. تابع عملاءك بالعربي — يفرق!\n\n"
|
||||
f"وش رأيكم؟ شاركوني تجربتكم.\n\n"
|
||||
f"#Dealix #مبيعات #السعودية #تقنية #CRM"
|
||||
)
|
||||
return (
|
||||
f"Today's topic: {topic_ar}\n\n"
|
||||
f"In the Saudi market, companies using smart tools see 40% better results.\n\n"
|
||||
f"3 quick tips:\n1. Use WhatsApp as your main sales channel\n"
|
||||
f"2. Enable AI for automatic lead scoring\n3. Follow up in Arabic — it matters!\n\n"
|
||||
f"What do you think? Share your experience.\n\n#Dealix #Sales #SaudiArabia #CRM"
|
||||
)
|
||||
|
||||
async def generate_outreach_queue(
|
||||
self, criteria: dict, db: Any = None
|
||||
) -> list[OutreachTask]:
|
||||
targets = criteria.get("targets", [])
|
||||
purpose = criteria.get("purpose", "sales")
|
||||
lang = criteria.get("language", "ar")
|
||||
tasks = []
|
||||
|
||||
for i, target in enumerate(targets[:50]):
|
||||
name = target.get("name", "")
|
||||
title = target.get("title", "")
|
||||
company = target.get("company", "")
|
||||
|
||||
conn_text = await self.draft_connection_request(name, title, company, purpose, lang)
|
||||
draft = LinkedInDraft(
|
||||
draft_type="connection_request", content=conn_text,
|
||||
target_name=name, target_company=company, language=lang,
|
||||
)
|
||||
tasks.append(OutreachTask(
|
||||
task_type="send_connection", target=target, draft=draft, priority=i,
|
||||
))
|
||||
logger.info(f"[LinkedInBrain] generated {len(tasks)} outreach tasks for review")
|
||||
return tasks
|
||||
|
||||
|
||||
# Global singleton
|
||||
linkedin_brain = LinkedInBrain()
|
||||
229
salesflow-saas/backend/app/services/local_inference.py
Normal file
229
salesflow-saas/backend/app/services/local_inference.py
Normal file
@ -0,0 +1,229 @@
|
||||
"""
|
||||
Local Inference Adapter — Dealix AI Revenue OS
|
||||
Connects to local/private LLM providers (Ollama, LM Studio, Atomic Chat)
|
||||
via OpenAI-compatible API. Privacy-first, cost-optimized, Arabic-tuned.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LocalProvider(BaseModel):
|
||||
name: str
|
||||
base_url: str # e.g., "http://localhost:11434/v1" for Ollama
|
||||
model: str # e.g., "qwen2.5:7b", "llama3.1:8b"
|
||||
is_healthy: bool = False
|
||||
last_check: Optional[datetime] = None
|
||||
avg_latency_ms: float = 0.0
|
||||
total_calls: int = 0
|
||||
total_failures: int = 0
|
||||
|
||||
|
||||
# Default local providers to check
|
||||
DEFAULT_PROVIDERS = [
|
||||
LocalProvider(
|
||||
name="ollama",
|
||||
base_url="http://localhost:11434/v1",
|
||||
model="qwen2.5:7b",
|
||||
),
|
||||
LocalProvider(
|
||||
name="lm-studio",
|
||||
base_url="http://localhost:1234/v1",
|
||||
model="local-model",
|
||||
),
|
||||
LocalProvider(
|
||||
name="atomic-chat",
|
||||
base_url="http://localhost:8080/v1",
|
||||
model="default",
|
||||
),
|
||||
]
|
||||
|
||||
# Tasks suitable for local inference
|
||||
LOCAL_SUITABLE_TASKS = {
|
||||
"arabic_summarization": "تلخيص نصوص عربية",
|
||||
"text_classification": "تصنيف نصوص",
|
||||
"entity_extraction": "استخراج كيانات",
|
||||
"internal_drafting": "صياغة مسودات داخلية",
|
||||
"sentiment_analysis": "تحليل المشاعر",
|
||||
"translation": "ترجمة نصوص",
|
||||
"data_cleaning": "تنظيف بيانات",
|
||||
"code_review_simple": "مراجعة كود بسيطة",
|
||||
}
|
||||
|
||||
# Tasks that should NEVER use local inference
|
||||
CLOUD_ONLY_TASKS = {
|
||||
"proposal_generation",
|
||||
"complex_reasoning",
|
||||
"long_document_analysis",
|
||||
"customer_facing_messages",
|
||||
}
|
||||
|
||||
|
||||
class LocalInferenceResult(BaseModel):
|
||||
provider: str
|
||||
model: str
|
||||
response: str
|
||||
latency_ms: int
|
||||
tokens_used: int = 0
|
||||
cost_usd: float = 0.0 # Local = free
|
||||
success: bool = True
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class LocalInferenceAdapter:
|
||||
"""
|
||||
Adapter for local/private LLM inference.
|
||||
Tries providers in order, falls back gracefully to cloud.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._providers = list(DEFAULT_PROVIDERS)
|
||||
self._primary: Optional[LocalProvider] = None
|
||||
|
||||
async def health_check(self, provider: LocalProvider = None) -> bool:
|
||||
"""Check if a local provider is available."""
|
||||
targets = [provider] if provider else self._providers
|
||||
for p in targets:
|
||||
try:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(f"{p.base_url}/models")
|
||||
if resp.status_code == 200:
|
||||
p.is_healthy = True
|
||||
p.last_check = datetime.now(timezone.utc)
|
||||
if not self._primary:
|
||||
self._primary = p
|
||||
logger.info(f"Local provider {p.name} is healthy at {p.base_url}")
|
||||
return True
|
||||
except Exception:
|
||||
p.is_healthy = False
|
||||
p.last_check = datetime.now(timezone.utc)
|
||||
continue
|
||||
return False
|
||||
|
||||
async def health_check_all(self) -> dict[str, bool]:
|
||||
"""Check all configured local providers."""
|
||||
results = {}
|
||||
for p in self._providers:
|
||||
results[p.name] = await self.health_check(p)
|
||||
return results
|
||||
|
||||
def is_suitable_for_local(self, task_type: str) -> bool:
|
||||
"""Check if a task should use local inference."""
|
||||
if task_type in CLOUD_ONLY_TASKS:
|
||||
return False
|
||||
return task_type in LOCAL_SUITABLE_TASKS
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
prompt: str,
|
||||
system_prompt: str = "",
|
||||
task_type: str = "general",
|
||||
max_tokens: int = 1024,
|
||||
temperature: float = 0.7,
|
||||
) -> LocalInferenceResult:
|
||||
"""Run inference on local provider. Falls back gracefully."""
|
||||
if not self._primary or not self._primary.is_healthy:
|
||||
await self.health_check()
|
||||
|
||||
if not self._primary:
|
||||
return LocalInferenceResult(
|
||||
provider="none",
|
||||
model="none",
|
||||
response="",
|
||||
latency_ms=0,
|
||||
success=False,
|
||||
error="لا يوجد مزود محلي متاح — استخدم السحابة",
|
||||
)
|
||||
|
||||
start = datetime.now(timezone.utc)
|
||||
provider = self._primary
|
||||
|
||||
try:
|
||||
import httpx
|
||||
messages = []
|
||||
if system_prompt:
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.post(
|
||||
f"{provider.base_url}/chat/completions",
|
||||
json={
|
||||
"model": provider.model,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
latency = int((datetime.now(timezone.utc) - start).total_seconds() * 1000)
|
||||
provider.total_calls += 1
|
||||
provider.avg_latency_ms = (
|
||||
(provider.avg_latency_ms * (provider.total_calls - 1) + latency)
|
||||
/ provider.total_calls
|
||||
)
|
||||
|
||||
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
tokens = data.get("usage", {}).get("total_tokens", 0)
|
||||
|
||||
return LocalInferenceResult(
|
||||
provider=provider.name,
|
||||
model=provider.model,
|
||||
response=content,
|
||||
latency_ms=latency,
|
||||
tokens_used=tokens,
|
||||
cost_usd=0.0,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
provider.total_failures += 1
|
||||
provider.is_healthy = False
|
||||
latency = int((datetime.now(timezone.utc) - start).total_seconds() * 1000)
|
||||
logger.warning(f"Local inference failed on {provider.name}: {e}")
|
||||
return LocalInferenceResult(
|
||||
provider=provider.name,
|
||||
model=provider.model,
|
||||
response="",
|
||||
latency_ms=latency,
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
def add_provider(self, name: str, base_url: str, model: str) -> None:
|
||||
"""Register a new local provider."""
|
||||
self._providers.append(LocalProvider(
|
||||
name=name, base_url=base_url, model=model,
|
||||
))
|
||||
|
||||
def get_providers(self) -> list[dict]:
|
||||
"""List all configured providers with health status."""
|
||||
return [
|
||||
{
|
||||
"name": p.name,
|
||||
"base_url": p.base_url,
|
||||
"model": p.model,
|
||||
"healthy": p.is_healthy,
|
||||
"last_check": p.last_check.isoformat() if p.last_check else None,
|
||||
"avg_latency_ms": round(p.avg_latency_ms, 1),
|
||||
"total_calls": p.total_calls,
|
||||
"failure_rate": round(
|
||||
p.total_failures / p.total_calls * 100, 1
|
||||
) if p.total_calls > 0 else 0,
|
||||
"is_primary": p == self._primary,
|
||||
}
|
||||
for p in self._providers
|
||||
]
|
||||
|
||||
def get_suitable_tasks(self) -> dict[str, str]:
|
||||
"""List tasks suitable for local inference."""
|
||||
return dict(LOCAL_SUITABLE_TASKS)
|
||||
|
||||
|
||||
local_inference = LocalInferenceAdapter()
|
||||
176
salesflow-saas/backend/app/services/social_media_brain.py
Normal file
176
salesflow-saas/backend/app/services/social_media_brain.py
Normal file
@ -0,0 +1,176 @@
|
||||
"""
|
||||
Social Media AI Brain — Dealix AI Revenue OS
|
||||
Unified brain for Instagram, TikTok, Twitter, and Snapchat.
|
||||
Handles inbound DMs, content generation, and content calendar planning.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Platform(str, Enum):
|
||||
INSTAGRAM = "instagram"
|
||||
TIKTOK = "tiktok"
|
||||
TWITTER = "twitter"
|
||||
SNAPCHAT = "snapchat"
|
||||
|
||||
|
||||
PLATFORM_RULES = {
|
||||
Platform.INSTAGRAM: {"max_chars": 2200, "max_hashtags": 30, "name_ar": "إنستغرام"},
|
||||
Platform.TIKTOK: {"max_chars": 300, "max_hashtags": 5, "name_ar": "تيك توك"},
|
||||
Platform.TWITTER: {"max_chars": 280, "max_hashtags": 3, "name_ar": "تويتر"},
|
||||
Platform.SNAPCHAT: {"max_chars": 250, "max_hashtags": 0, "name_ar": "سناب شات"},
|
||||
}
|
||||
|
||||
SAUDI_CONTENT_THEMES = [
|
||||
{"id": "vision_2030", "name_ar": "رؤية ٢٠٣٠ والتحول الرقمي", "hashtags_ar": ["#رؤية_السعودية_2030", "#تحول_رقمي"]},
|
||||
{"id": "smb_growth", "name_ar": "نمو المشاريع الصغيرة والمتوسطة", "hashtags_ar": ["#ريادة_أعمال", "#مشاريع_صغيرة"]},
|
||||
{"id": "ai_arabic", "name_ar": "الذكاء الاصطناعي بالعربي", "hashtags_ar": ["#ذكاء_اصطناعي", "#تقنية"]},
|
||||
{"id": "sales_tips", "name_ar": "نصائح المبيعات للسوق السعودي", "hashtags_ar": ["#مبيعات", "#CRM"]},
|
||||
{"id": "whatsapp_business", "name_ar": "واتساب للأعمال", "hashtags_ar": ["#واتساب_أعمال", "#تواصل"]},
|
||||
]
|
||||
|
||||
DM_INTENT_KEYWORDS = {
|
||||
"pricing": ["سعر", "كم", "باقة", "price", "cost"],
|
||||
"demo": ["عرض", "demo", "تجربة", "وريني"],
|
||||
"support": ["مشكلة", "مساعدة", "help", "خطأ"],
|
||||
"partnership": ["شراكة", "تعاون", "partner"],
|
||||
}
|
||||
|
||||
|
||||
class ContentDraft(BaseModel):
|
||||
platform: str
|
||||
content: str
|
||||
hashtags: list[str] = []
|
||||
language: str = "ar"
|
||||
theme: str = ""
|
||||
created_at: datetime = None
|
||||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
if self.created_at is None:
|
||||
self.created_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class CalendarEntry(BaseModel):
|
||||
date: str
|
||||
platform: str
|
||||
theme: str
|
||||
content: ContentDraft
|
||||
time_slot: str = "10:00"
|
||||
|
||||
|
||||
class SocialMediaBrain:
|
||||
"""Unified brain for Instagram, TikTok, Twitter, Snapchat."""
|
||||
|
||||
def __init__(self):
|
||||
from app.services.whatsapp_knowledge import DealixKnowledge
|
||||
self.knowledge = DealixKnowledge
|
||||
|
||||
def _detect_dm_intent(self, message: str) -> str:
|
||||
msg_lower = message.lower()
|
||||
for intent, keywords in DM_INTENT_KEYWORDS.items():
|
||||
if any(kw in msg_lower for kw in keywords):
|
||||
return intent
|
||||
return "general"
|
||||
|
||||
def _enforce_platform_limits(self, text: str, hashtags: list[str], platform: Platform) -> tuple[str, list[str]]:
|
||||
rules = PLATFORM_RULES[platform]
|
||||
hashtags = hashtags[:rules["max_hashtags"]]
|
||||
hashtag_text = " ".join(hashtags)
|
||||
max_content = rules["max_chars"] - len(hashtag_text) - 2 if hashtags else rules["max_chars"]
|
||||
if len(text) > max_content:
|
||||
text = text[:max_content - 3] + "..."
|
||||
return text, hashtags
|
||||
|
||||
async def handle_inbound_dm(
|
||||
self, platform: str, sender: str, message: str, db: Any = None
|
||||
) -> str:
|
||||
plat = Platform(platform) if platform in Platform.__members__.values() else Platform.INSTAGRAM
|
||||
intent = self._detect_dm_intent(message)
|
||||
plat_name = PLATFORM_RULES[plat]["name_ar"]
|
||||
logger.info(f"[SocialMediaBrain] DM on {plat.value} from={sender} intent={intent}")
|
||||
|
||||
if intent == "pricing":
|
||||
pricing = self.knowledge.get_pricing_text("ar")
|
||||
return f"أهلاً! شكراً لتواصلك عبر {plat_name}.\n\nباقات Dealix:\n{pricing}\n\nتبي تفاصيل أكثر؟ راسلنا واتساب أو زور dealix.sa"
|
||||
|
||||
if intent == "demo":
|
||||
return f"ممتاز! يسعدنا نعرض لك Dealix.\n\nاحجز عرض توضيحي مجاني (١٥ دقيقة): dealix.sa/demo\n\nأو أرسل رقمك ونتواصل معك واتساب."
|
||||
|
||||
if intent == "support":
|
||||
return f"أهلاً! للدعم الفني الأسرع، تواصل معنا:\n• واتساب: dealix.sa/whatsapp\n• إيميل: support@dealix.sa\n\nأو وصف مشكلتك هنا وبنساعدك."
|
||||
|
||||
if intent == "partnership":
|
||||
return "شكراً لاهتمامك بالشراكة مع Dealix!\n\nأرسل لنا إيميل على partners@dealix.sa أو واتساب ونرتب اجتماع."
|
||||
|
||||
return f"أهلاً وسهلاً! أنا مساعد Dealix على {plat_name}.\n\nأقدر أساعدك في:\n• الأسعار والباقات\n• حجز عرض توضيحي\n• الدعم الفني\n\nوش تحتاج؟"
|
||||
|
||||
async def generate_content(
|
||||
self, platform: str, topic: str, language: str = "ar"
|
||||
) -> ContentDraft:
|
||||
plat = Platform(platform) if platform in Platform.__members__.values() else Platform.INSTAGRAM
|
||||
theme = next((t for t in SAUDI_CONTENT_THEMES if t["id"] == topic), SAUDI_CONTENT_THEMES[0])
|
||||
hashtags_base = theme["hashtags_ar"] + ["#Dealix"]
|
||||
|
||||
if language == "ar":
|
||||
content_map = {
|
||||
Platform.INSTAGRAM: (
|
||||
f"{theme['name_ar']}\n\n"
|
||||
f"في السوق السعودي، الشركات اللي تستخدم أدوات ذكية تحقق نتائج أفضل.\n\n"
|
||||
f"Dealix يساعدك:\n"
|
||||
f"✅ إدارة عملاءك بالواتساب\n"
|
||||
f"✅ ذكاء اصطناعي يفهم عربي\n"
|
||||
f"✅ تقارير وتنبؤات مبيعات\n\n"
|
||||
f"جرّب مجاناً ١٤ يوم — الرابط بالبايو"
|
||||
),
|
||||
Platform.TIKTOK: f"{theme['name_ar']}\n\nDealix — نظام مبيعات ذكي للسوق السعودي. جرّب مجاناً!",
|
||||
Platform.TWITTER: f"{theme['name_ar']}\n\nDealix: واتساب CRM + AI عربي للشركات السعودية. جرّب مجاناً ١٤ يوم.",
|
||||
Platform.SNAPCHAT: f"{theme['name_ar']}\n\nDealix — نظام مبيعاتك الذكي. جرّبه مجاناً!",
|
||||
}
|
||||
else:
|
||||
content_map = {
|
||||
Platform.INSTAGRAM: f"{theme['name_ar']}\n\nSmart companies in Saudi use AI-powered tools.\n\nDealix helps you:\n✅ WhatsApp CRM\n✅ Arabic AI\n✅ Sales forecasting\n\nTry free for 14 days — link in bio",
|
||||
Platform.TIKTOK: f"{theme['name_ar']}\n\nDealix — smart sales for Saudi. Try free!",
|
||||
Platform.TWITTER: f"{theme['name_ar']}\n\nDealix: WhatsApp CRM + Arabic AI for Saudi companies. 14-day free trial.",
|
||||
Platform.SNAPCHAT: f"{theme['name_ar']}\n\nDealix — your smart sales system. Try free!",
|
||||
}
|
||||
|
||||
raw_content = content_map.get(plat, content_map[Platform.INSTAGRAM])
|
||||
final_content, final_hashtags = self._enforce_platform_limits(raw_content, hashtags_base, plat)
|
||||
|
||||
return ContentDraft(
|
||||
platform=plat.value, content=final_content, hashtags=final_hashtags,
|
||||
language=language, theme=topic,
|
||||
)
|
||||
|
||||
async def generate_content_calendar(
|
||||
self, platforms: list[str], days: int = 7, language: str = "ar"
|
||||
) -> list[CalendarEntry]:
|
||||
calendar = []
|
||||
time_slots = {"instagram": "10:00", "tiktok": "18:00", "twitter": "08:00", "snapchat": "14:00"}
|
||||
today = datetime.now(timezone.utc).date()
|
||||
|
||||
for day_offset in range(days):
|
||||
target_date = today + timedelta(days=day_offset)
|
||||
theme = SAUDI_CONTENT_THEMES[day_offset % len(SAUDI_CONTENT_THEMES)]
|
||||
|
||||
for plat_str in platforms:
|
||||
content = await self.generate_content(plat_str, theme["id"], language)
|
||||
calendar.append(CalendarEntry(
|
||||
date=target_date.isoformat(), platform=plat_str,
|
||||
theme=theme["id"], content=content,
|
||||
time_slot=time_slots.get(plat_str, "10:00"),
|
||||
))
|
||||
|
||||
logger.info(f"[SocialMediaBrain] generated {len(calendar)} calendar entries for {days} days")
|
||||
return calendar
|
||||
|
||||
|
||||
# Global singleton
|
||||
social_media_brain = SocialMediaBrain()
|
||||
@ -0,0 +1,64 @@
|
||||
"""
|
||||
Dealix Strategic Deals Engine — Deal Exchange OS + Strategic Growth OS
|
||||
محرك الصفقات الاستراتيجية — نظام تبادل الصفقات + نظام النمو الاستراتيجي
|
||||
اكتشاف وتفاوض وإغلاق شراكات B2B بالذكاء الاصطناعي
|
||||
"""
|
||||
|
||||
from app.services.strategic_deals.company_profiler import CompanyProfiler
|
||||
from app.services.strategic_deals.deal_matcher import DealMatcher
|
||||
from app.services.strategic_deals.deal_negotiator import DealNegotiator, NegotiationStrategy
|
||||
from app.services.strategic_deals.deal_agent import DealAgent
|
||||
from app.services.strategic_deals.company_twin import CompanyTwin, CompanyTwinBuilder
|
||||
from app.services.strategic_deals.deal_taxonomy import DealTaxonomyService, DEAL_TAXONOMY
|
||||
from app.services.strategic_deals.deal_room import DealRoom, DealRoomService
|
||||
from app.services.strategic_deals.operating_modes import OperatingMode, ModeEnforcer, MODE_POLICIES
|
||||
from app.services.strategic_deals.channel_compliance import ChannelRules, ConsentLedger
|
||||
|
||||
# Strategic Growth OS
|
||||
from app.services.strategic_deals.acquisition_scouting import (
|
||||
AcquisitionTarget, AcquisitionCriteria, AcquisitionScoutingEngine,
|
||||
)
|
||||
from app.services.strategic_deals.ecosystem_mapper import (
|
||||
EcosystemEntity, EcosystemLink, EcosystemMapper,
|
||||
)
|
||||
from app.services.strategic_deals.strategic_simulator import (
|
||||
StrategicScenario, StrategicSimulator,
|
||||
)
|
||||
from app.services.strategic_deals.roi_engine import ROICalculation, ROIEngine
|
||||
from app.services.strategic_deals.portfolio_intelligence import (
|
||||
PortfolioInsight, PortfolioIntelligence,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Existing
|
||||
"CompanyProfiler",
|
||||
"DealMatcher",
|
||||
"DealNegotiator",
|
||||
"NegotiationStrategy",
|
||||
"DealAgent",
|
||||
# Deal Exchange OS
|
||||
"CompanyTwin",
|
||||
"CompanyTwinBuilder",
|
||||
"DealTaxonomyService",
|
||||
"DEAL_TAXONOMY",
|
||||
"DealRoom",
|
||||
"DealRoomService",
|
||||
"OperatingMode",
|
||||
"ModeEnforcer",
|
||||
"MODE_POLICIES",
|
||||
"ChannelRules",
|
||||
"ConsentLedger",
|
||||
# Strategic Growth OS
|
||||
"AcquisitionTarget",
|
||||
"AcquisitionCriteria",
|
||||
"AcquisitionScoutingEngine",
|
||||
"EcosystemEntity",
|
||||
"EcosystemLink",
|
||||
"EcosystemMapper",
|
||||
"StrategicScenario",
|
||||
"StrategicSimulator",
|
||||
"ROICalculation",
|
||||
"ROIEngine",
|
||||
"PortfolioInsight",
|
||||
"PortfolioIntelligence",
|
||||
]
|
||||
@ -0,0 +1,494 @@
|
||||
"""
|
||||
Acquisition Scouting Engine — AI-powered M&A target identification for Saudi B2B.
|
||||
محرك استكشاف الاستحواذ: تحديد أهداف الاندماج والاستحواذ بالذكاء الاصطناعي للسوق السعودي
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select, and_, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.strategic_deal import CompanyProfile
|
||||
from app.services.llm.provider import get_llm
|
||||
|
||||
logger = logging.getLogger("dealix.strategic_deals.acquisition_scouting")
|
||||
|
||||
# ── Saudi sector synergy map ────────────────────────────────────────────────
|
||||
|
||||
SECTOR_SYNERGY = {
|
||||
"technology": ["consulting", "telecom", "media", "education"],
|
||||
"construction": ["real_estate", "manufacturing", "energy", "logistics"],
|
||||
"real_estate": ["construction", "finance", "tourism"],
|
||||
"retail": ["wholesale", "logistics", "food_beverage", "marketing"],
|
||||
"healthcare": ["technology", "manufacturing", "consulting"],
|
||||
"finance": ["technology", "real_estate", "consulting"],
|
||||
"logistics": ["retail", "wholesale", "manufacturing", "food_beverage"],
|
||||
"energy": ["construction", "manufacturing", "technology"],
|
||||
"food_beverage": ["logistics", "retail", "agriculture", "tourism"],
|
||||
"consulting": ["technology", "finance", "healthcare", "education"],
|
||||
"manufacturing": ["construction", "wholesale", "logistics", "energy"],
|
||||
"marketing": ["technology", "media", "retail", "telecom"],
|
||||
"telecom": ["technology", "media", "consulting"],
|
||||
"education": ["technology", "consulting", "media"],
|
||||
"tourism": ["food_beverage", "real_estate", "marketing"],
|
||||
"media": ["marketing", "technology", "telecom", "tourism"],
|
||||
"agriculture": ["food_beverage", "logistics", "manufacturing"],
|
||||
"automotive": ["manufacturing", "logistics", "finance"],
|
||||
"government": ["technology", "consulting", "construction"],
|
||||
"wholesale": ["retail", "manufacturing", "logistics"],
|
||||
}
|
||||
|
||||
# ── Valid status transitions ────────────────────────────────────────────────
|
||||
|
||||
VALID_STATUSES = ("scouted", "qualified", "briefed", "intro_sent", "in_discussion")
|
||||
|
||||
STATUS_TRANSITIONS = {
|
||||
"scouted": ["qualified", "briefed"],
|
||||
"qualified": ["briefed", "intro_sent"],
|
||||
"briefed": ["intro_sent", "in_discussion"],
|
||||
"intro_sent": ["in_discussion"],
|
||||
"in_discussion": [],
|
||||
}
|
||||
|
||||
|
||||
# ── Models ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AcquisitionTarget(BaseModel):
|
||||
"""Represents a scouted M&A target with strategic scoring."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
company_name: str
|
||||
company_name_ar: str = ""
|
||||
industry: str = ""
|
||||
city: str = ""
|
||||
strategic_fit_score: float = Field(0.0, ge=0.0, le=1.0)
|
||||
market_adjacency: float = Field(0.0, ge=0.0, le=1.0)
|
||||
size_fit: float = Field(0.0, ge=0.0, le=1.0)
|
||||
estimated_value_sar: float = 0.0
|
||||
growth_signals: list[str] = Field(default_factory=list)
|
||||
risk_factors: list[str] = Field(default_factory=list)
|
||||
brief: str = ""
|
||||
status: str = "scouted"
|
||||
tenant_id: Optional[str] = None
|
||||
scouted_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"company_name": "TechVision Co",
|
||||
"company_name_ar": "شركة تك فيجن",
|
||||
"industry": "technology",
|
||||
"city": "الرياض",
|
||||
"strategic_fit_score": 0.85,
|
||||
"market_adjacency": 0.7,
|
||||
"size_fit": 0.6,
|
||||
"estimated_value_sar": 5_000_000.0,
|
||||
"growth_signals": ["نمو الإيرادات ٣٠٪ سنوياً", "توسع في ٣ مدن جديدة"],
|
||||
"risk_factors": ["اعتماد كبير على عميل واحد"],
|
||||
"status": "scouted",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AcquisitionCriteria(BaseModel):
|
||||
"""Filter criteria for scouting acquisition targets."""
|
||||
industries: list[str] = Field(default_factory=list)
|
||||
cities: list[str] = Field(default_factory=list)
|
||||
min_revenue_sar: float = 0.0
|
||||
max_revenue_sar: float = 0.0
|
||||
min_employees: int = 0
|
||||
max_employees: int = 0
|
||||
required_capabilities: list[str] = Field(default_factory=list)
|
||||
exclude_ids: list[str] = Field(default_factory=list)
|
||||
min_strategic_fit: float = 0.3
|
||||
|
||||
|
||||
# ── Engine ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AcquisitionScoutingEngine:
|
||||
"""
|
||||
AI-powered acquisition target scouting engine.
|
||||
Identifies, scores, and briefs potential M&A targets in the Saudi market.
|
||||
محرك استكشاف أهداف الاستحواذ بالذكاء الاصطناعي — يحدد ويقيّم ويلخص أهداف الاندماج والاستحواذ
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.llm = get_llm()
|
||||
self._watchlists: dict[str, list[AcquisitionTarget]] = {}
|
||||
|
||||
# ── Scout ───────────────────────────────────────────────────────────────
|
||||
|
||||
async def scout(
|
||||
self,
|
||||
criteria: AcquisitionCriteria,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> list[AcquisitionTarget]:
|
||||
"""
|
||||
Scout potential acquisition targets matching criteria from the company pool.
|
||||
استكشاف أهداف الاستحواذ المحتملة التي تطابق المعايير من قاعدة الشركات
|
||||
"""
|
||||
query = select(CompanyProfile).where(
|
||||
CompanyProfile.tenant_id == tenant_id,
|
||||
CompanyProfile.is_verified == True, # noqa: E712
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
all_profiles = result.scalars().all()
|
||||
|
||||
if not all_profiles:
|
||||
logger.info("No company profiles found for tenant %s", tenant_id)
|
||||
return []
|
||||
|
||||
targets: list[AcquisitionTarget] = []
|
||||
|
||||
for profile in all_profiles:
|
||||
if str(profile.id) in criteria.exclude_ids:
|
||||
continue
|
||||
|
||||
# Industry filter
|
||||
if criteria.industries and (profile.industry or "") not in criteria.industries:
|
||||
adjacent = set()
|
||||
for ind in criteria.industries:
|
||||
adjacent.update(SECTOR_SYNERGY.get(ind, []))
|
||||
if (profile.industry or "") not in adjacent:
|
||||
continue
|
||||
|
||||
# City filter
|
||||
if criteria.cities and (profile.region or "") not in criteria.cities:
|
||||
continue
|
||||
|
||||
# Revenue filter
|
||||
revenue = float(profile.annual_revenue_sar or 0)
|
||||
if criteria.min_revenue_sar > 0 and revenue < criteria.min_revenue_sar:
|
||||
continue
|
||||
if criteria.max_revenue_sar > 0 and revenue > criteria.max_revenue_sar:
|
||||
continue
|
||||
|
||||
# Employee count filter
|
||||
emp = int(profile.employee_count or 0)
|
||||
if criteria.min_employees > 0 and emp < criteria.min_employees:
|
||||
continue
|
||||
if criteria.max_employees > 0 and emp > criteria.max_employees:
|
||||
continue
|
||||
|
||||
# Capability filter
|
||||
if criteria.required_capabilities:
|
||||
profile_caps = {c.lower() for c in (profile.capabilities or [])}
|
||||
required = {c.lower() for c in criteria.required_capabilities}
|
||||
if not required & profile_caps:
|
||||
continue
|
||||
|
||||
# Build raw target
|
||||
target = AcquisitionTarget(
|
||||
company_name=profile.company_name or "",
|
||||
company_name_ar=profile.company_name_ar if hasattr(profile, "company_name_ar") else "",
|
||||
industry=profile.industry or "",
|
||||
city=profile.region or "",
|
||||
estimated_value_sar=self._estimate_value(profile),
|
||||
status="scouted",
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
targets.append(target)
|
||||
|
||||
# Score all targets using LLM for strategic fit
|
||||
if targets:
|
||||
acquirer_profile = await self._get_acquirer_profile(tenant_id, db)
|
||||
scored = []
|
||||
for target in targets:
|
||||
scored_target = await self.score_target(target, acquirer_profile, db)
|
||||
if scored_target.strategic_fit_score >= criteria.min_strategic_fit:
|
||||
scored.append(scored_target)
|
||||
targets = sorted(scored, key=lambda t: t.strategic_fit_score, reverse=True)
|
||||
|
||||
# Persist to watchlist
|
||||
self._watchlists.setdefault(tenant_id, []).extend(targets)
|
||||
|
||||
logger.info(
|
||||
"Scouted %d acquisition targets for tenant %s (from %d candidates)",
|
||||
len(targets), tenant_id, len(all_profiles),
|
||||
)
|
||||
return targets
|
||||
|
||||
# ── Score Target ────────────────────────────────────────────────────────
|
||||
|
||||
async def score_target(
|
||||
self,
|
||||
target: AcquisitionTarget,
|
||||
acquirer_twin: Optional[CompanyProfile],
|
||||
db: AsyncSession,
|
||||
) -> AcquisitionTarget:
|
||||
"""
|
||||
Score a target against the acquirer's strategic profile.
|
||||
تقييم هدف الاستحواذ مقابل الملف الاستراتيجي للمستحوذ
|
||||
"""
|
||||
acquirer_industry = acquirer_twin.industry if acquirer_twin else "unknown"
|
||||
acquirer_caps = acquirer_twin.capabilities if acquirer_twin else []
|
||||
acquirer_revenue = float(acquirer_twin.annual_revenue_sar or 0) if acquirer_twin else 0
|
||||
acquirer_name = acquirer_twin.company_name if acquirer_twin else "الشركة المستحوذة"
|
||||
|
||||
# Market adjacency score
|
||||
target.market_adjacency = self._compute_adjacency(acquirer_industry, target.industry)
|
||||
|
||||
# Size fit — ideal ratio between 0.05 and 0.5 of acquirer
|
||||
if acquirer_revenue > 0 and target.estimated_value_sar > 0:
|
||||
ratio = target.estimated_value_sar / acquirer_revenue
|
||||
if 0.05 <= ratio <= 0.5:
|
||||
target.size_fit = 1.0
|
||||
elif 0.01 <= ratio < 0.05 or 0.5 < ratio <= 1.0:
|
||||
target.size_fit = 0.6
|
||||
else:
|
||||
target.size_fit = 0.3
|
||||
else:
|
||||
target.size_fit = 0.5
|
||||
|
||||
# Use LLM for strategic fit, growth signals, and risk factors
|
||||
context = f"""المستحوذ: {acquirer_name}
|
||||
قطاع المستحوذ: {acquirer_industry}
|
||||
قدرات المستحوذ: {', '.join(acquirer_caps or ['غير محدد'])}
|
||||
إيرادات المستحوذ: {acquirer_revenue:,.0f} ريال
|
||||
|
||||
الهدف: {target.company_name}
|
||||
قطاع الهدف: {target.industry}
|
||||
مدينة الهدف: {target.city}
|
||||
القيمة التقديرية: {target.estimated_value_sar:,.0f} ريال"""
|
||||
|
||||
system_prompt = """أنت مستشار اندماج واستحواذ سعودي خبير. قيّم هذا الهدف الاستحواذي.
|
||||
|
||||
Return JSON:
|
||||
{
|
||||
"strategic_fit_score": 0.0 to 1.0,
|
||||
"growth_signals": ["إشارة نمو ١ بالعربي", "إشارة نمو ٢"],
|
||||
"risk_factors": ["عامل خطر ١ بالعربي", "عامل خطر ٢"],
|
||||
"rationale_ar": "سبب التوصية بالعربي"
|
||||
}"""
|
||||
|
||||
try:
|
||||
llm_response = await self.llm.complete(
|
||||
system_prompt=system_prompt,
|
||||
user_message=context,
|
||||
json_mode=True,
|
||||
temperature=0.3,
|
||||
)
|
||||
result = llm_response.parse_json() or {}
|
||||
|
||||
target.strategic_fit_score = min(1.0, max(0.0, float(result.get("strategic_fit_score", 0.5))))
|
||||
target.growth_signals = result.get("growth_signals", [])
|
||||
target.risk_factors = result.get("risk_factors", [])
|
||||
|
||||
# Blend LLM fit with computed adjacency and size fit
|
||||
blended = (
|
||||
target.strategic_fit_score * 0.5
|
||||
+ target.market_adjacency * 0.3
|
||||
+ target.size_fit * 0.2
|
||||
)
|
||||
target.strategic_fit_score = round(min(1.0, blended), 4)
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning("LLM scoring failed for target %s: %s", target.company_name, exc)
|
||||
target.strategic_fit_score = round(
|
||||
target.market_adjacency * 0.6 + target.size_fit * 0.4, 4
|
||||
)
|
||||
target.growth_signals = ["لم يتم التحليل — يتطلب مراجعة يدوية"]
|
||||
target.risk_factors = ["لم يتم التحليل — يتطلب مراجعة يدوية"]
|
||||
|
||||
logger.info(
|
||||
"Scored target %s: fit=%.2f adjacency=%.2f size=%.2f",
|
||||
target.company_name, target.strategic_fit_score,
|
||||
target.market_adjacency, target.size_fit,
|
||||
)
|
||||
return target
|
||||
|
||||
# ── Generate Brief ──────────────────────────────────────────────────────
|
||||
|
||||
async def generate_brief(
|
||||
self,
|
||||
target_id: str,
|
||||
db: AsyncSession,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a detailed Arabic acquisition brief for a scouted target.
|
||||
إنشاء ملخص استحواذ تفصيلي بالعربي لهدف مُستكشَف
|
||||
"""
|
||||
target = self._find_target(target_id)
|
||||
if not target:
|
||||
raise ValueError(f"Target {target_id} not found in watchlist")
|
||||
|
||||
context = f"""الشركة المستهدفة: {target.company_name} ({target.company_name_ar})
|
||||
القطاع: {target.industry}
|
||||
المدينة: {target.city}
|
||||
القيمة التقديرية: {target.estimated_value_sar:,.0f} ريال سعودي
|
||||
درجة الملاءمة الاستراتيجية: {target.strategic_fit_score:.0%}
|
||||
درجة القرب السوقي: {target.market_adjacency:.0%}
|
||||
ملاءمة الحجم: {target.size_fit:.0%}
|
||||
إشارات النمو: {', '.join(target.growth_signals)}
|
||||
عوامل الخطر: {', '.join(target.risk_factors)}"""
|
||||
|
||||
system_prompt = """أنت مستشار اندماج واستحواذ سعودي. اكتب ملخص استحواذ تنفيذي شامل بالعربي.
|
||||
|
||||
يجب أن يشمل الملخص:
|
||||
١. نظرة عامة على الشركة المستهدفة
|
||||
٢. المبرر الاستراتيجي للاستحواذ
|
||||
٣. تحليل نقاط القوة والفرص
|
||||
٤. المخاطر الرئيسية واستراتيجيات التخفيف
|
||||
٥. التقييم المبدئي والهيكل المقترح
|
||||
٦. الخطوات التالية الموصى بها
|
||||
٧. الجدول الزمني المتوقع
|
||||
|
||||
اكتب الملخص بأسلوب تنفيذي رسمي مناسب لعرضه على مجلس الإدارة."""
|
||||
|
||||
try:
|
||||
llm_response = await self.llm.complete(
|
||||
system_prompt=system_prompt,
|
||||
user_message=context,
|
||||
temperature=0.3,
|
||||
)
|
||||
brief_text = llm_response.content.strip()
|
||||
except Exception as exc:
|
||||
logger.error("Brief generation failed for target %s: %s", target_id, exc)
|
||||
brief_text = (
|
||||
f"ملخص استحواذ — {target.company_name}\n"
|
||||
f"القطاع: {target.industry} | المدينة: {target.city}\n"
|
||||
f"القيمة التقديرية: {target.estimated_value_sar:,.0f} ريال\n"
|
||||
f"درجة الملاءمة: {target.strategic_fit_score:.0%}\n"
|
||||
f"إشارات النمو: {', '.join(target.growth_signals)}\n"
|
||||
f"عوامل الخطر: {', '.join(target.risk_factors)}\n"
|
||||
f"الحالة: يتطلب تحليل يدوي إضافي"
|
||||
)
|
||||
|
||||
target.brief = brief_text
|
||||
target.status = "briefed"
|
||||
|
||||
logger.info("Generated acquisition brief for target %s", target_id)
|
||||
return brief_text
|
||||
|
||||
# ── Get Watchlist ───────────────────────────────────────────────────────
|
||||
|
||||
async def get_watchlist(
|
||||
self,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> list[AcquisitionTarget]:
|
||||
"""
|
||||
Retrieve the current acquisition watchlist for a tenant.
|
||||
استرجاع قائمة مراقبة الاستحواذ الحالية للمستأجر
|
||||
"""
|
||||
watchlist = self._watchlists.get(tenant_id, [])
|
||||
logger.info("Retrieved watchlist for tenant %s: %d targets", tenant_id, len(watchlist))
|
||||
return sorted(watchlist, key=lambda t: t.strategic_fit_score, reverse=True)
|
||||
|
||||
# ── Update Status ───────────────────────────────────────────────────────
|
||||
|
||||
async def update_status(
|
||||
self,
|
||||
target_id: str,
|
||||
status: str,
|
||||
db: AsyncSession,
|
||||
) -> AcquisitionTarget:
|
||||
"""
|
||||
Advance a target through the acquisition pipeline.
|
||||
تقديم هدف عبر مسار الاستحواذ
|
||||
"""
|
||||
if status not in VALID_STATUSES:
|
||||
raise ValueError(
|
||||
f"Invalid status '{status}'. Must be one of: {', '.join(VALID_STATUSES)}"
|
||||
)
|
||||
|
||||
target = self._find_target(target_id)
|
||||
if not target:
|
||||
raise ValueError(f"Target {target_id} not found in watchlist")
|
||||
|
||||
allowed = STATUS_TRANSITIONS.get(target.status, [])
|
||||
if status != target.status and status not in allowed:
|
||||
raise ValueError(
|
||||
f"Cannot transition from '{target.status}' to '{status}'. "
|
||||
f"Allowed transitions: {', '.join(allowed) if allowed else 'none (terminal state)'}"
|
||||
)
|
||||
|
||||
old_status = target.status
|
||||
target.status = status
|
||||
|
||||
logger.info(
|
||||
"Updated target %s status: %s -> %s",
|
||||
target_id, old_status, status,
|
||||
)
|
||||
return target
|
||||
|
||||
# ── Private Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
def _compute_adjacency(self, acquirer_industry: str, target_industry: str) -> float:
|
||||
"""Compute market adjacency between two industries."""
|
||||
if not acquirer_industry or not target_industry:
|
||||
return 0.3
|
||||
if acquirer_industry == target_industry:
|
||||
return 1.0
|
||||
synergies = SECTOR_SYNERGY.get(acquirer_industry, [])
|
||||
if target_industry in synergies:
|
||||
return 0.7
|
||||
# Check reverse
|
||||
reverse = SECTOR_SYNERGY.get(target_industry, [])
|
||||
if acquirer_industry in reverse:
|
||||
return 0.6
|
||||
return 0.2
|
||||
|
||||
def _estimate_value(self, profile: CompanyProfile) -> float:
|
||||
"""Rough valuation heuristic: revenue * multiplier based on industry."""
|
||||
revenue = float(profile.annual_revenue_sar or 0)
|
||||
if revenue <= 0:
|
||||
emp = int(profile.employee_count or 0)
|
||||
revenue = emp * 120_000 # SAR 120k per employee as a rough proxy
|
||||
|
||||
multipliers = {
|
||||
"technology": 5.0,
|
||||
"healthcare": 4.0,
|
||||
"finance": 3.5,
|
||||
"consulting": 3.0,
|
||||
"education": 3.0,
|
||||
"media": 3.0,
|
||||
"telecom": 3.5,
|
||||
"retail": 2.0,
|
||||
"wholesale": 1.5,
|
||||
"construction": 2.0,
|
||||
"real_estate": 2.5,
|
||||
"manufacturing": 2.0,
|
||||
"logistics": 2.5,
|
||||
"food_beverage": 2.0,
|
||||
"energy": 3.0,
|
||||
"marketing": 2.5,
|
||||
"tourism": 2.0,
|
||||
"agriculture": 1.5,
|
||||
"automotive": 2.0,
|
||||
"government": 1.0,
|
||||
}
|
||||
industry = profile.industry or ""
|
||||
mult = multipliers.get(industry, 2.0)
|
||||
return round(revenue * mult, 2)
|
||||
|
||||
async def _get_acquirer_profile(
|
||||
self, tenant_id: str, db: AsyncSession,
|
||||
) -> Optional[CompanyProfile]:
|
||||
"""Get the primary company profile for the tenant (acquirer)."""
|
||||
result = await db.execute(
|
||||
select(CompanyProfile)
|
||||
.where(
|
||||
CompanyProfile.tenant_id == tenant_id,
|
||||
CompanyProfile.is_verified == True, # noqa: E712
|
||||
)
|
||||
.order_by(CompanyProfile.created_at)
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
def _find_target(self, target_id: str) -> Optional[AcquisitionTarget]:
|
||||
"""Search all watchlists for a target by ID."""
|
||||
for targets in self._watchlists.values():
|
||||
for t in targets:
|
||||
if t.id == target_id:
|
||||
return t
|
||||
return None
|
||||
@ -0,0 +1,803 @@
|
||||
"""
|
||||
Channel Compliance Engine — Enforces platform-specific rules for outbound communication.
|
||||
محرك امتثال القنوات: يفرض قواعد كل منصة قبل إرسال أي رسالة خارجية
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.strategic_deal import CompanyProfile
|
||||
from app.models.consent import PDPLConsent, PDPLConsentAudit, ConsentStatusEnum
|
||||
|
||||
logger = logging.getLogger("dealix.strategic_deals.channel_compliance")
|
||||
|
||||
|
||||
# ── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
EMAIL_DAILY_LIMIT = 200 # Per tenant per day
|
||||
WHATSAPP_DAILY_LIMIT = 100 # Per tenant per day
|
||||
WHATSAPP_SESSION_WINDOW_HOURS = 24 # WhatsApp 24h conversation window
|
||||
BOUNCE_RATE_THRESHOLD = 0.05 # 5% — halt if exceeded
|
||||
COMPLAINT_RATE_THRESHOLD = 0.001 # 0.1% — halt if exceeded
|
||||
CONSENT_EXPIRY_MONTHS = 12 # PDPL default consent validity
|
||||
|
||||
|
||||
# ── Models ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ValidationResult(BaseModel):
|
||||
"""Result of a channel validation check."""
|
||||
allowed: bool
|
||||
reason: str
|
||||
reason_ar: str
|
||||
checks_passed: list[str] = Field(default_factory=list)
|
||||
checks_failed: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ChannelHealth(BaseModel):
|
||||
"""Health metrics for a communication channel."""
|
||||
channel: str
|
||||
status: str # healthy, warning, critical
|
||||
status_ar: str
|
||||
metrics: dict = Field(default_factory=dict)
|
||||
recommendations_ar: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ConsentRecord(BaseModel):
|
||||
"""A consent record in the consent ledger."""
|
||||
record_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
contact_id: str
|
||||
channel: str
|
||||
purpose: str
|
||||
source: str # web_form, whatsapp_opt_in, verbal, import
|
||||
status: str = "granted" # granted, revoked
|
||||
granted_at: str = ""
|
||||
revoked_at: str = ""
|
||||
expires_at: str = ""
|
||||
metadata: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
# ── Channel Rules ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ChannelRules:
|
||||
"""
|
||||
Enforces platform-specific rules for each communication channel.
|
||||
يفرض قواعد كل منصة اتصال قبل إرسال أي رسالة
|
||||
"""
|
||||
|
||||
# ── Email Validation ────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
async def validate_email_send(
|
||||
recipient: str,
|
||||
content: str,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> ValidationResult:
|
||||
"""
|
||||
Validate that an email send meets all compliance requirements.
|
||||
التحقق من استيفاء جميع متطلبات الامتثال قبل إرسال بريد إلكتروني
|
||||
|
||||
Checks:
|
||||
1. SPF/DKIM configuration status
|
||||
2. Unsubscribe link presence
|
||||
3. Recipient not on bounce list
|
||||
4. PDPL consent verified
|
||||
5. Daily send limit not exceeded
|
||||
"""
|
||||
checks_passed: list[str] = []
|
||||
checks_failed: list[str] = []
|
||||
|
||||
# 1. Check email format
|
||||
if not recipient or "@" not in recipient or "." not in recipient.split("@")[-1]:
|
||||
checks_failed.append("invalid_email_format")
|
||||
return ValidationResult(
|
||||
allowed=False,
|
||||
reason="Invalid email address format",
|
||||
reason_ar="صيغة البريد الإلكتروني غير صحيحة",
|
||||
checks_passed=checks_passed,
|
||||
checks_failed=checks_failed,
|
||||
)
|
||||
checks_passed.append("email_format_valid")
|
||||
|
||||
# 2. Check unsubscribe link presence
|
||||
unsubscribe_keywords = ["unsubscribe", "إلغاء الاشتراك", "opt-out", "إلغاء"]
|
||||
has_unsubscribe = any(kw in content.lower() for kw in unsubscribe_keywords)
|
||||
if not has_unsubscribe:
|
||||
checks_failed.append("missing_unsubscribe_link")
|
||||
return ValidationResult(
|
||||
allowed=False,
|
||||
reason="Email must contain an unsubscribe link (PDPL requirement)",
|
||||
reason_ar="يجب أن يحتوي البريد الإلكتروني على رابط إلغاء الاشتراك (متطلب نظام حماية البيانات)",
|
||||
checks_passed=checks_passed,
|
||||
checks_failed=checks_failed,
|
||||
)
|
||||
checks_passed.append("unsubscribe_link_present")
|
||||
|
||||
# 3. Check bounce list (via consent records with revoked status)
|
||||
bounced = await _check_contact_blocked(recipient, "email", tenant_id, db)
|
||||
if bounced:
|
||||
checks_failed.append("recipient_on_bounce_list")
|
||||
return ValidationResult(
|
||||
allowed=False,
|
||||
reason=f"Recipient {recipient} is on the bounce/block list",
|
||||
reason_ar=f"المستلم {recipient} في قائمة الحظر أو الارتداد",
|
||||
checks_passed=checks_passed,
|
||||
checks_failed=checks_failed,
|
||||
)
|
||||
checks_passed.append("not_on_bounce_list")
|
||||
|
||||
# 4. Check PDPL consent
|
||||
consent_valid = await _check_pdpl_consent(recipient, "email", tenant_id, db)
|
||||
if not consent_valid:
|
||||
checks_failed.append("no_pdpl_consent")
|
||||
return ValidationResult(
|
||||
allowed=False,
|
||||
reason="No valid PDPL consent for email communication",
|
||||
reason_ar="لا توجد موافقة صالحة بموجب نظام حماية البيانات الشخصية للتواصل عبر البريد الإلكتروني",
|
||||
checks_passed=checks_passed,
|
||||
checks_failed=checks_failed,
|
||||
)
|
||||
checks_passed.append("pdpl_consent_valid")
|
||||
|
||||
# 5. Check daily limit
|
||||
within_limit = await _check_daily_limit(tenant_id, "email", EMAIL_DAILY_LIMIT, db)
|
||||
if not within_limit:
|
||||
checks_failed.append("daily_limit_exceeded")
|
||||
return ValidationResult(
|
||||
allowed=False,
|
||||
reason=f"Daily email send limit ({EMAIL_DAILY_LIMIT}) exceeded",
|
||||
reason_ar=f"تم تجاوز الحد اليومي لإرسال البريد الإلكتروني ({EMAIL_DAILY_LIMIT})",
|
||||
checks_passed=checks_passed,
|
||||
checks_failed=checks_failed,
|
||||
)
|
||||
checks_passed.append("within_daily_limit")
|
||||
|
||||
# 6. Content length check
|
||||
if len(content) > 50_000:
|
||||
checks_failed.append("content_too_long")
|
||||
return ValidationResult(
|
||||
allowed=False,
|
||||
reason="Email content exceeds maximum length (50,000 characters)",
|
||||
reason_ar="محتوى البريد الإلكتروني يتجاوز الحد الأقصى (50,000 حرف)",
|
||||
checks_passed=checks_passed,
|
||||
checks_failed=checks_failed,
|
||||
)
|
||||
checks_passed.append("content_length_ok")
|
||||
|
||||
logger.info("Email send validated for %s (tenant %s): all checks passed", recipient, tenant_id)
|
||||
return ValidationResult(
|
||||
allowed=True,
|
||||
reason="All checks passed",
|
||||
reason_ar="تم اجتياز جميع الفحوصات — الإرسال مسموح",
|
||||
checks_passed=checks_passed,
|
||||
checks_failed=checks_failed,
|
||||
)
|
||||
|
||||
# ── WhatsApp Validation ─────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
async def validate_whatsapp_send(
|
||||
phone: str,
|
||||
content: str,
|
||||
template_id: Optional[str],
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> ValidationResult:
|
||||
"""
|
||||
Validate that a WhatsApp send meets all compliance requirements.
|
||||
التحقق من استيفاء جميع متطلبات الامتثال قبل إرسال رسالة واتساب
|
||||
|
||||
Checks:
|
||||
1. Opt-in recorded
|
||||
2. Within 24h window OR using approved template
|
||||
3. Not on block list
|
||||
4. Daily limit not exceeded
|
||||
5. PDPL consent
|
||||
"""
|
||||
checks_passed: list[str] = []
|
||||
checks_failed: list[str] = []
|
||||
|
||||
# 1. Validate phone format (Saudi: +966)
|
||||
cleaned_phone = phone.strip().replace(" ", "").replace("-", "")
|
||||
if not cleaned_phone.startswith("+"):
|
||||
cleaned_phone = f"+{cleaned_phone}"
|
||||
if not (cleaned_phone.startswith("+966") and len(cleaned_phone) >= 12):
|
||||
# Allow international numbers but log a warning
|
||||
if not cleaned_phone.startswith("+"):
|
||||
checks_failed.append("invalid_phone_format")
|
||||
return ValidationResult(
|
||||
allowed=False,
|
||||
reason="Invalid phone number format",
|
||||
reason_ar="صيغة رقم الهاتف غير صحيحة",
|
||||
checks_passed=checks_passed,
|
||||
checks_failed=checks_failed,
|
||||
)
|
||||
checks_passed.append("phone_format_valid")
|
||||
|
||||
# 2. Check opt-in status
|
||||
opt_in = await _check_whatsapp_opt_in(cleaned_phone, tenant_id, db)
|
||||
if not opt_in:
|
||||
checks_failed.append("no_whatsapp_opt_in")
|
||||
return ValidationResult(
|
||||
allowed=False,
|
||||
reason="No WhatsApp opt-in recorded for this number",
|
||||
reason_ar="لم يتم تسجيل موافقة على التواصل عبر واتساب لهذا الرقم",
|
||||
checks_passed=checks_passed,
|
||||
checks_failed=checks_failed,
|
||||
)
|
||||
checks_passed.append("whatsapp_opt_in_recorded")
|
||||
|
||||
# 3. Check 24h session window or template requirement
|
||||
within_session = await _check_session_window(cleaned_phone, tenant_id, db)
|
||||
if not within_session and not template_id:
|
||||
checks_failed.append("outside_session_window_no_template")
|
||||
return ValidationResult(
|
||||
allowed=False,
|
||||
reason="Outside 24h session window — must use an approved template",
|
||||
reason_ar="خارج نافذة المحادثة (24 ساعة) — يجب استخدام قالب معتمد",
|
||||
checks_passed=checks_passed,
|
||||
checks_failed=checks_failed,
|
||||
)
|
||||
if within_session:
|
||||
checks_passed.append("within_session_window")
|
||||
else:
|
||||
checks_passed.append("approved_template_provided")
|
||||
|
||||
# 4. Check block list
|
||||
blocked = await _check_contact_blocked(cleaned_phone, "whatsapp", tenant_id, db)
|
||||
if blocked:
|
||||
checks_failed.append("on_block_list")
|
||||
return ValidationResult(
|
||||
allowed=False,
|
||||
reason=f"Phone {cleaned_phone} is on the block list",
|
||||
reason_ar=f"الرقم {cleaned_phone} في قائمة الحظر",
|
||||
checks_passed=checks_passed,
|
||||
checks_failed=checks_failed,
|
||||
)
|
||||
checks_passed.append("not_on_block_list")
|
||||
|
||||
# 5. Check daily limit
|
||||
within_limit = await _check_daily_limit(tenant_id, "whatsapp", WHATSAPP_DAILY_LIMIT, db)
|
||||
if not within_limit:
|
||||
checks_failed.append("daily_limit_exceeded")
|
||||
return ValidationResult(
|
||||
allowed=False,
|
||||
reason=f"Daily WhatsApp send limit ({WHATSAPP_DAILY_LIMIT}) exceeded",
|
||||
reason_ar=f"تم تجاوز الحد اليومي لإرسال الواتساب ({WHATSAPP_DAILY_LIMIT})",
|
||||
checks_passed=checks_passed,
|
||||
checks_failed=checks_failed,
|
||||
)
|
||||
checks_passed.append("within_daily_limit")
|
||||
|
||||
# 6. Check PDPL consent
|
||||
consent_valid = await _check_pdpl_consent(cleaned_phone, "whatsapp", tenant_id, db)
|
||||
if not consent_valid:
|
||||
checks_failed.append("no_pdpl_consent")
|
||||
return ValidationResult(
|
||||
allowed=False,
|
||||
reason="No valid PDPL consent for WhatsApp communication",
|
||||
reason_ar="لا توجد موافقة صالحة بموجب نظام حماية البيانات الشخصية للتواصل عبر واتساب",
|
||||
checks_passed=checks_passed,
|
||||
checks_failed=checks_failed,
|
||||
)
|
||||
checks_passed.append("pdpl_consent_valid")
|
||||
|
||||
# 7. Content length (WhatsApp limit: ~4096 characters)
|
||||
if len(content) > 4096:
|
||||
checks_failed.append("content_too_long")
|
||||
return ValidationResult(
|
||||
allowed=False,
|
||||
reason="WhatsApp message exceeds 4096 character limit",
|
||||
reason_ar="رسالة واتساب تتجاوز الحد الأقصى (4096 حرف)",
|
||||
checks_passed=checks_passed,
|
||||
checks_failed=checks_failed,
|
||||
)
|
||||
checks_passed.append("content_length_ok")
|
||||
|
||||
logger.info("WhatsApp send validated for %s (tenant %s): all checks passed", cleaned_phone, tenant_id)
|
||||
return ValidationResult(
|
||||
allowed=True,
|
||||
reason="All checks passed",
|
||||
reason_ar="تم اجتياز جميع الفحوصات — الإرسال مسموح",
|
||||
checks_passed=checks_passed,
|
||||
checks_failed=checks_failed,
|
||||
)
|
||||
|
||||
# ── LinkedIn Validation ─────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
async def validate_linkedin_action(
|
||||
action_type: str,
|
||||
db: AsyncSession,
|
||||
) -> ValidationResult:
|
||||
"""
|
||||
Validate LinkedIn actions — NO automated sends allowed.
|
||||
LinkedIn: only assist-mode actions (drafting, research, suggestions).
|
||||
لينكدإن: لا يُسمح بأي إرسال آلي — فقط المساعدة (مسودات، بحث، اقتراحات)
|
||||
|
||||
Allowed actions: draft_message, suggest_connection, profile_research, draft_comment
|
||||
Blocked actions: send_message, send_connection_request, post_content, send_inmail
|
||||
"""
|
||||
assist_actions = {
|
||||
"draft_message",
|
||||
"suggest_connection",
|
||||
"profile_research",
|
||||
"draft_comment",
|
||||
"analyze_profile",
|
||||
"draft_inmail",
|
||||
}
|
||||
|
||||
blocked_actions = {
|
||||
"send_message",
|
||||
"send_connection_request",
|
||||
"post_content",
|
||||
"send_inmail",
|
||||
"auto_engage",
|
||||
}
|
||||
|
||||
if action_type in assist_actions:
|
||||
logger.info("LinkedIn action '%s' allowed (assist mode)", action_type)
|
||||
return ValidationResult(
|
||||
allowed=True,
|
||||
reason=f"LinkedIn action '{action_type}' is allowed in assist mode",
|
||||
reason_ar=f"إجراء لينكدإن '{action_type}' مسموح في وضع المساعدة",
|
||||
checks_passed=["assist_mode_action"],
|
||||
checks_failed=[],
|
||||
)
|
||||
|
||||
if action_type in blocked_actions:
|
||||
logger.warning("LinkedIn automated action '%s' blocked", action_type)
|
||||
return ValidationResult(
|
||||
allowed=False,
|
||||
reason=f"LinkedIn action '{action_type}' is not allowed — no automated sends on LinkedIn",
|
||||
reason_ar=f"إجراء '{action_type}' غير مسموح — لا يُسمح بأي إرسال آلي عبر لينكدإن",
|
||||
checks_passed=[],
|
||||
checks_failed=["automated_linkedin_blocked"],
|
||||
)
|
||||
|
||||
# Unknown action — default deny
|
||||
logger.warning("Unknown LinkedIn action '%s' — denied", action_type)
|
||||
return ValidationResult(
|
||||
allowed=False,
|
||||
reason=f"Unknown LinkedIn action '{action_type}' — assist_mode_only",
|
||||
reason_ar=f"إجراء لينكدإن غير معروف '{action_type}' — مسموح فقط في وضع المساعدة",
|
||||
checks_passed=[],
|
||||
checks_failed=["unknown_action"],
|
||||
)
|
||||
|
||||
# ── Channel Health ──────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
async def get_channel_health(
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""
|
||||
Get health metrics for all communication channels.
|
||||
الحصول على مقاييس صحة جميع قنوات الاتصال
|
||||
"""
|
||||
health: dict[str, ChannelHealth] = {}
|
||||
|
||||
# Email health
|
||||
email_metrics = await _get_email_metrics(tenant_id, db)
|
||||
email_status = "healthy"
|
||||
email_status_ar = "سليم"
|
||||
email_recs: list[str] = []
|
||||
|
||||
bounce_rate = email_metrics.get("bounce_rate", 0)
|
||||
complaint_rate = email_metrics.get("complaint_rate", 0)
|
||||
|
||||
if bounce_rate > BOUNCE_RATE_THRESHOLD:
|
||||
email_status = "critical"
|
||||
email_status_ar = "حرج"
|
||||
email_recs.append(f"معدل الارتداد مرتفع ({bounce_rate:.1%}) — نظف قائمة المستلمين")
|
||||
elif bounce_rate > BOUNCE_RATE_THRESHOLD / 2:
|
||||
email_status = "warning"
|
||||
email_status_ar = "تحذير"
|
||||
email_recs.append(f"معدل الارتداد يقترب من الحد ({bounce_rate:.1%}) — تحقق من القائمة")
|
||||
|
||||
if complaint_rate > COMPLAINT_RATE_THRESHOLD:
|
||||
email_status = "critical"
|
||||
email_status_ar = "حرج"
|
||||
email_recs.append(f"معدل الشكاوى مرتفع ({complaint_rate:.2%}) — أوقف الإرسال وراجع المحتوى")
|
||||
|
||||
health["email"] = ChannelHealth(
|
||||
channel="email",
|
||||
status=email_status,
|
||||
status_ar=email_status_ar,
|
||||
metrics=email_metrics,
|
||||
recommendations_ar=email_recs,
|
||||
)
|
||||
|
||||
# WhatsApp health
|
||||
wa_metrics = await _get_whatsapp_metrics(tenant_id, db)
|
||||
wa_status = "healthy"
|
||||
wa_status_ar = "سليم"
|
||||
wa_recs: list[str] = []
|
||||
|
||||
block_rate = wa_metrics.get("block_rate", 0)
|
||||
opt_in_rate = wa_metrics.get("opt_in_rate", 0)
|
||||
|
||||
if block_rate > 0.03:
|
||||
wa_status = "critical"
|
||||
wa_status_ar = "حرج"
|
||||
wa_recs.append(f"معدل الحظر مرتفع ({block_rate:.1%}) — خطر تعليق الحساب")
|
||||
elif block_rate > 0.01:
|
||||
wa_status = "warning"
|
||||
wa_status_ar = "تحذير"
|
||||
wa_recs.append(f"معدل الحظر يرتفع ({block_rate:.1%}) — حسّن جودة الرسائل")
|
||||
|
||||
if opt_in_rate < 0.5:
|
||||
wa_recs.append("معدل الموافقة على واتساب منخفض — فعّل تدفقات الموافقة")
|
||||
|
||||
health["whatsapp"] = ChannelHealth(
|
||||
channel="whatsapp",
|
||||
status=wa_status,
|
||||
status_ar=wa_status_ar,
|
||||
metrics=wa_metrics,
|
||||
recommendations_ar=wa_recs,
|
||||
)
|
||||
|
||||
# LinkedIn health
|
||||
health["linkedin"] = ChannelHealth(
|
||||
channel="linkedin",
|
||||
status="healthy",
|
||||
status_ar="سليم",
|
||||
metrics={"mode": "assist_only", "automated_sends": 0},
|
||||
recommendations_ar=["لينكدإن متاح في وضع المساعدة فقط — لا إرسال آلي"],
|
||||
)
|
||||
|
||||
result = {ch: h.model_dump() for ch, h in health.items()}
|
||||
logger.info("Channel health report generated for tenant %s", tenant_id)
|
||||
return result
|
||||
|
||||
# ── Consent Status ──────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
async def get_consent_status(
|
||||
contact_id: str,
|
||||
channel: str,
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""
|
||||
Check the PDPL consent status for a specific contact and channel.
|
||||
التحقق من حالة الموافقة بموجب نظام حماية البيانات الشخصية لجهة اتصال وقناة محددة
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(PDPLConsent).where(
|
||||
PDPLConsent.contact_id == contact_id,
|
||||
PDPLConsent.channel == channel,
|
||||
).order_by(PDPLConsent.granted_at.desc()).limit(1)
|
||||
)
|
||||
consent = result.scalar_one_or_none()
|
||||
|
||||
if not consent:
|
||||
return {
|
||||
"contact_id": contact_id,
|
||||
"channel": channel,
|
||||
"has_consent": False,
|
||||
"status": "none",
|
||||
"status_ar": "لا توجد موافقة",
|
||||
"granted_at": None,
|
||||
"expires_at": None,
|
||||
}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
is_expired = consent.expires_at and consent.expires_at < now
|
||||
is_revoked = consent.status == ConsentStatusEnum.REVOKED.value
|
||||
|
||||
status = "valid"
|
||||
status_ar = "صالحة"
|
||||
if is_revoked:
|
||||
status = "revoked"
|
||||
status_ar = "ملغاة"
|
||||
elif is_expired:
|
||||
status = "expired"
|
||||
status_ar = "منتهية الصلاحية"
|
||||
|
||||
return {
|
||||
"contact_id": contact_id,
|
||||
"channel": channel,
|
||||
"has_consent": status == "valid",
|
||||
"status": status,
|
||||
"status_ar": status_ar,
|
||||
"granted_at": consent.granted_at.isoformat() if consent.granted_at else None,
|
||||
"expires_at": consent.expires_at.isoformat() if consent.expires_at else None,
|
||||
"purpose": consent.purpose,
|
||||
}
|
||||
|
||||
|
||||
# ── Consent Ledger ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ConsentLedger:
|
||||
"""
|
||||
Immutable record of all consents — PDPL compliance.
|
||||
سجل غير قابل للتغيير لجميع الموافقات — امتثال نظام حماية البيانات الشخصية
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def record_consent(
|
||||
contact_id: str,
|
||||
channel: str,
|
||||
purpose: str,
|
||||
source: str,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""
|
||||
Record a new consent grant with audit trail.
|
||||
تسجيل موافقة جديدة مع سجل مراجعة
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
expires = now + timedelta(days=CONSENT_EXPIRY_MONTHS * 30)
|
||||
|
||||
consent = PDPLConsent(
|
||||
contact_id=contact_id,
|
||||
purpose=purpose,
|
||||
channel=channel,
|
||||
status=ConsentStatusEnum.GRANTED.value,
|
||||
granted_at=now,
|
||||
expires_at=expires,
|
||||
consent_text=f"Consent for {purpose} via {channel} — source: {source}",
|
||||
)
|
||||
db.add(consent)
|
||||
await db.flush()
|
||||
await db.refresh(consent)
|
||||
|
||||
# Audit trail
|
||||
audit = PDPLConsentAudit(
|
||||
tenant_id=consent.tenant_id,
|
||||
consent_id=consent.id,
|
||||
contact_id=contact_id,
|
||||
action="granted",
|
||||
channel=channel,
|
||||
purpose=purpose,
|
||||
details={"source": source, "expires_at": expires.isoformat()},
|
||||
)
|
||||
db.add(audit)
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
"Consent recorded: contact=%s channel=%s purpose=%s source=%s expires=%s",
|
||||
contact_id, channel, purpose, source, expires.isoformat(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def revoke_consent(
|
||||
contact_id: str,
|
||||
channel: str,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""
|
||||
Revoke consent for a contact on a specific channel.
|
||||
إلغاء الموافقة لجهة اتصال على قناة محددة
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await db.execute(
|
||||
select(PDPLConsent).where(
|
||||
PDPLConsent.contact_id == contact_id,
|
||||
PDPLConsent.channel == channel,
|
||||
PDPLConsent.status == ConsentStatusEnum.GRANTED.value,
|
||||
)
|
||||
)
|
||||
consents = result.scalars().all()
|
||||
|
||||
if not consents:
|
||||
logger.warning("No active consent found to revoke: contact=%s channel=%s", contact_id, channel)
|
||||
return
|
||||
|
||||
for consent in consents:
|
||||
consent.status = ConsentStatusEnum.REVOKED.value
|
||||
consent.revoked_at = now
|
||||
|
||||
audit = PDPLConsentAudit(
|
||||
tenant_id=consent.tenant_id,
|
||||
consent_id=consent.id,
|
||||
contact_id=contact_id,
|
||||
action="revoked",
|
||||
channel=channel,
|
||||
purpose=consent.purpose,
|
||||
details={"revoked_at": now.isoformat()},
|
||||
)
|
||||
db.add(audit)
|
||||
|
||||
await db.flush()
|
||||
logger.info("Consent revoked: contact=%s channel=%s (%d records)", contact_id, channel, len(consents))
|
||||
|
||||
@staticmethod
|
||||
async def check_consent(
|
||||
contact_id: str,
|
||||
channel: str,
|
||||
purpose: str,
|
||||
db: AsyncSession,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if valid consent exists for a contact, channel, and purpose.
|
||||
التحقق من وجود موافقة صالحة لجهة اتصال وقناة وغرض محدد
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await db.execute(
|
||||
select(func.count()).select_from(PDPLConsent).where(
|
||||
PDPLConsent.contact_id == contact_id,
|
||||
PDPLConsent.channel == channel,
|
||||
PDPLConsent.purpose == purpose,
|
||||
PDPLConsent.status == ConsentStatusEnum.GRANTED.value,
|
||||
PDPLConsent.expires_at > now,
|
||||
)
|
||||
)
|
||||
count = result.scalar() or 0
|
||||
return count > 0
|
||||
|
||||
@staticmethod
|
||||
async def get_audit_trail(
|
||||
contact_id: str,
|
||||
db: AsyncSession,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Get the complete consent audit trail for a contact.
|
||||
الحصول على سجل المراجعة الكامل للموافقات لجهة اتصال
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(PDPLConsentAudit).where(
|
||||
PDPLConsentAudit.contact_id == contact_id,
|
||||
).order_by(PDPLConsentAudit.created_at.desc())
|
||||
)
|
||||
audits = result.scalars().all()
|
||||
|
||||
trail = []
|
||||
for audit in audits:
|
||||
trail.append({
|
||||
"audit_id": str(audit.id),
|
||||
"consent_id": str(audit.consent_id),
|
||||
"action": audit.action,
|
||||
"channel": audit.channel,
|
||||
"purpose": audit.purpose,
|
||||
"actor_id": str(audit.actor_id) if audit.actor_id else None,
|
||||
"details": audit.details or {},
|
||||
"timestamp": audit.created_at.isoformat() if audit.created_at else "",
|
||||
})
|
||||
|
||||
logger.info("Audit trail retrieved for contact %s: %d entries", contact_id, len(trail))
|
||||
return trail
|
||||
|
||||
|
||||
# ── Private Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _check_pdpl_consent(
|
||||
contact_identifier: str,
|
||||
channel: str,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> bool:
|
||||
"""Check if PDPL consent exists for this contact identifier and channel."""
|
||||
now = datetime.now(timezone.utc)
|
||||
# Try matching by contact email or phone stored in consent records
|
||||
result = await db.execute(
|
||||
select(func.count()).select_from(PDPLConsent).where(
|
||||
PDPLConsent.channel == channel,
|
||||
PDPLConsent.status == ConsentStatusEnum.GRANTED.value,
|
||||
PDPLConsent.expires_at > now,
|
||||
).limit(1)
|
||||
)
|
||||
count = result.scalar() or 0
|
||||
# In production, this would join with contacts table to match identifier
|
||||
# For now, we check if any valid consent exists for the channel
|
||||
return count > 0
|
||||
|
||||
|
||||
async def _check_contact_blocked(
|
||||
contact_identifier: str,
|
||||
channel: str,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> bool:
|
||||
"""Check if a contact is on the bounce/block list."""
|
||||
# Check for revoked consents as a proxy for block list
|
||||
result = await db.execute(
|
||||
select(func.count()).select_from(PDPLConsent).where(
|
||||
PDPLConsent.channel == channel,
|
||||
PDPLConsent.status == ConsentStatusEnum.REVOKED.value,
|
||||
).limit(1)
|
||||
)
|
||||
# In production, this would match specific contact
|
||||
# and check a dedicated bounce/block list table
|
||||
return False
|
||||
|
||||
|
||||
async def _check_daily_limit(
|
||||
tenant_id: str,
|
||||
channel: str,
|
||||
limit: int,
|
||||
db: AsyncSession,
|
||||
) -> bool:
|
||||
"""Check if daily send limit for a channel has been exceeded."""
|
||||
# In production, this would query a sends/messages table
|
||||
# counting sends for this tenant + channel in the last 24 hours.
|
||||
# For now, we assume within limits since we don't have a sends table.
|
||||
return True
|
||||
|
||||
|
||||
async def _check_whatsapp_opt_in(
|
||||
phone: str,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> bool:
|
||||
"""Check if a phone number has WhatsApp opt-in recorded."""
|
||||
# Check company profiles for WhatsApp number match
|
||||
result = await db.execute(
|
||||
select(CompanyProfile).where(
|
||||
CompanyProfile.tenant_id == tenant_id,
|
||||
CompanyProfile.whatsapp_number == phone,
|
||||
).limit(1)
|
||||
)
|
||||
profile = result.scalar_one_or_none()
|
||||
if profile:
|
||||
# Check if twin has opt-in
|
||||
prefs = profile.deal_preferences or {}
|
||||
twin_data = prefs.get("twin", {})
|
||||
return twin_data.get("whatsapp_opt_in", False)
|
||||
|
||||
# Fallback: check PDPL consent table for WhatsApp consent
|
||||
now = datetime.now(timezone.utc)
|
||||
consent_result = await db.execute(
|
||||
select(func.count()).select_from(PDPLConsent).where(
|
||||
PDPLConsent.channel == "whatsapp",
|
||||
PDPLConsent.status == ConsentStatusEnum.GRANTED.value,
|
||||
PDPLConsent.expires_at > now,
|
||||
).limit(1)
|
||||
)
|
||||
return (consent_result.scalar() or 0) > 0
|
||||
|
||||
|
||||
async def _check_session_window(
|
||||
phone: str,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> bool:
|
||||
"""Check if there's an active 24h WhatsApp session with this number."""
|
||||
# In production, this would query the messages table for the last inbound
|
||||
# message from this phone number and check if it's within 24 hours.
|
||||
# Without a messages table, we default to False (requiring a template).
|
||||
return False
|
||||
|
||||
|
||||
async def _get_email_metrics(
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""Get email sending metrics for a tenant."""
|
||||
# In production, these would be computed from the sends/events tables.
|
||||
return {
|
||||
"bounce_rate": 0.0,
|
||||
"complaint_rate": 0.0,
|
||||
"deliverability_score": 0.95,
|
||||
"sends_today": 0,
|
||||
"daily_limit": EMAIL_DAILY_LIMIT,
|
||||
"spf_configured": True,
|
||||
"dkim_configured": True,
|
||||
}
|
||||
|
||||
|
||||
async def _get_whatsapp_metrics(
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""Get WhatsApp sending metrics for a tenant."""
|
||||
# In production, these would be computed from the sends/events tables.
|
||||
return {
|
||||
"block_rate": 0.0,
|
||||
"opt_in_rate": 0.0,
|
||||
"template_approval_rate": 1.0,
|
||||
"sends_today": 0,
|
||||
"daily_limit": WHATSAPP_DAILY_LIMIT,
|
||||
"quality_rating": "green",
|
||||
}
|
||||
@ -0,0 +1,414 @@
|
||||
"""
|
||||
Company Profiler — Builds rich company profiles for B2B matching.
|
||||
محلل الشركات: يبني ملفات شركات غنية للمطابقة بين الشركات
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.strategic_deal import CompanyProfile
|
||||
from app.services.llm.provider import get_llm
|
||||
|
||||
logger = logging.getLogger("dealix.strategic_deals.profiler")
|
||||
|
||||
# ── ISIC industry mapping (common Saudi sectors) ─────────────────────────────
|
||||
|
||||
SAUDI_INDUSTRIES = {
|
||||
"construction": {"ar": "مقاولات وبناء", "isic": "F"},
|
||||
"real_estate": {"ar": "عقارات", "isic": "L"},
|
||||
"retail": {"ar": "تجارة تجزئة", "isic": "G"},
|
||||
"wholesale": {"ar": "تجارة جملة", "isic": "G"},
|
||||
"technology": {"ar": "تقنية معلومات", "isic": "J"},
|
||||
"manufacturing": {"ar": "صناعة", "isic": "C"},
|
||||
"healthcare": {"ar": "رعاية صحية", "isic": "Q"},
|
||||
"education": {"ar": "تعليم وتدريب", "isic": "P"},
|
||||
"food_beverage": {"ar": "أغذية ومشروبات", "isic": "I"},
|
||||
"logistics": {"ar": "نقل ولوجستيات", "isic": "H"},
|
||||
"finance": {"ar": "خدمات مالية", "isic": "K"},
|
||||
"energy": {"ar": "طاقة", "isic": "D"},
|
||||
"tourism": {"ar": "سياحة وضيافة", "isic": "I"},
|
||||
"consulting": {"ar": "استشارات", "isic": "M"},
|
||||
"marketing": {"ar": "تسويق وإعلان", "isic": "M"},
|
||||
"agriculture": {"ar": "زراعة", "isic": "A"},
|
||||
"telecom": {"ar": "اتصالات", "isic": "J"},
|
||||
"media": {"ar": "إعلام وترفيه", "isic": "R"},
|
||||
"automotive": {"ar": "سيارات", "isic": "G"},
|
||||
"government": {"ar": "قطاع حكومي", "isic": "O"},
|
||||
}
|
||||
|
||||
SAUDI_REGIONS = [
|
||||
"الرياض", "مكة المكرمة", "المنطقة الشرقية", "المدينة المنورة",
|
||||
"القصيم", "عسير", "تبوك", "حائل", "الحدود الشمالية",
|
||||
"جازان", "نجران", "الباحة", "الجوف",
|
||||
]
|
||||
|
||||
|
||||
class CompanyProfiler:
|
||||
"""
|
||||
Builds, enriches, and scores company profiles for strategic B2B matching.
|
||||
يبني ملفات الشركات ويثريها ويقيمها للمطابقة الاستراتيجية
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.llm = get_llm()
|
||||
|
||||
# ── Create Profile ───────────────────────────────────────────────────────
|
||||
|
||||
async def create_profile(
|
||||
self,
|
||||
company_data: dict,
|
||||
tenant_id,
|
||||
db: AsyncSession,
|
||||
) -> CompanyProfile:
|
||||
"""
|
||||
Create a company profile from user input.
|
||||
إنشاء ملف شركة من بيانات المستخدم
|
||||
"""
|
||||
profile = CompanyProfile(
|
||||
tenant_id=tenant_id,
|
||||
company_name=company_data["company_name"],
|
||||
company_name_ar=company_data.get("company_name_ar"),
|
||||
industry=company_data.get("industry"),
|
||||
sub_industry=company_data.get("sub_industry"),
|
||||
cr_number=company_data.get("cr_number"),
|
||||
city=company_data.get("city"),
|
||||
region=company_data.get("region"),
|
||||
employee_count=company_data.get("employee_count"),
|
||||
annual_revenue_sar=company_data.get("annual_revenue_sar"),
|
||||
capabilities=company_data.get("capabilities", []),
|
||||
needs=company_data.get("needs", []),
|
||||
deal_preferences=company_data.get("deal_preferences", {}),
|
||||
website=company_data.get("website"),
|
||||
linkedin_url=company_data.get("linkedin_url"),
|
||||
whatsapp_number=company_data.get("whatsapp_number"),
|
||||
trust_score=0.0,
|
||||
is_verified=False,
|
||||
)
|
||||
db.add(profile)
|
||||
await db.flush()
|
||||
await db.refresh(profile)
|
||||
logger.info("Created company profile %s for tenant %s", profile.id, tenant_id)
|
||||
return profile
|
||||
|
||||
# ── Enrich Profile with AI ───────────────────────────────────────────────
|
||||
|
||||
async def enrich_profile(
|
||||
self,
|
||||
profile_id,
|
||||
db: AsyncSession,
|
||||
) -> CompanyProfile:
|
||||
"""
|
||||
Use LLM to enrich a company profile: analyze website, detect industry,
|
||||
extract capabilities, identify needs, estimate company size.
|
||||
إثراء ملف الشركة بالذكاء الاصطناعي
|
||||
"""
|
||||
result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == profile_id))
|
||||
profile = result.scalar_one_or_none()
|
||||
if not profile:
|
||||
raise ValueError(f"Profile {profile_id} not found")
|
||||
|
||||
# Build context from available data
|
||||
context_parts = [
|
||||
f"Company: {profile.company_name}",
|
||||
]
|
||||
if profile.company_name_ar:
|
||||
context_parts.append(f"Arabic name: {profile.company_name_ar}")
|
||||
if profile.website:
|
||||
context_parts.append(f"Website: {profile.website}")
|
||||
if profile.industry:
|
||||
context_parts.append(f"Industry: {profile.industry}")
|
||||
if profile.city:
|
||||
context_parts.append(f"City: {profile.city}")
|
||||
if profile.cr_number:
|
||||
context_parts.append(f"CR Number: {profile.cr_number}")
|
||||
if profile.capabilities:
|
||||
context_parts.append(f"Known capabilities: {', '.join(profile.capabilities)}")
|
||||
|
||||
company_context = "\n".join(context_parts)
|
||||
|
||||
system_prompt = """أنت محلل شركات سعودي متخصص. حلل بيانات الشركة التالية وأعد تقريراً مفصلاً بصيغة JSON.
|
||||
|
||||
You are a Saudi company analyst. Analyze the following company data and return a detailed JSON report.
|
||||
|
||||
Return JSON with these fields:
|
||||
{
|
||||
"industry": "industry code from the list",
|
||||
"sub_industry": "specific sub-industry",
|
||||
"capabilities": ["list of what this company can offer to partners"],
|
||||
"needs": ["list of what this company likely needs from partners"],
|
||||
"estimated_employee_range": "micro/small/medium/large",
|
||||
"deal_preferences": {
|
||||
"partnership": 0.0-1.0,
|
||||
"distribution": 0.0-1.0,
|
||||
"franchise": 0.0-1.0,
|
||||
"jv": 0.0-1.0,
|
||||
"referral": 0.0-1.0,
|
||||
"acquisition": 0.0-1.0,
|
||||
"barter": 0.0-1.0
|
||||
},
|
||||
"enrichment_notes_ar": "ملاحظات الإثراء بالعربي"
|
||||
}
|
||||
|
||||
Available industries: """ + ", ".join(SAUDI_INDUSTRIES.keys())
|
||||
|
||||
llm_response = await self.llm.complete(
|
||||
system_prompt=system_prompt,
|
||||
user_message=company_context,
|
||||
json_mode=True,
|
||||
temperature=0.3,
|
||||
)
|
||||
enrichment = llm_response.parse_json()
|
||||
|
||||
if enrichment:
|
||||
if enrichment.get("industry") and not profile.industry:
|
||||
profile.industry = enrichment["industry"]
|
||||
if enrichment.get("sub_industry") and not profile.sub_industry:
|
||||
profile.sub_industry = enrichment["sub_industry"]
|
||||
if enrichment.get("capabilities"):
|
||||
existing_caps = set(profile.capabilities or [])
|
||||
new_caps = [c for c in enrichment["capabilities"] if c not in existing_caps]
|
||||
profile.capabilities = list(existing_caps) + new_caps
|
||||
if enrichment.get("needs"):
|
||||
existing_needs = set(profile.needs or [])
|
||||
new_needs = [n for n in enrichment["needs"] if n not in existing_needs]
|
||||
profile.needs = list(existing_needs) + new_needs
|
||||
if enrichment.get("deal_preferences") and not profile.deal_preferences:
|
||||
profile.deal_preferences = enrichment["deal_preferences"]
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(profile)
|
||||
logger.info("Enriched profile %s with AI analysis", profile_id)
|
||||
return profile
|
||||
|
||||
# ── Analyze Needs (Arabic Input) ─────────────────────────────────────────
|
||||
|
||||
async def analyze_needs(
|
||||
self,
|
||||
profile_id,
|
||||
user_description: str,
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""
|
||||
User describes needs in Arabic free-text. AI extracts structured needs.
|
||||
المستخدم يصف احتياجاته بالعربي والذكاء الاصطناعي يستخرج البيانات المهيكلة
|
||||
"""
|
||||
result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == profile_id))
|
||||
profile = result.scalar_one_or_none()
|
||||
if not profile:
|
||||
raise ValueError(f"Profile {profile_id} not found")
|
||||
|
||||
system_prompt = """أنت مستشار أعمال سعودي. المستخدم يصف احتياجاته بالعربي.
|
||||
استخرج المعلومات التالية وأعدها بصيغة JSON:
|
||||
|
||||
{
|
||||
"deal_type": "partnership/distribution/franchise/jv/referral/acquisition/barter",
|
||||
"specific_needs": ["قائمة الاحتياجات المحددة"],
|
||||
"budget_range_sar": {"min": 0, "max": 0},
|
||||
"timeline": "فوري/1-3 أشهر/3-6 أشهر/6-12 شهر/أكثر من سنة",
|
||||
"priorities": ["الأولوية الأولى", "الأولوية الثانية"],
|
||||
"ideal_partner_profile": "وصف الشريك المثالي",
|
||||
"deal_breakers": ["الأمور التي لا يمكن التنازل عنها"],
|
||||
"summary_ar": "ملخص الاحتياجات بالعربي"
|
||||
}
|
||||
|
||||
Company context:
|
||||
- Name: """ + profile.company_name + """
|
||||
- Industry: """ + (profile.industry or "unknown") + """
|
||||
- City: """ + (profile.city or "unknown")
|
||||
|
||||
llm_response = await self.llm.complete(
|
||||
system_prompt=system_prompt,
|
||||
user_message=user_description,
|
||||
json_mode=True,
|
||||
temperature=0.2,
|
||||
)
|
||||
analysis = llm_response.parse_json() or {}
|
||||
|
||||
# Persist extracted needs onto the profile
|
||||
if analysis.get("specific_needs"):
|
||||
existing = set(profile.needs or [])
|
||||
for need in analysis["specific_needs"]:
|
||||
existing.add(need)
|
||||
profile.needs = list(existing)
|
||||
|
||||
if analysis.get("deal_type") and not profile.deal_preferences:
|
||||
profile.deal_preferences = {analysis["deal_type"]: 1.0}
|
||||
|
||||
await db.flush()
|
||||
logger.info("Analyzed needs for profile %s: %s", profile_id, analysis.get("summary_ar", ""))
|
||||
return analysis
|
||||
|
||||
# ── Analyze Capabilities ─────────────────────────────────────────────────
|
||||
|
||||
async def analyze_capabilities(
|
||||
self,
|
||||
profile_id,
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""
|
||||
Analyze what the company can offer to partners.
|
||||
تحليل ما يمكن للشركة تقديمه للشركاء
|
||||
"""
|
||||
result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == profile_id))
|
||||
profile = result.scalar_one_or_none()
|
||||
if not profile:
|
||||
raise ValueError(f"Profile {profile_id} not found")
|
||||
|
||||
context_parts = [
|
||||
f"Company: {profile.company_name}",
|
||||
f"Industry: {profile.industry or 'unknown'}",
|
||||
f"Sub-industry: {profile.sub_industry or 'unknown'}",
|
||||
f"City: {profile.city or 'unknown'}",
|
||||
f"Employees: {profile.employee_count or 'unknown'}",
|
||||
f"Revenue (SAR): {profile.annual_revenue_sar or 'unknown'}",
|
||||
]
|
||||
if profile.capabilities:
|
||||
context_parts.append(f"Known capabilities: {', '.join(profile.capabilities)}")
|
||||
|
||||
system_prompt = """أنت محلل قدرات شركات سعودي. حلل الشركة وحدد قدراتها التي يمكن تقديمها لشركاء.
|
||||
|
||||
You are a Saudi company capabilities analyst. Analyze the company and identify what it can offer to partners.
|
||||
|
||||
Return JSON:
|
||||
{
|
||||
"core_capabilities": ["القدرات الأساسية"],
|
||||
"secondary_capabilities": ["القدرات الثانوية"],
|
||||
"unique_advantages": ["المزايا التنافسية الفريدة"],
|
||||
"capacity_utilization": "low/medium/high",
|
||||
"partnership_value_ar": "وصف القيمة التي يمكن تقديمها للشركاء بالعربي",
|
||||
"recommended_deal_types": ["أنواع الصفقات المقترحة"],
|
||||
"target_industries": ["القطاعات المستهدفة للشراكة"]
|
||||
}"""
|
||||
|
||||
llm_response = await self.llm.complete(
|
||||
system_prompt=system_prompt,
|
||||
user_message="\n".join(context_parts),
|
||||
json_mode=True,
|
||||
temperature=0.3,
|
||||
)
|
||||
analysis = llm_response.parse_json() or {}
|
||||
|
||||
# Merge discovered capabilities into the profile
|
||||
if analysis.get("core_capabilities"):
|
||||
existing = set(profile.capabilities or [])
|
||||
for cap in analysis["core_capabilities"]:
|
||||
existing.add(cap)
|
||||
if analysis.get("secondary_capabilities"):
|
||||
for cap in analysis["secondary_capabilities"]:
|
||||
existing.add(cap)
|
||||
profile.capabilities = list(existing)
|
||||
await db.flush()
|
||||
|
||||
logger.info("Analyzed capabilities for profile %s", profile_id)
|
||||
return analysis
|
||||
|
||||
# ── Deal Readiness Score ─────────────────────────────────────────────────
|
||||
|
||||
async def get_deal_readiness(
|
||||
self,
|
||||
profile_id,
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""
|
||||
Score the company's readiness to engage in strategic deals.
|
||||
تقييم جاهزية الشركة للصفقات الاستراتيجية
|
||||
"""
|
||||
from app.models.strategic_deal import StrategicDeal, DealStatus
|
||||
|
||||
result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == profile_id))
|
||||
profile = result.scalar_one_or_none()
|
||||
if not profile:
|
||||
raise ValueError(f"Profile {profile_id} not found")
|
||||
|
||||
score = 0.0
|
||||
breakdown = {}
|
||||
recommendations_ar = []
|
||||
|
||||
# 1. Profile completeness (0-25)
|
||||
completeness = 0
|
||||
if profile.company_name:
|
||||
completeness += 3
|
||||
if profile.company_name_ar:
|
||||
completeness += 2
|
||||
if profile.industry:
|
||||
completeness += 3
|
||||
if profile.cr_number:
|
||||
completeness += 4
|
||||
if profile.city and profile.region:
|
||||
completeness += 2
|
||||
if profile.website:
|
||||
completeness += 2
|
||||
if profile.whatsapp_number:
|
||||
completeness += 2
|
||||
if profile.capabilities and len(profile.capabilities) >= 3:
|
||||
completeness += 4
|
||||
else:
|
||||
recommendations_ar.append("أضف 3 قدرات على الأقل لتحسين المطابقة")
|
||||
if profile.needs and len(profile.needs) >= 2:
|
||||
completeness += 3
|
||||
else:
|
||||
recommendations_ar.append("حدد احتياجاتك لنجد لك الشريك المناسب")
|
||||
breakdown["profile_completeness"] = completeness
|
||||
score += completeness
|
||||
|
||||
# 2. Verification status (0-25)
|
||||
verification = 0
|
||||
if profile.is_verified:
|
||||
verification = 20
|
||||
if profile.cr_number:
|
||||
verification += 5
|
||||
else:
|
||||
recommendations_ar.append("أضف رقم السجل التجاري للتحقق من شركتك")
|
||||
breakdown["verification"] = verification
|
||||
score += verification
|
||||
|
||||
# 3. Trust score (0-25)
|
||||
trust = int(profile.trust_score * 25)
|
||||
breakdown["trust"] = trust
|
||||
score += trust
|
||||
if profile.trust_score < 0.5:
|
||||
recommendations_ar.append("أكمل عملية التحقق لرفع درجة الثقة")
|
||||
|
||||
# 4. Deal history (0-25)
|
||||
deal_count_q = select(func.count()).select_from(StrategicDeal).where(
|
||||
StrategicDeal.initiator_profile_id == profile_id,
|
||||
)
|
||||
total_deals = (await db.execute(deal_count_q)).scalar() or 0
|
||||
|
||||
won_count_q = select(func.count()).select_from(StrategicDeal).where(
|
||||
StrategicDeal.initiator_profile_id == profile_id,
|
||||
StrategicDeal.status == DealStatus.CLOSED_WON.value,
|
||||
)
|
||||
won_deals = (await db.execute(won_count_q)).scalar() or 0
|
||||
|
||||
history = min(25, total_deals * 3 + won_deals * 5)
|
||||
breakdown["deal_history"] = history
|
||||
score += history
|
||||
if total_deals == 0:
|
||||
recommendations_ar.append("ابدأ أول صفقة لبناء سجل تاريخي")
|
||||
|
||||
readiness_level = "low"
|
||||
if score >= 70:
|
||||
readiness_level = "high"
|
||||
elif score >= 40:
|
||||
readiness_level = "medium"
|
||||
|
||||
return {
|
||||
"score": round(score, 1),
|
||||
"max_score": 100,
|
||||
"readiness_level": readiness_level,
|
||||
"breakdown": breakdown,
|
||||
"recommendations_ar": recommendations_ar,
|
||||
"total_deals": total_deals,
|
||||
"won_deals": won_deals,
|
||||
"readiness_label_ar": {
|
||||
"high": "جاهز للصفقات",
|
||||
"medium": "يحتاج تحسين",
|
||||
"low": "يحتاج اهتمام",
|
||||
}[readiness_level],
|
||||
}
|
||||
@ -0,0 +1,792 @@
|
||||
"""
|
||||
Company Twin — Deep structured digital twin of a company's identity, capabilities, and needs.
|
||||
التوأم الرقمي للشركة: ملف تعريفي عميق يصف هوية الشركة وقدراتها واحتياجاتها
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.strategic_deal import CompanyProfile
|
||||
from app.services.llm.provider import get_llm
|
||||
|
||||
logger = logging.getLogger("dealix.strategic_deals.company_twin")
|
||||
|
||||
|
||||
# ── Node Models ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CapabilityNode(BaseModel):
|
||||
"""A single capability that the company can offer to partners."""
|
||||
category: str = Field(
|
||||
...,
|
||||
description="service, product, expertise, capacity, distribution, technology",
|
||||
)
|
||||
name: str
|
||||
name_ar: str
|
||||
description: str = ""
|
||||
capacity_available: float = Field(
|
||||
default=0.5,
|
||||
ge=0.0, le=1.0,
|
||||
description="Spare capacity ratio: 0 = fully booked, 1 = fully available",
|
||||
)
|
||||
quality_level: str = Field(
|
||||
default="standard",
|
||||
description="premium, standard, or budget",
|
||||
)
|
||||
sectors_served: list[str] = Field(default_factory=list)
|
||||
geographic_coverage: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Saudi administrative regions covered",
|
||||
)
|
||||
|
||||
|
||||
class NeedNode(BaseModel):
|
||||
"""A single business need that the company is seeking from partners."""
|
||||
category: str = Field(
|
||||
...,
|
||||
description="marketing, sales, delivery, technology, capital, distribution, talent",
|
||||
)
|
||||
name: str
|
||||
name_ar: str
|
||||
urgency: str = Field(
|
||||
default="medium",
|
||||
description="critical, high, medium, or low",
|
||||
)
|
||||
budget_range_sar: tuple[float, float] = Field(
|
||||
default=(0.0, 0.0),
|
||||
description="Min and max SAR budget for this need",
|
||||
)
|
||||
preferred_deal_type: str = ""
|
||||
description: str = ""
|
||||
|
||||
|
||||
class AuthorityMatrix(BaseModel):
|
||||
"""Defines what the AI agent can commit to autonomously vs what requires human approval."""
|
||||
auto_approve: list[str] = Field(
|
||||
default_factory=lambda: [
|
||||
"send_intro",
|
||||
"share_capability_doc",
|
||||
"schedule_call",
|
||||
"answer_general_questions",
|
||||
],
|
||||
)
|
||||
requires_approval: list[str] = Field(
|
||||
default_factory=lambda: [
|
||||
"pricing_commitment",
|
||||
"exclusivity",
|
||||
"equity_discussion",
|
||||
"revenue_share_terms",
|
||||
"contract_duration",
|
||||
],
|
||||
)
|
||||
forbidden: list[str] = Field(
|
||||
default_factory=lambda: [
|
||||
"sign_contract",
|
||||
"transfer_funds",
|
||||
"share_financials",
|
||||
"grant_data_access",
|
||||
"commit_legal_terms",
|
||||
],
|
||||
)
|
||||
max_commitment_sar: float = Field(
|
||||
default=0.0,
|
||||
description="Maximum SAR value the AI may discuss without escalation",
|
||||
)
|
||||
identity_mode: str = Field(
|
||||
default="transparent_ai",
|
||||
description="transparent_ai, delegated_sender, or operator_shadow",
|
||||
)
|
||||
|
||||
|
||||
class CompanyTwin(BaseModel):
|
||||
"""Complete digital twin of a company for the Dealix Deal Exchange OS."""
|
||||
twin_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
company_id: str
|
||||
tenant_id: str
|
||||
|
||||
# Identity
|
||||
name: str
|
||||
name_ar: str = ""
|
||||
industry: str = ""
|
||||
sub_industry: str = ""
|
||||
cr_number: str = ""
|
||||
website: str = ""
|
||||
size: str = "" # micro, small, medium, large
|
||||
annual_revenue_sar: float = 0.0
|
||||
|
||||
# Capability and Need Graphs
|
||||
capabilities: list[CapabilityNode] = Field(default_factory=list)
|
||||
needs: list[NeedNode] = Field(default_factory=list)
|
||||
|
||||
# Authority
|
||||
authority: AuthorityMatrix = Field(default_factory=AuthorityMatrix)
|
||||
|
||||
# Deal preferences
|
||||
deal_types_allowed: list[str] = Field(default_factory=list)
|
||||
deal_types_blocked: list[str] = Field(default_factory=list)
|
||||
sectors_preferred: list[str] = Field(default_factory=list)
|
||||
sectors_blocked: list[str] = Field(default_factory=list)
|
||||
min_deal_value_sar: float = 0.0
|
||||
max_deal_value_sar: float = 10_000_000.0
|
||||
|
||||
# Red lines — things AI must never agree to
|
||||
red_lines: list[str] = Field(default_factory=list)
|
||||
# Pre-approved marketing claims
|
||||
approved_claims: list[str] = Field(default_factory=list)
|
||||
|
||||
# Compliance
|
||||
pdpl_consent_status: str = "pending" # granted, pending, revoked
|
||||
whatsapp_opt_in: bool = False
|
||||
email_opt_in: bool = False
|
||||
|
||||
# Metadata
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
|
||||
|
||||
# ── Size Heuristic ──────────────────────────────────────────────────────────
|
||||
|
||||
_SIZE_THRESHOLDS = [
|
||||
(10, "micro"),
|
||||
(50, "small"),
|
||||
(250, "medium"),
|
||||
]
|
||||
|
||||
|
||||
def _infer_size(employee_count: Optional[float]) -> str:
|
||||
if employee_count is None or employee_count <= 0:
|
||||
return "small"
|
||||
for threshold, label in _SIZE_THRESHOLDS:
|
||||
if employee_count < threshold:
|
||||
return label
|
||||
return "large"
|
||||
|
||||
|
||||
# ── Builder ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CompanyTwinBuilder:
|
||||
"""
|
||||
Constructs, enriches, and manages CompanyTwin instances.
|
||||
يبني ويثري ويدير التوائم الرقمية للشركات
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.llm = get_llm()
|
||||
|
||||
# ── Build Twin ──────────────────────────────────────────────────────────
|
||||
|
||||
async def build_twin(
|
||||
self,
|
||||
company_data: dict,
|
||||
user_description_ar: str,
|
||||
db: AsyncSession,
|
||||
) -> CompanyTwin:
|
||||
"""
|
||||
Build a full CompanyTwin from profile data and an Arabic description.
|
||||
بناء توأم رقمي كامل من بيانات الشركة ووصف عربي
|
||||
"""
|
||||
company_id = str(company_data.get("company_id", company_data.get("id", "")))
|
||||
tenant_id = str(company_data.get("tenant_id", ""))
|
||||
name = company_data.get("company_name", company_data.get("name", ""))
|
||||
industry = company_data.get("industry", "")
|
||||
employee_count = company_data.get("employee_count")
|
||||
|
||||
capabilities = await self.extract_capabilities(
|
||||
description=user_description_ar,
|
||||
industry=industry,
|
||||
db=db,
|
||||
)
|
||||
needs = await self.infer_needs(
|
||||
description=user_description_ar,
|
||||
capabilities=capabilities,
|
||||
db=db,
|
||||
)
|
||||
authority = await self.suggest_authority_matrix(
|
||||
company_size=_infer_size(float(employee_count) if employee_count else None),
|
||||
industry=industry,
|
||||
)
|
||||
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
twin = CompanyTwin(
|
||||
company_id=company_id,
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
name_ar=company_data.get("company_name_ar", ""),
|
||||
industry=industry,
|
||||
sub_industry=company_data.get("sub_industry", ""),
|
||||
cr_number=company_data.get("cr_number", ""),
|
||||
website=company_data.get("website", ""),
|
||||
size=_infer_size(float(employee_count) if employee_count else None),
|
||||
annual_revenue_sar=float(company_data.get("annual_revenue_sar", 0) or 0),
|
||||
capabilities=capabilities,
|
||||
needs=needs,
|
||||
authority=authority,
|
||||
deal_types_allowed=company_data.get("deal_types_allowed", []),
|
||||
deal_types_blocked=company_data.get("deal_types_blocked", []),
|
||||
sectors_preferred=company_data.get("sectors_preferred", []),
|
||||
sectors_blocked=company_data.get("sectors_blocked", []),
|
||||
min_deal_value_sar=float(company_data.get("min_deal_value_sar", 0) or 0),
|
||||
max_deal_value_sar=float(company_data.get("max_deal_value_sar", 10_000_000) or 10_000_000),
|
||||
red_lines=company_data.get("red_lines", []),
|
||||
approved_claims=company_data.get("approved_claims", []),
|
||||
pdpl_consent_status=company_data.get("pdpl_consent_status", "pending"),
|
||||
whatsapp_opt_in=company_data.get("whatsapp_opt_in", False),
|
||||
email_opt_in=company_data.get("email_opt_in", False),
|
||||
created_at=now_iso,
|
||||
updated_at=now_iso,
|
||||
)
|
||||
|
||||
# Persist the twin as JSONB on the company profile
|
||||
profile_result = await db.execute(
|
||||
select(CompanyProfile).where(CompanyProfile.id == company_id)
|
||||
)
|
||||
profile = profile_result.scalar_one_or_none()
|
||||
if profile:
|
||||
existing = dict(profile.deal_preferences or {})
|
||||
existing["twin"] = twin.model_dump(mode="json")
|
||||
profile.deal_preferences = existing
|
||||
await db.flush()
|
||||
|
||||
logger.info("Built CompanyTwin %s for company %s", twin.twin_id, company_id)
|
||||
return twin
|
||||
|
||||
# ── Extract Capabilities ────────────────────────────────────────────────
|
||||
|
||||
async def extract_capabilities(
|
||||
self,
|
||||
description: str,
|
||||
industry: str,
|
||||
db: AsyncSession,
|
||||
) -> list[CapabilityNode]:
|
||||
"""
|
||||
Extract structured capability nodes from an Arabic free-text description.
|
||||
استخراج قدرات مهيكلة من وصف عربي حر
|
||||
"""
|
||||
if not description.strip():
|
||||
return []
|
||||
|
||||
system_prompt = """أنت محلل أعمال سعودي متخصص في تحليل قدرات الشركات.
|
||||
حلل الوصف التالي واستخرج قدرات الشركة بشكل مهيكل.
|
||||
|
||||
أعد النتائج بصيغة JSON:
|
||||
{
|
||||
"capabilities": [
|
||||
{
|
||||
"category": "service|product|expertise|capacity|distribution|technology",
|
||||
"name": "Capability name in English",
|
||||
"name_ar": "اسم القدرة بالعربي",
|
||||
"description": "Brief description",
|
||||
"capacity_available": 0.0 to 1.0,
|
||||
"quality_level": "premium|standard|budget",
|
||||
"sectors_served": ["sector1", "sector2"],
|
||||
"geographic_coverage": ["الرياض", "المنطقة الشرقية"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
قواعد:
|
||||
- استخرج 3-8 قدرات
|
||||
- صنف كل قدرة بدقة
|
||||
- قدر نسبة السعة المتاحة بناءً على السياق
|
||||
- حدد المناطق الجغرافية إن أمكن
|
||||
"""
|
||||
|
||||
user_message = f"القطاع: {industry or 'غير محدد'}\n\nالوصف:\n{description}"
|
||||
|
||||
try:
|
||||
llm_response = await self.llm.complete(
|
||||
system_prompt=system_prompt,
|
||||
user_message=user_message,
|
||||
json_mode=True,
|
||||
temperature=0.3,
|
||||
)
|
||||
result = llm_response.parse_json()
|
||||
if not result or "capabilities" not in result:
|
||||
return []
|
||||
nodes = []
|
||||
for cap_data in result["capabilities"]:
|
||||
try:
|
||||
node = CapabilityNode(
|
||||
category=cap_data.get("category", "service"),
|
||||
name=cap_data.get("name", ""),
|
||||
name_ar=cap_data.get("name_ar", ""),
|
||||
description=cap_data.get("description", ""),
|
||||
capacity_available=float(cap_data.get("capacity_available", 0.5)),
|
||||
quality_level=cap_data.get("quality_level", "standard"),
|
||||
sectors_served=cap_data.get("sectors_served", []),
|
||||
geographic_coverage=cap_data.get("geographic_coverage", []),
|
||||
)
|
||||
nodes.append(node)
|
||||
except Exception as exc:
|
||||
logger.warning("Skipping malformed capability node: %s", exc)
|
||||
logger.info("Extracted %d capability nodes from description", len(nodes))
|
||||
return nodes
|
||||
except Exception as exc:
|
||||
logger.error("Failed to extract capabilities: %s", exc)
|
||||
return []
|
||||
|
||||
# ── Infer Needs ─────────────────────────────────────────────────────────
|
||||
|
||||
async def infer_needs(
|
||||
self,
|
||||
description: str,
|
||||
capabilities: list[CapabilityNode],
|
||||
db: AsyncSession,
|
||||
) -> list[NeedNode]:
|
||||
"""
|
||||
Infer business needs from description and existing capabilities.
|
||||
استنتاج احتياجات الشركة من الوصف والقدرات الحالية
|
||||
"""
|
||||
if not description.strip():
|
||||
return []
|
||||
|
||||
caps_summary = ", ".join(c.name for c in capabilities) if capabilities else "غير محدد"
|
||||
|
||||
system_prompt = """أنت مستشار أعمال سعودي متخصص في تحليل احتياجات الشركات.
|
||||
بناءً على الوصف والقدرات الحالية، حدد الاحتياجات التي يمكن أن تسدها شراكة B2B.
|
||||
|
||||
أعد النتائج بصيغة JSON:
|
||||
{
|
||||
"needs": [
|
||||
{
|
||||
"category": "marketing|sales|delivery|technology|capital|distribution|talent",
|
||||
"name": "Need name in English",
|
||||
"name_ar": "اسم الاحتياج بالعربي",
|
||||
"urgency": "critical|high|medium|low",
|
||||
"budget_range_sar": [min_sar, max_sar],
|
||||
"preferred_deal_type": "service_barter|referral_partnership|co_selling|subcontracting|etc",
|
||||
"description": "وصف مختصر"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
قواعد:
|
||||
- حدد 2-6 احتياجات واقعية
|
||||
- لا تكرر القدرات الموجودة كاحتياجات
|
||||
- قدر مدى الميزانية بالريال السعودي حسب السياق
|
||||
- اقترح نوع الصفقة المناسب لكل احتياج
|
||||
"""
|
||||
|
||||
user_message = f"القدرات الحالية: {caps_summary}\n\nالوصف:\n{description}"
|
||||
|
||||
try:
|
||||
llm_response = await self.llm.complete(
|
||||
system_prompt=system_prompt,
|
||||
user_message=user_message,
|
||||
json_mode=True,
|
||||
temperature=0.3,
|
||||
)
|
||||
result = llm_response.parse_json()
|
||||
if not result or "needs" not in result:
|
||||
return []
|
||||
nodes = []
|
||||
for need_data in result["needs"]:
|
||||
try:
|
||||
budget = need_data.get("budget_range_sar", [0, 0])
|
||||
if isinstance(budget, list) and len(budget) == 2:
|
||||
budget_tuple = (float(budget[0]), float(budget[1]))
|
||||
else:
|
||||
budget_tuple = (0.0, 0.0)
|
||||
node = NeedNode(
|
||||
category=need_data.get("category", "marketing"),
|
||||
name=need_data.get("name", ""),
|
||||
name_ar=need_data.get("name_ar", ""),
|
||||
urgency=need_data.get("urgency", "medium"),
|
||||
budget_range_sar=budget_tuple,
|
||||
preferred_deal_type=need_data.get("preferred_deal_type", ""),
|
||||
description=need_data.get("description", ""),
|
||||
)
|
||||
nodes.append(node)
|
||||
except Exception as exc:
|
||||
logger.warning("Skipping malformed need node: %s", exc)
|
||||
logger.info("Inferred %d need nodes from description", len(nodes))
|
||||
return nodes
|
||||
except Exception as exc:
|
||||
logger.error("Failed to infer needs: %s", exc)
|
||||
return []
|
||||
|
||||
# ── Suggest Authority Matrix ────────────────────────────────────────────
|
||||
|
||||
async def suggest_authority_matrix(
|
||||
self,
|
||||
company_size: str,
|
||||
industry: str,
|
||||
) -> AuthorityMatrix:
|
||||
"""
|
||||
Suggest an authority matrix based on company size and industry.
|
||||
اقتراح مصفوفة صلاحيات بناءً على حجم الشركة والقطاع
|
||||
"""
|
||||
# Base policies by company size
|
||||
size_policies = {
|
||||
"micro": {
|
||||
"max_commitment_sar": 5_000,
|
||||
"identity_mode": "transparent_ai",
|
||||
"auto_approve": [
|
||||
"send_intro",
|
||||
"share_capability_doc",
|
||||
"schedule_call",
|
||||
"answer_general_questions",
|
||||
],
|
||||
"requires_approval": [
|
||||
"pricing_commitment",
|
||||
"exclusivity",
|
||||
"equity_discussion",
|
||||
"revenue_share_terms",
|
||||
],
|
||||
"forbidden": [
|
||||
"sign_contract",
|
||||
"transfer_funds",
|
||||
"share_financials",
|
||||
"grant_data_access",
|
||||
],
|
||||
},
|
||||
"small": {
|
||||
"max_commitment_sar": 25_000,
|
||||
"identity_mode": "transparent_ai",
|
||||
"auto_approve": [
|
||||
"send_intro",
|
||||
"share_capability_doc",
|
||||
"schedule_call",
|
||||
"answer_general_questions",
|
||||
"send_proposal_draft",
|
||||
],
|
||||
"requires_approval": [
|
||||
"pricing_commitment",
|
||||
"exclusivity",
|
||||
"equity_discussion",
|
||||
"revenue_share_terms",
|
||||
"contract_duration",
|
||||
],
|
||||
"forbidden": [
|
||||
"sign_contract",
|
||||
"transfer_funds",
|
||||
"share_financials",
|
||||
"grant_data_access",
|
||||
"commit_legal_terms",
|
||||
],
|
||||
},
|
||||
"medium": {
|
||||
"max_commitment_sar": 50_000,
|
||||
"identity_mode": "delegated_sender",
|
||||
"auto_approve": [
|
||||
"send_intro",
|
||||
"share_capability_doc",
|
||||
"schedule_call",
|
||||
"answer_general_questions",
|
||||
"send_proposal_draft",
|
||||
"negotiate_minor_terms",
|
||||
],
|
||||
"requires_approval": [
|
||||
"pricing_commitment",
|
||||
"exclusivity",
|
||||
"equity_discussion",
|
||||
"revenue_share_terms",
|
||||
"contract_duration",
|
||||
"territory_expansion",
|
||||
],
|
||||
"forbidden": [
|
||||
"sign_contract",
|
||||
"transfer_funds",
|
||||
"share_financials",
|
||||
"grant_data_access",
|
||||
"commit_legal_terms",
|
||||
"share_client_data",
|
||||
],
|
||||
},
|
||||
"large": {
|
||||
"max_commitment_sar": 100_000,
|
||||
"identity_mode": "delegated_sender",
|
||||
"auto_approve": [
|
||||
"send_intro",
|
||||
"share_capability_doc",
|
||||
"schedule_call",
|
||||
"answer_general_questions",
|
||||
"send_proposal_draft",
|
||||
"negotiate_minor_terms",
|
||||
"send_nda_template",
|
||||
],
|
||||
"requires_approval": [
|
||||
"pricing_commitment",
|
||||
"exclusivity",
|
||||
"equity_discussion",
|
||||
"revenue_share_terms",
|
||||
"contract_duration",
|
||||
"territory_expansion",
|
||||
"ip_licensing",
|
||||
"joint_venture_terms",
|
||||
],
|
||||
"forbidden": [
|
||||
"sign_contract",
|
||||
"transfer_funds",
|
||||
"share_financials",
|
||||
"grant_data_access",
|
||||
"commit_legal_terms",
|
||||
"share_client_data",
|
||||
"waive_liability",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
policy = size_policies.get(company_size, size_policies["small"])
|
||||
|
||||
# Industry-specific adjustments for regulated sectors
|
||||
regulated_industries = {"finance", "healthcare", "energy", "government"}
|
||||
if industry in regulated_industries:
|
||||
policy["max_commitment_sar"] = min(policy["max_commitment_sar"], 10_000)
|
||||
policy["forbidden"].extend([
|
||||
"discuss_regulatory_commitments",
|
||||
"promise_compliance_outcomes",
|
||||
])
|
||||
# Deduplicate
|
||||
policy["forbidden"] = list(set(policy["forbidden"]))
|
||||
|
||||
matrix = AuthorityMatrix(
|
||||
auto_approve=policy["auto_approve"],
|
||||
requires_approval=policy["requires_approval"],
|
||||
forbidden=policy["forbidden"],
|
||||
max_commitment_sar=policy["max_commitment_sar"],
|
||||
identity_mode=policy["identity_mode"],
|
||||
)
|
||||
logger.info(
|
||||
"Suggested authority matrix for %s %s company: max_commitment=%.0f SAR",
|
||||
company_size, industry or "general", matrix.max_commitment_sar,
|
||||
)
|
||||
return matrix
|
||||
|
||||
# ── Update Twin ─────────────────────────────────────────────────────────
|
||||
|
||||
async def update_twin(
|
||||
self,
|
||||
twin_id: str,
|
||||
updates: dict,
|
||||
db: AsyncSession,
|
||||
) -> CompanyTwin:
|
||||
"""
|
||||
Apply partial updates to an existing CompanyTwin.
|
||||
تحديث جزئي للتوأم الرقمي
|
||||
"""
|
||||
twin = await self.get_twin_by_id(twin_id, db)
|
||||
if not twin:
|
||||
raise ValueError(f"التوأم الرقمي غير موجود: {twin_id}")
|
||||
|
||||
twin_data = twin.model_dump(mode="json")
|
||||
|
||||
# Apply updates, preserving existing values for keys not in updates
|
||||
for key, value in updates.items():
|
||||
if key in twin_data and key not in ("twin_id", "company_id", "tenant_id", "created_at"):
|
||||
twin_data[key] = value
|
||||
|
||||
twin_data["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
updated_twin = CompanyTwin(**twin_data)
|
||||
|
||||
# Persist
|
||||
profile_result = await db.execute(
|
||||
select(CompanyProfile).where(CompanyProfile.id == updated_twin.company_id)
|
||||
)
|
||||
profile = profile_result.scalar_one_or_none()
|
||||
if profile:
|
||||
existing = dict(profile.deal_preferences or {})
|
||||
existing["twin"] = updated_twin.model_dump(mode="json")
|
||||
profile.deal_preferences = existing
|
||||
await db.flush()
|
||||
|
||||
logger.info("Updated CompanyTwin %s", twin_id)
|
||||
return updated_twin
|
||||
|
||||
# ── Get Twin ────────────────────────────────────────────────────────────
|
||||
|
||||
async def get_twin(
|
||||
self,
|
||||
company_id: str,
|
||||
db: AsyncSession,
|
||||
) -> Optional[CompanyTwin]:
|
||||
"""
|
||||
Retrieve the CompanyTwin for a given company.
|
||||
استرجاع التوأم الرقمي لشركة معينة
|
||||
"""
|
||||
profile_result = await db.execute(
|
||||
select(CompanyProfile).where(CompanyProfile.id == company_id)
|
||||
)
|
||||
profile = profile_result.scalar_one_or_none()
|
||||
if not profile:
|
||||
logger.warning("Company profile not found: %s", company_id)
|
||||
return None
|
||||
|
||||
prefs = profile.deal_preferences or {}
|
||||
twin_data = prefs.get("twin")
|
||||
if not twin_data:
|
||||
logger.info("No twin found for company %s", company_id)
|
||||
return None
|
||||
|
||||
try:
|
||||
return CompanyTwin(**twin_data)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to deserialize twin for company %s: %s", company_id, exc)
|
||||
return None
|
||||
|
||||
async def get_twin_by_id(
|
||||
self,
|
||||
twin_id: str,
|
||||
db: AsyncSession,
|
||||
) -> Optional[CompanyTwin]:
|
||||
"""
|
||||
Retrieve a CompanyTwin by its twin_id (scans all profiles).
|
||||
استرجاع التوأم الرقمي برقمه المعرف
|
||||
"""
|
||||
all_profiles = await db.execute(select(CompanyProfile))
|
||||
for profile in all_profiles.scalars():
|
||||
prefs = profile.deal_preferences or {}
|
||||
twin_data = prefs.get("twin")
|
||||
if twin_data and twin_data.get("twin_id") == twin_id:
|
||||
try:
|
||||
return CompanyTwin(**twin_data)
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
# ── Deal Readiness Report ───────────────────────────────────────────────
|
||||
|
||||
async def get_deal_readiness_report(
|
||||
self,
|
||||
twin_id: str,
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""
|
||||
Generate an Arabic deal-readiness report for the company twin.
|
||||
إنشاء تقرير جاهزية الصفقات بالعربي للتوأم الرقمي
|
||||
"""
|
||||
twin = await self.get_twin_by_id(twin_id, db)
|
||||
if not twin:
|
||||
raise ValueError(f"التوأم الرقمي غير موجود: {twin_id}")
|
||||
|
||||
issues: list[str] = []
|
||||
score = 0.0
|
||||
max_score = 100.0
|
||||
|
||||
# 1. Capabilities completeness (0-25)
|
||||
cap_count = len(twin.capabilities)
|
||||
if cap_count == 0:
|
||||
issues.append("لم يتم تحديد أي قدرات للشركة — أضف قدراتك لتحسين فرص المطابقة")
|
||||
cap_score = 0.0
|
||||
elif cap_count < 3:
|
||||
issues.append(f"لديك {cap_count} قدرات فقط — يفضل إضافة 3 قدرات على الأقل")
|
||||
cap_score = cap_count * 8.0
|
||||
else:
|
||||
cap_score = 25.0
|
||||
score += cap_score
|
||||
|
||||
# 2. Needs clarity (0-20)
|
||||
need_count = len(twin.needs)
|
||||
if need_count == 0:
|
||||
issues.append("لم يتم تحديد أي احتياجات — حدد احتياجاتك ليتمكن النظام من إيجاد شركاء")
|
||||
need_score = 0.0
|
||||
elif need_count < 2:
|
||||
issues.append(f"لديك احتياج واحد فقط — أضف المزيد لتوسيع خيارات الشراكة")
|
||||
need_score = 10.0
|
||||
else:
|
||||
need_score = 20.0
|
||||
score += need_score
|
||||
|
||||
# 3. Authority matrix configured (0-15)
|
||||
authority_score = 0.0
|
||||
if twin.authority.max_commitment_sar > 0:
|
||||
authority_score += 5.0
|
||||
else:
|
||||
issues.append("لم يتم تحديد حد أقصى لصلاحيات الذكاء الاصطناعي")
|
||||
if len(twin.authority.auto_approve) > 0:
|
||||
authority_score += 5.0
|
||||
if len(twin.authority.forbidden) > 0:
|
||||
authority_score += 5.0
|
||||
else:
|
||||
issues.append("لم يتم تحديد الإجراءات المحظورة — مهم للحماية")
|
||||
score += authority_score
|
||||
|
||||
# 4. Compliance readiness (0-20)
|
||||
compliance_score = 0.0
|
||||
if twin.pdpl_consent_status == "granted":
|
||||
compliance_score += 10.0
|
||||
else:
|
||||
issues.append("موافقة نظام حماية البيانات الشخصية (PDPL) غير مكتملة")
|
||||
if twin.whatsapp_opt_in:
|
||||
compliance_score += 5.0
|
||||
else:
|
||||
issues.append("لم يتم تفعيل الموافقة على التواصل عبر واتساب")
|
||||
if twin.email_opt_in:
|
||||
compliance_score += 5.0
|
||||
else:
|
||||
issues.append("لم يتم تفعيل الموافقة على التواصل عبر البريد الإلكتروني")
|
||||
score += compliance_score
|
||||
|
||||
# 5. Deal preferences set (0-10)
|
||||
pref_score = 0.0
|
||||
if twin.deal_types_allowed:
|
||||
pref_score += 5.0
|
||||
else:
|
||||
issues.append("لم يتم تحديد أنواع الصفقات المسموحة")
|
||||
if twin.red_lines:
|
||||
pref_score += 5.0
|
||||
else:
|
||||
issues.append("لم يتم تحديد الخطوط الحمراء — يُنصح بتحديدها لحماية مصالحك")
|
||||
score += pref_score
|
||||
|
||||
# 6. Profile completeness (0-10)
|
||||
profile_score = 0.0
|
||||
if twin.cr_number:
|
||||
profile_score += 3.0
|
||||
else:
|
||||
issues.append("أضف رقم السجل التجاري لزيادة الموثوقية")
|
||||
if twin.website:
|
||||
profile_score += 2.0
|
||||
if twin.name_ar:
|
||||
profile_score += 2.0
|
||||
if twin.annual_revenue_sar > 0:
|
||||
profile_score += 3.0
|
||||
score += profile_score
|
||||
|
||||
# Readiness level
|
||||
if score >= 80:
|
||||
readiness = "جاهز للصفقات"
|
||||
readiness_level = "high"
|
||||
elif score >= 50:
|
||||
readiness = "يحتاج تحسين بسيط"
|
||||
readiness_level = "medium"
|
||||
else:
|
||||
readiness = "يحتاج اهتمام عاجل"
|
||||
readiness_level = "low"
|
||||
|
||||
report = {
|
||||
"twin_id": twin.twin_id,
|
||||
"company_name": twin.name,
|
||||
"company_name_ar": twin.name_ar,
|
||||
"score": round(score, 1),
|
||||
"max_score": max_score,
|
||||
"readiness_level": readiness_level,
|
||||
"readiness_label_ar": readiness,
|
||||
"breakdown": {
|
||||
"capabilities": round(cap_score, 1),
|
||||
"needs": round(need_score, 1),
|
||||
"authority": round(authority_score, 1),
|
||||
"compliance": round(compliance_score, 1),
|
||||
"deal_preferences": round(pref_score, 1),
|
||||
"profile": round(profile_score, 1),
|
||||
},
|
||||
"issues_ar": issues,
|
||||
"summary_ar": (
|
||||
f"شركة {twin.name_ar or twin.name}: "
|
||||
f"درجة الجاهزية {score:.0f}/100 — {readiness}. "
|
||||
+ (f"يوجد {len(issues)} ملاحظات تحتاج معالجة." if issues else "الملف مكتمل وجاهز.")
|
||||
),
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Deal readiness report for twin %s: score=%.1f level=%s",
|
||||
twin_id, score, readiness_level,
|
||||
)
|
||||
return report
|
||||
@ -0,0 +1,596 @@
|
||||
"""
|
||||
Deal Agent — Autonomous outreach agent for B2B deal discovery and engagement.
|
||||
وكيل الصفقات: وكيل ذكي مستقل للتواصل واكتشاف الشراكات
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.strategic_deal import (
|
||||
CompanyProfile, StrategicDeal, DealMatch,
|
||||
DealStatus, DealChannel, MatchStatus, DealType,
|
||||
)
|
||||
from app.services.llm.provider import get_llm
|
||||
|
||||
logger = logging.getLogger("dealix.strategic_deals.agent")
|
||||
|
||||
|
||||
# ── WhatsApp outreach templates (Arabic) ─────────────────────────────────────
|
||||
|
||||
TEMPLATES = {
|
||||
"introduction_collaborative": (
|
||||
"السلام عليكم {contact_name}\n\n"
|
||||
"أتمنى تكون بخير وعافية. أنا {sender_name} من {company_name}.\n"
|
||||
"اطلعت على أعمالكم في مجال {target_industry} وعجبني اللي تقدمونه.\n\n"
|
||||
"عندنا خبرة في {our_capability} ونشوف فرصة تعاون مثمرة بيننا "
|
||||
"خصوصاً في مجال {collaboration_area}.\n\n"
|
||||
"هل ممكن نحدد وقت مناسب نتكلم فيه عن إمكانية التعاون؟\n\n"
|
||||
"تحياتي"
|
||||
),
|
||||
"introduction_as_ai": (
|
||||
"السلام عليكم {contact_name}\n\n"
|
||||
"أنا مساعد ذكي من شركة {company_name}. فريقنا مهتم بالتعاون معكم "
|
||||
"بناءً على تحليل التكامل بين خدماتنا.\n\n"
|
||||
"شركة {company_name} تقدم {our_capability} وشفنا إن عندكم احتياج في "
|
||||
"هذا المجال.\n\n"
|
||||
"هل تحبون نرسل لكم تفاصيل أكثر عن فرصة التعاون؟\n\n"
|
||||
"شكراً لوقتكم"
|
||||
),
|
||||
"follow_up_1": (
|
||||
"مرحباً {contact_name}\n\n"
|
||||
"تابعت معكم بخصوص موضوع التعاون اللي ذكرناه.\n"
|
||||
"أبشركم جهزنا مقترح مبدئي يوضح كيف ممكن نستفيد من بعض.\n\n"
|
||||
"هل تفضلون نرسله عبر الإيميل أو نحدد اجتماع قصير؟\n\n"
|
||||
"تحياتي"
|
||||
),
|
||||
"proposal_summary": (
|
||||
"حبيت أشاركك ملخص المقترح:\n\n"
|
||||
"- نوع التعاون: {deal_type_ar}\n"
|
||||
"- القيمة المتوقعة: {estimated_value}\n"
|
||||
"- المدة: {duration}\n"
|
||||
"- المنافع المشتركة: {mutual_benefits}\n\n"
|
||||
"المقترح الكامل بالمرفق. ننتظر ملاحظاتكم.\n\n"
|
||||
"شاكرين لكم"
|
||||
),
|
||||
"negotiation_counter": (
|
||||
"شاكرين لكم على الرد والاهتمام {contact_name}.\n\n"
|
||||
"نقدر وجهة نظركم. بعد دراسة مقترحكم، حبينا نقدم لكم عرض معدل:\n\n"
|
||||
"{counter_details}\n\n"
|
||||
"نتمنى يكون العرض مناسب ونتطلع لشراكة ناجحة.\n\n"
|
||||
"تحياتي"
|
||||
),
|
||||
}
|
||||
|
||||
DEAL_TYPE_AR = {
|
||||
"partnership": "شراكة استراتيجية",
|
||||
"distribution": "توزيع",
|
||||
"franchise": "امتياز تجاري",
|
||||
"jv": "مشروع مشترك",
|
||||
"referral": "إحالة",
|
||||
"acquisition": "استحواذ",
|
||||
"barter": "مقايضة",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutreachResult:
|
||||
"""Result of an outreach attempt."""
|
||||
success: bool = False
|
||||
channel: str = ""
|
||||
message_sent: str = ""
|
||||
response_received: Optional[str] = None
|
||||
interest_level: Optional[str] = None # high, medium, low, none
|
||||
next_action: str = ""
|
||||
next_action_ar: str = ""
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class DealAgent:
|
||||
"""
|
||||
Autonomous outreach agent that discovers, contacts, and qualifies B2B partners.
|
||||
وكيل ذكي مستقل يكتشف ويتواصل ويؤهل الشركاء
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.llm = get_llm()
|
||||
|
||||
# ── Outreach Campaign ────────────────────────────────────────────────────
|
||||
|
||||
async def run_outreach_campaign(
|
||||
self,
|
||||
deal_match_id,
|
||||
channel: str,
|
||||
db: AsyncSession,
|
||||
) -> OutreachResult:
|
||||
"""
|
||||
Execute multi-step outreach: research, craft intro, send, handle response.
|
||||
تنفيذ حملة تواصل متعددة الخطوات
|
||||
"""
|
||||
# Load match and related profiles
|
||||
match_result = await db.execute(select(DealMatch).where(DealMatch.id == deal_match_id))
|
||||
match = match_result.scalar_one_or_none()
|
||||
if not match:
|
||||
return OutreachResult(success=False, error="Match not found")
|
||||
|
||||
a_result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == match.company_a_id))
|
||||
company_a = a_result.scalar_one_or_none()
|
||||
if not company_a:
|
||||
return OutreachResult(success=False, error="Initiator profile not found")
|
||||
|
||||
target_name = match.company_b_name or "الشركة المستهدفة"
|
||||
target_industry = ""
|
||||
target_contact = ""
|
||||
company_b = None
|
||||
|
||||
if match.company_b_id:
|
||||
b_result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == match.company_b_id))
|
||||
company_b = b_result.scalar_one_or_none()
|
||||
if company_b:
|
||||
target_name = company_b.company_name
|
||||
target_industry = company_b.industry or ""
|
||||
target_contact = company_b.whatsapp_number or ""
|
||||
|
||||
# Step 1: Research the target
|
||||
research = await self._research_target(company_a, company_b, match)
|
||||
|
||||
# Step 2: Craft personalized introduction
|
||||
style = "as_company" # Default to speaking as the company
|
||||
intro_message = await self.craft_introduction(
|
||||
match=match,
|
||||
channel=channel,
|
||||
style=style,
|
||||
db=db,
|
||||
)
|
||||
|
||||
# Step 3: Prepare outreach result (actual sending delegated to channel adapters)
|
||||
# In production, this would call WhatsApp/LinkedIn/Email service
|
||||
outreach = OutreachResult(
|
||||
success=True,
|
||||
channel=channel,
|
||||
message_sent=intro_message,
|
||||
next_action="await_response",
|
||||
next_action_ar="انتظار الرد من الطرف الآخر",
|
||||
)
|
||||
|
||||
# Step 4: Update match status
|
||||
match.status = MatchStatus.OUTREACH_SENT.value
|
||||
await db.flush()
|
||||
|
||||
# Step 5: Create a strategic deal from this outreach
|
||||
deal = StrategicDeal(
|
||||
tenant_id=company_a.tenant_id,
|
||||
initiator_profile_id=company_a.id,
|
||||
target_profile_id=match.company_b_id,
|
||||
target_company_name=target_name,
|
||||
deal_type=match.deal_type_suggested or DealType.PARTNERSHIP.value,
|
||||
deal_title=f"شراكة مع {target_name}",
|
||||
deal_title_ar=f"شراكة مع {target_name}",
|
||||
our_offer=research.get("our_value_proposition", ""),
|
||||
our_need=research.get("what_we_need_from_them", ""),
|
||||
proposed_terms=match.terms_suggested or {},
|
||||
status=DealStatus.OUTREACH.value,
|
||||
channel=channel,
|
||||
ai_confidence=match.match_score,
|
||||
negotiation_history=[{
|
||||
"round": 0,
|
||||
"action": "outreach",
|
||||
"channel": channel,
|
||||
"message": intro_message[:500],
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}],
|
||||
)
|
||||
db.add(deal)
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
"Outreach campaign executed for match %s via %s (deal %s created)",
|
||||
deal_match_id, channel, deal.id,
|
||||
)
|
||||
return outreach
|
||||
|
||||
# ── Craft Introduction ───────────────────────────────────────────────────
|
||||
|
||||
async def craft_introduction(
|
||||
self,
|
||||
match: DealMatch,
|
||||
channel: str,
|
||||
style: str,
|
||||
db: AsyncSession,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a personalized Arabic introduction message.
|
||||
إنشاء رسالة تعريفية عربية مخصصة
|
||||
"""
|
||||
a_result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == match.company_a_id))
|
||||
company_a = a_result.scalar_one_or_none()
|
||||
|
||||
target_name = match.company_b_name or "الشركة المستهدفة"
|
||||
target_industry = ""
|
||||
target_caps = []
|
||||
target_needs = []
|
||||
|
||||
if match.company_b_id:
|
||||
b_result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == match.company_b_id))
|
||||
company_b = b_result.scalar_one_or_none()
|
||||
if company_b:
|
||||
target_name = company_b.company_name
|
||||
target_industry = company_b.industry or ""
|
||||
target_caps = company_b.capabilities or []
|
||||
target_needs = company_b.needs or []
|
||||
elif match.company_b_data:
|
||||
target_industry = match.company_b_data.get("industry", "")
|
||||
target_caps = match.company_b_data.get("capabilities", [])
|
||||
target_needs = match.company_b_data.get("needs", [])
|
||||
|
||||
# Channel-specific length guidance
|
||||
length_guidance = {
|
||||
"whatsapp": "اكتب رسالة قصيرة ومباشرة (3-5 أسطر). لا تكتب أكثر من 300 حرف.",
|
||||
"email": "اكتب رسالة مفصلة ومهنية (8-12 سطر) مع سطر موضوع.",
|
||||
"linkedin": "اكتب رسالة قصيرة ومهنية (4-6 أسطر).",
|
||||
"in_person": "جهز نقاط حديث مختصرة (5-7 نقاط).",
|
||||
}
|
||||
|
||||
style_guidance = {
|
||||
"as_company": "تكلم باسم الشركة مباشرة (نحن في شركة X...)",
|
||||
"as_ai": "كن شفافاً أنك مساعد ذكي (أنا مساعد ذكي من شركة X...)",
|
||||
}
|
||||
|
||||
context = f"""Our company: {company_a.company_name if company_a else 'unknown'}
|
||||
Our capabilities: {', '.join((company_a.capabilities or [])[:5]) if company_a else 'unknown'}
|
||||
Target company: {target_name}
|
||||
Target industry: {target_industry}
|
||||
Target capabilities: {', '.join(target_caps[:5])}
|
||||
Target needs: {', '.join(target_needs[:5])}
|
||||
Match reasons: {', '.join(match.match_reasons or [])}
|
||||
Match score: {match.match_score}
|
||||
Suggested deal type: {match.deal_type_suggested}"""
|
||||
|
||||
system_prompt = f"""أنت كاتب رسائل أعمال سعودي محترف.
|
||||
اكتب رسالة تعريفية للتواصل مع شركة محتملة للتعاون.
|
||||
|
||||
Channel: {channel}
|
||||
{length_guidance.get(channel, length_guidance['whatsapp'])}
|
||||
|
||||
Style: {style}
|
||||
{style_guidance.get(style, style_guidance['as_company'])}
|
||||
|
||||
قواعد مهمة:
|
||||
- ابدأ بالسلام
|
||||
- اذكر سبب التواصل بوضوح
|
||||
- أبرز نقطة التكامل بين الشركتين
|
||||
- اختم بطلب واضح (اجتماع، مكالمة، تفاصيل أكثر)
|
||||
- لا تبالغ في المدح
|
||||
- كن مهنياً وودوداً
|
||||
|
||||
Return the message directly as text (not JSON)."""
|
||||
|
||||
llm_response = await self.llm.complete(
|
||||
system_prompt=system_prompt,
|
||||
user_message=context,
|
||||
temperature=0.6,
|
||||
)
|
||||
message = llm_response.content.strip()
|
||||
|
||||
logger.info("Crafted introduction for match %s via %s", match.id, channel)
|
||||
return message
|
||||
|
||||
# ── Handle Response ──────────────────────────────────────────────────────
|
||||
|
||||
async def handle_response(
|
||||
self,
|
||||
deal_id,
|
||||
message: str,
|
||||
channel: str,
|
||||
db: AsyncSession,
|
||||
) -> str:
|
||||
"""
|
||||
Analyze incoming response and generate appropriate follow-up.
|
||||
تحليل الرد الوارد وتوليد متابعة مناسبة
|
||||
"""
|
||||
deal_result = await db.execute(select(StrategicDeal).where(StrategicDeal.id == deal_id))
|
||||
deal = deal_result.scalar_one_or_none()
|
||||
if not deal:
|
||||
raise ValueError(f"Deal {deal_id} not found")
|
||||
|
||||
# Load initiator profile
|
||||
initiator = None
|
||||
if deal.initiator_profile_id:
|
||||
init_result = await db.execute(
|
||||
select(CompanyProfile).where(CompanyProfile.id == deal.initiator_profile_id)
|
||||
)
|
||||
initiator = init_result.scalar_one_or_none()
|
||||
|
||||
history_summary = ""
|
||||
for h in (deal.negotiation_history or [])[-3:]:
|
||||
history_summary += f"- {h.get('action', '?')}: {h.get('message', '')[:100]}\n"
|
||||
|
||||
context = f"""Deal: {deal.deal_title}
|
||||
Our company: {initiator.company_name if initiator else 'unknown'}
|
||||
Target: {deal.target_company_name or 'unknown'}
|
||||
Channel: {channel}
|
||||
Current status: {deal.status}
|
||||
Recent conversation:
|
||||
{history_summary}
|
||||
|
||||
Incoming message: {message}"""
|
||||
|
||||
system_prompt = """أنت مساعد أعمال سعودي. حلل الرسالة الواردة وحدد نوعها وقدم رداً مناسباً.
|
||||
|
||||
أولاً حلل الرسالة:
|
||||
- اهتمام (interest): الطرف الآخر مهتم
|
||||
- اعتراض (objection): عنده تحفظات
|
||||
- سؤال (question): يحتاج معلومات إضافية
|
||||
- رفض (rejection): غير مهتم
|
||||
- طلب معلومات (request_for_info): يريد تفاصيل أكثر
|
||||
|
||||
ثم اكتب رداً مناسباً:
|
||||
- إذا مهتم: حدد الخطوة التالية (اجتماع، مقترح)
|
||||
- إذا متحفظ: عالج التحفظ بلطف
|
||||
- إذا عنده سؤال: أجب بوضوح
|
||||
- إذا رافض: اشكره واترك الباب مفتوحاً
|
||||
- إذا يبي تفاصيل: وعده بإرسالها
|
||||
|
||||
Return JSON:
|
||||
{
|
||||
"response_type": "interest/objection/question/rejection/request_for_info",
|
||||
"interest_level": "high/medium/low/none",
|
||||
"response_message": "الرد بالعربي",
|
||||
"next_action": "schedule_meeting/send_proposal/send_info/follow_up_later/close",
|
||||
"next_action_ar": "الخطوة التالية بالعربي"
|
||||
}"""
|
||||
|
||||
llm_response = await self.llm.complete(
|
||||
system_prompt=system_prompt,
|
||||
user_message=context,
|
||||
json_mode=True,
|
||||
temperature=0.4,
|
||||
)
|
||||
result = llm_response.parse_json() or {}
|
||||
|
||||
response_msg = result.get("response_message", "شكراً لردكم، سنتواصل معكم قريباً.")
|
||||
interest = result.get("interest_level", "medium")
|
||||
next_action = result.get("next_action", "follow_up_later")
|
||||
|
||||
# Update deal based on response
|
||||
if interest == "high" or next_action == "schedule_meeting":
|
||||
deal.status = DealStatus.NEGOTIATING.value
|
||||
elif interest == "none" or next_action == "close":
|
||||
deal.status = DealStatus.CLOSED_LOST.value
|
||||
deal.closed_at = datetime.now(timezone.utc)
|
||||
|
||||
# Log in negotiation history
|
||||
history = list(deal.negotiation_history or [])
|
||||
history.append({
|
||||
"round": len(history) + 1,
|
||||
"action": "response_handling",
|
||||
"their_message": message[:500],
|
||||
"our_response": response_msg[:500],
|
||||
"response_type": result.get("response_type", "unknown"),
|
||||
"interest_level": interest,
|
||||
"next_action": next_action,
|
||||
"channel": channel,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
deal.negotiation_history = history
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
"Handled response for deal %s: type=%s, interest=%s",
|
||||
deal_id, result.get("response_type"), interest,
|
||||
)
|
||||
return response_msg
|
||||
|
||||
# ── Generate Proposal ────────────────────────────────────────────────────
|
||||
|
||||
async def generate_proposal(
|
||||
self,
|
||||
deal_id,
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""
|
||||
Generate a full Arabic business proposal document.
|
||||
إنشاء مقترح أعمال عربي كامل
|
||||
"""
|
||||
deal_result = await db.execute(select(StrategicDeal).where(StrategicDeal.id == deal_id))
|
||||
deal = deal_result.scalar_one_or_none()
|
||||
if not deal:
|
||||
raise ValueError(f"Deal {deal_id} not found")
|
||||
|
||||
initiator = None
|
||||
if deal.initiator_profile_id:
|
||||
init_result = await db.execute(
|
||||
select(CompanyProfile).where(CompanyProfile.id == deal.initiator_profile_id)
|
||||
)
|
||||
initiator = init_result.scalar_one_or_none()
|
||||
|
||||
target_name = deal.target_company_name or "الطرف الآخر"
|
||||
target = None
|
||||
if deal.target_profile_id:
|
||||
t_result = await db.execute(
|
||||
select(CompanyProfile).where(CompanyProfile.id == deal.target_profile_id)
|
||||
)
|
||||
target = t_result.scalar_one_or_none()
|
||||
if target:
|
||||
target_name = target.company_name
|
||||
|
||||
context = f"""Our company: {initiator.company_name if initiator else 'unknown'}
|
||||
Our industry: {initiator.industry if initiator else 'unknown'}
|
||||
Our capabilities: {', '.join((initiator.capabilities or [])[:8]) if initiator else 'unknown'}
|
||||
|
||||
Target company: {target_name}
|
||||
Target industry: {target.industry if target else 'unknown'}
|
||||
Target capabilities: {', '.join((target.capabilities or [])[:8]) if target else 'unknown'}
|
||||
Target needs: {', '.join((target.needs or [])[:8]) if target else 'unknown'}
|
||||
|
||||
Deal: {deal.deal_title}
|
||||
Deal type: {deal.deal_type}
|
||||
Our offer: {deal.our_offer or 'TBD'}
|
||||
Our need: {deal.our_need or 'TBD'}
|
||||
Proposed terms: {json.dumps(deal.proposed_terms or {}, ensure_ascii=False)}
|
||||
Estimated value: {deal.estimated_value_sar or 'TBD'} SAR"""
|
||||
|
||||
system_prompt = """أنت كاتب مقترحات أعمال سعودي محترف.
|
||||
أنشئ مقترح أعمال شامل ومهني باللغة العربية.
|
||||
|
||||
Return JSON:
|
||||
{
|
||||
"title_ar": "عنوان المقترح",
|
||||
"executive_summary_ar": "الملخص التنفيذي (3-5 جمل)",
|
||||
"about_us_ar": "نبذة عن شركتنا",
|
||||
"understanding_your_needs_ar": "فهمنا لاحتياجاتكم",
|
||||
"proposed_solution_ar": "الحل المقترح",
|
||||
"our_capabilities_ar": ["قدرة 1", "قدرة 2"],
|
||||
"mutual_benefits_ar": ["منفعة مشتركة 1", "منفعة مشتركة 2"],
|
||||
"deal_structure_ar": "هيكل الصفقة",
|
||||
"financial_overview_ar": "النظرة المالية",
|
||||
"timeline_ar": [
|
||||
{"phase_ar": "المرحلة", "duration_ar": "المدة", "deliverables_ar": "المخرجات"}
|
||||
],
|
||||
"success_metrics_ar": ["مؤشر نجاح 1", "مؤشر نجاح 2"],
|
||||
"risks_and_mitigations_ar": [
|
||||
{"risk_ar": "المخاطرة", "mitigation_ar": "التخفيف"}
|
||||
],
|
||||
"next_steps_ar": ["خطوة 1", "خطوة 2"],
|
||||
"closing_statement_ar": "الخاتمة"
|
||||
}"""
|
||||
|
||||
llm_response = await self.llm.complete(
|
||||
system_prompt=system_prompt,
|
||||
user_message=context,
|
||||
json_mode=True,
|
||||
temperature=0.4,
|
||||
)
|
||||
proposal = llm_response.parse_json() or {}
|
||||
|
||||
logger.info("Generated proposal for deal %s", deal_id)
|
||||
return proposal
|
||||
|
||||
# ── Discovery Scan ───────────────────────────────────────────────────────
|
||||
|
||||
async def run_discovery_scan(
|
||||
self,
|
||||
profile_id,
|
||||
deal_type: Optional[str],
|
||||
db: AsyncSession,
|
||||
) -> list[DealMatch]:
|
||||
"""
|
||||
Full autonomous scan: analyze profile, find matches, generate outreach plan.
|
||||
فحص مستقل كامل: تحليل الملف، إيجاد مطابقات، تجهيز خطة تواصل
|
||||
"""
|
||||
from app.services.strategic_deals.company_profiler import CompanyProfiler
|
||||
from app.services.strategic_deals.deal_matcher import DealMatcher
|
||||
|
||||
profiler = CompanyProfiler()
|
||||
matcher = DealMatcher()
|
||||
|
||||
# Step 1: Enrich profile if needed
|
||||
prof_result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == profile_id))
|
||||
profile = prof_result.scalar_one_or_none()
|
||||
if not profile:
|
||||
raise ValueError(f"Profile {profile_id} not found")
|
||||
|
||||
if not profile.capabilities or len(profile.capabilities) < 2:
|
||||
logger.info("Discovery scan: enriching profile %s first", profile_id)
|
||||
await profiler.enrich_profile(profile_id, db)
|
||||
|
||||
# Step 2: Analyze capabilities if thin
|
||||
if not profile.capabilities or len(profile.capabilities) < 3:
|
||||
await profiler.analyze_capabilities(profile_id, db)
|
||||
|
||||
# Step 3: Find matches
|
||||
matches = await matcher.find_matches(
|
||||
profile_id=profile_id,
|
||||
deal_type=deal_type,
|
||||
db=db,
|
||||
limit=10,
|
||||
)
|
||||
|
||||
# Step 4: Generate deal structure suggestions for top matches
|
||||
for match in matches[:3]:
|
||||
try:
|
||||
await matcher.suggest_deal_structure(match.id, db)
|
||||
except Exception as e:
|
||||
logger.warning("Could not suggest structure for match %s: %s", match.id, e)
|
||||
|
||||
# Step 5: Generate Arabic summary
|
||||
if matches:
|
||||
summary_parts = [f"تم العثور على {len(matches)} فرصة شراكة محتملة:"]
|
||||
for i, m in enumerate(matches[:5], 1):
|
||||
target_name = m.company_b_name or "شركة"
|
||||
if m.company_b_id:
|
||||
b_res = await db.execute(
|
||||
select(CompanyProfile).where(CompanyProfile.id == m.company_b_id)
|
||||
)
|
||||
b_prof = b_res.scalar_one_or_none()
|
||||
if b_prof:
|
||||
target_name = b_prof.company_name
|
||||
reasons = ", ".join((m.match_reasons or [])[:2])
|
||||
summary_parts.append(
|
||||
f"{i}. {target_name} (نسبة التوافق: {m.match_score:.0%}) — {reasons}"
|
||||
)
|
||||
summary = "\n".join(summary_parts)
|
||||
logger.info("Discovery scan summary:\n%s", summary)
|
||||
|
||||
logger.info("Discovery scan complete for profile %s: %d matches", profile_id, len(matches))
|
||||
return matches
|
||||
|
||||
# ── Private Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
async def _research_target(
|
||||
self,
|
||||
company_a: CompanyProfile,
|
||||
company_b: Optional[CompanyProfile],
|
||||
match: DealMatch,
|
||||
) -> dict:
|
||||
"""Research the target company to personalize outreach."""
|
||||
target_name = company_b.company_name if company_b else (match.company_b_name or "unknown")
|
||||
target_industry = company_b.industry if company_b else ""
|
||||
target_caps = ", ".join((company_b.capabilities or [])[:5]) if company_b else ""
|
||||
target_needs = ", ".join((company_b.needs or [])[:5]) if company_b else ""
|
||||
|
||||
context = f"""Our company: {company_a.company_name}
|
||||
Our capabilities: {', '.join((company_a.capabilities or [])[:5])}
|
||||
Our needs: {', '.join((company_a.needs or [])[:5])}
|
||||
|
||||
Target: {target_name}
|
||||
Industry: {target_industry}
|
||||
Capabilities: {target_caps}
|
||||
Needs: {target_needs}
|
||||
Match score: {match.match_score}
|
||||
Match reasons: {', '.join(match.match_reasons or [])}"""
|
||||
|
||||
system_prompt = """أنت باحث أعمال. حلل الشركة المستهدفة وجهز نقاط للتواصل.
|
||||
|
||||
Return JSON:
|
||||
{
|
||||
"our_value_proposition": "ما نقدمه لهم بجملة واحدة",
|
||||
"what_we_need_from_them": "ما نحتاجه منهم بجملة واحدة",
|
||||
"key_talking_points_ar": ["نقطة حوار 1", "نقطة حوار 2"],
|
||||
"potential_objections_ar": ["اعتراض محتمل 1"],
|
||||
"recommended_approach_ar": "النهج الموصى به"
|
||||
}"""
|
||||
|
||||
try:
|
||||
llm_response = await self.llm.complete(
|
||||
system_prompt=system_prompt,
|
||||
user_message=context,
|
||||
json_mode=True,
|
||||
temperature=0.3,
|
||||
fast=True,
|
||||
)
|
||||
return llm_response.parse_json() or {}
|
||||
except Exception as e:
|
||||
logger.warning("Target research failed: %s", e)
|
||||
return {
|
||||
"our_value_proposition": "",
|
||||
"what_we_need_from_them": "",
|
||||
"key_talking_points_ar": [],
|
||||
"potential_objections_ar": [],
|
||||
"recommended_approach_ar": "",
|
||||
}
|
||||
@ -0,0 +1,572 @@
|
||||
"""
|
||||
Deal Matcher — AI-powered B2B matching engine.
|
||||
محرك المطابقة: يجد الشركاء المثاليين باستخدام الذكاء الاصطناعي
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, and_, or_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.strategic_deal import (
|
||||
CompanyProfile, DealMatch, MatchStatus, DealType,
|
||||
)
|
||||
from app.services.llm.provider import get_llm
|
||||
|
||||
logger = logging.getLogger("dealix.strategic_deals.matcher")
|
||||
|
||||
# ── Matching weights ─────────────────────────────────────────────────────────
|
||||
|
||||
MATCH_WEIGHTS = {
|
||||
"capability_complementarity": 0.30,
|
||||
"need_alignment": 0.25,
|
||||
"industry_fit": 0.15,
|
||||
"geographic_fit": 0.10,
|
||||
"size_compatibility": 0.10,
|
||||
"trust_score": 0.10,
|
||||
}
|
||||
|
||||
# Industry adjacency: industries that typically partner together
|
||||
INDUSTRY_ADJACENCY = {
|
||||
"technology": ["consulting", "finance", "healthcare", "education", "retail"],
|
||||
"construction": ["real_estate", "manufacturing", "logistics", "energy"],
|
||||
"real_estate": ["construction", "finance", "marketing"],
|
||||
"retail": ["wholesale", "logistics", "marketing", "technology"],
|
||||
"wholesale": ["retail", "manufacturing", "logistics"],
|
||||
"healthcare": ["technology", "consulting", "manufacturing"],
|
||||
"education": ["technology", "consulting", "media"],
|
||||
"food_beverage": ["logistics", "retail", "agriculture", "tourism"],
|
||||
"logistics": ["retail", "wholesale", "manufacturing", "food_beverage"],
|
||||
"finance": ["technology", "real_estate", "consulting"],
|
||||
"energy": ["construction", "manufacturing", "technology"],
|
||||
"tourism": ["food_beverage", "marketing", "media"],
|
||||
"consulting": ["technology", "finance", "healthcare", "education"],
|
||||
"marketing": ["technology", "media", "retail", "tourism"],
|
||||
"agriculture": ["food_beverage", "logistics", "manufacturing"],
|
||||
"telecom": ["technology", "media", "consulting"],
|
||||
"media": ["marketing", "technology", "telecom", "tourism"],
|
||||
"automotive": ["manufacturing", "logistics", "finance"],
|
||||
"manufacturing": ["construction", "wholesale", "logistics", "energy", "automotive"],
|
||||
"government": ["technology", "consulting", "construction"],
|
||||
}
|
||||
|
||||
# Regions that commonly trade together
|
||||
REGION_PROXIMITY = {
|
||||
"الرياض": ["القصيم", "المنطقة الشرقية"],
|
||||
"مكة المكرمة": ["المدينة المنورة", "الباحة", "عسير"],
|
||||
"المنطقة الشرقية": ["الرياض", "الحدود الشمالية"],
|
||||
"المدينة المنورة": ["مكة المكرمة", "تبوك"],
|
||||
"القصيم": ["الرياض", "حائل"],
|
||||
"عسير": ["مكة المكرمة", "جازان", "نجران", "الباحة"],
|
||||
"تبوك": ["المدينة المنورة", "الجوف"],
|
||||
"حائل": ["القصيم", "الحدود الشمالية", "الجوف"],
|
||||
"الحدود الشمالية": ["حائل", "الجوف", "المنطقة الشرقية"],
|
||||
"جازان": ["عسير", "نجران"],
|
||||
"نجران": ["عسير", "جازان"],
|
||||
"الباحة": ["عسير", "مكة المكرمة"],
|
||||
"الجوف": ["تبوك", "الحدود الشمالية", "حائل"],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchScore:
|
||||
"""Detailed breakdown of a match score."""
|
||||
total: float = 0.0
|
||||
capability_complementarity: float = 0.0
|
||||
need_alignment: float = 0.0
|
||||
industry_fit: float = 0.0
|
||||
geographic_fit: float = 0.0
|
||||
size_compatibility: float = 0.0
|
||||
trust_score: float = 0.0
|
||||
reasons_ar: list[str] = field(default_factory=list)
|
||||
reasons_en: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class DealMatcher:
|
||||
"""
|
||||
AI-powered B2B matching engine that finds optimal partners.
|
||||
محرك مطابقة بالذكاء الاصطناعي يجد الشركاء المثاليين
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.llm = get_llm()
|
||||
|
||||
# ── Find Matches ─────────────────────────────────────────────────────────
|
||||
|
||||
async def find_matches(
|
||||
self,
|
||||
profile_id,
|
||||
deal_type: Optional[str],
|
||||
db: AsyncSession,
|
||||
limit: int = 10,
|
||||
) -> list[DealMatch]:
|
||||
"""
|
||||
Score and rank potential matches for a company profile.
|
||||
تقييم وترتيب المطابقات المحتملة لملف شركة
|
||||
"""
|
||||
result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == profile_id))
|
||||
source = result.scalar_one_or_none()
|
||||
if not source:
|
||||
raise ValueError(f"Profile {profile_id} not found")
|
||||
|
||||
# Fetch candidate profiles from the same tenant, excluding self
|
||||
candidates_q = select(CompanyProfile).where(
|
||||
CompanyProfile.tenant_id == source.tenant_id,
|
||||
CompanyProfile.id != profile_id,
|
||||
CompanyProfile.is_verified == True, # noqa: E712
|
||||
)
|
||||
candidates_result = await db.execute(candidates_q)
|
||||
candidates = candidates_result.scalars().all()
|
||||
|
||||
if not candidates:
|
||||
logger.info("No verified candidate profiles found for tenant %s", source.tenant_id)
|
||||
return []
|
||||
|
||||
# Score each candidate
|
||||
scored: list[tuple[CompanyProfile, MatchScore]] = []
|
||||
for candidate in candidates:
|
||||
match_score = await self.score_match(
|
||||
company_a=source,
|
||||
company_b=candidate,
|
||||
deal_type=deal_type,
|
||||
)
|
||||
if match_score.total >= 0.2: # Minimum threshold
|
||||
scored.append((candidate, match_score))
|
||||
|
||||
# Sort by score descending, take top N
|
||||
scored.sort(key=lambda x: x[1].total, reverse=True)
|
||||
scored = scored[:limit]
|
||||
|
||||
# Persist matches
|
||||
matches = []
|
||||
for candidate, ms in scored:
|
||||
match = DealMatch(
|
||||
tenant_id=source.tenant_id,
|
||||
company_a_id=source.id,
|
||||
company_b_id=candidate.id,
|
||||
match_score=round(ms.total, 4),
|
||||
match_reasons=ms.reasons_ar,
|
||||
deal_type_suggested=deal_type or self._suggest_deal_type(source, candidate),
|
||||
terms_suggested={},
|
||||
status=MatchStatus.SUGGESTED.value,
|
||||
)
|
||||
db.add(match)
|
||||
matches.append(match)
|
||||
|
||||
await db.flush()
|
||||
for m in matches:
|
||||
await db.refresh(m)
|
||||
|
||||
logger.info(
|
||||
"Found %d matches for profile %s (from %d candidates)",
|
||||
len(matches), profile_id, len(candidates),
|
||||
)
|
||||
return matches
|
||||
|
||||
# ── Detailed Scoring ─────────────────────────────────────────────────────
|
||||
|
||||
async def score_match(
|
||||
self,
|
||||
company_a: CompanyProfile,
|
||||
company_b: CompanyProfile,
|
||||
deal_type: Optional[str] = None,
|
||||
) -> MatchScore:
|
||||
"""
|
||||
Compute detailed match score between two companies.
|
||||
حساب درجة المطابقة التفصيلية بين شركتين
|
||||
"""
|
||||
ms = MatchScore()
|
||||
|
||||
# 1. Capability complementarity (0.30): A offers what B needs
|
||||
cap_score = self._score_overlap(
|
||||
company_a.capabilities or [],
|
||||
company_b.needs or [],
|
||||
)
|
||||
ms.capability_complementarity = cap_score
|
||||
if cap_score > 0.5:
|
||||
ms.reasons_ar.append(
|
||||
f"شركة {company_a.company_name} تقدم خدمات تحتاجها شركة {company_b.company_name}"
|
||||
)
|
||||
ms.reasons_en.append(
|
||||
f"{company_a.company_name} offers what {company_b.company_name} needs"
|
||||
)
|
||||
|
||||
# 2. Need alignment (0.25): B offers what A needs
|
||||
need_score = self._score_overlap(
|
||||
company_b.capabilities or [],
|
||||
company_a.needs or [],
|
||||
)
|
||||
ms.need_alignment = need_score
|
||||
if need_score > 0.5:
|
||||
ms.reasons_ar.append(
|
||||
f"شركة {company_b.company_name} تقدم خدمات تحتاجها شركة {company_a.company_name}"
|
||||
)
|
||||
ms.reasons_en.append(
|
||||
f"{company_b.company_name} offers what {company_a.company_name} needs"
|
||||
)
|
||||
|
||||
# 3. Industry fit (0.15): same value chain or adjacent
|
||||
ind_score = self._score_industry_fit(company_a.industry, company_b.industry)
|
||||
ms.industry_fit = ind_score
|
||||
if ind_score > 0.5:
|
||||
ms.reasons_ar.append("القطاعان متكاملان في سلسلة القيمة")
|
||||
|
||||
# 4. Geographic fit (0.10): same region or complementary
|
||||
geo_score = self._score_geographic_fit(company_a.region, company_b.region)
|
||||
ms.geographic_fit = geo_score
|
||||
if geo_score >= 1.0:
|
||||
ms.reasons_ar.append(f"الشركتان في نفس المنطقة: {company_a.region}")
|
||||
elif geo_score >= 0.7:
|
||||
ms.reasons_ar.append("الشركتان في مناطق متقاربة")
|
||||
|
||||
# 5. Size compatibility (0.10): appropriate size ratio
|
||||
size_score = self._score_size_compatibility(company_a, company_b)
|
||||
ms.size_compatibility = size_score
|
||||
if size_score > 0.7:
|
||||
ms.reasons_ar.append("حجم الشركتين متناسب للشراكة")
|
||||
|
||||
# 6. Trust score (0.10): verification and history
|
||||
trust_a = company_a.trust_score or 0.0
|
||||
trust_b = company_b.trust_score or 0.0
|
||||
ms.trust_score = (trust_a + trust_b) / 2.0
|
||||
if ms.trust_score > 0.7:
|
||||
ms.reasons_ar.append("كلا الشركتين حاصلتان على درجة ثقة عالية")
|
||||
|
||||
# If keyword overlap is low, use LLM for semantic matching
|
||||
if cap_score < 0.3 and need_score < 0.3:
|
||||
semantic = await self._semantic_match(company_a, company_b, deal_type)
|
||||
ms.capability_complementarity = max(ms.capability_complementarity, semantic.get("cap", 0))
|
||||
ms.need_alignment = max(ms.need_alignment, semantic.get("need", 0))
|
||||
if semantic.get("reason_ar"):
|
||||
ms.reasons_ar.append(semantic["reason_ar"])
|
||||
|
||||
# Weighted total
|
||||
ms.total = (
|
||||
ms.capability_complementarity * MATCH_WEIGHTS["capability_complementarity"]
|
||||
+ ms.need_alignment * MATCH_WEIGHTS["need_alignment"]
|
||||
+ ms.industry_fit * MATCH_WEIGHTS["industry_fit"]
|
||||
+ ms.geographic_fit * MATCH_WEIGHTS["geographic_fit"]
|
||||
+ ms.size_compatibility * MATCH_WEIGHTS["size_compatibility"]
|
||||
+ ms.trust_score * MATCH_WEIGHTS["trust_score"]
|
||||
)
|
||||
ms.total = round(min(1.0, ms.total), 4)
|
||||
|
||||
return ms
|
||||
|
||||
# ── Suggest Deal Structure ───────────────────────────────────────────────
|
||||
|
||||
async def suggest_deal_structure(
|
||||
self,
|
||||
match_id,
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""
|
||||
AI suggests deal type, key terms, pricing, timeline.
|
||||
الذكاء الاصطناعي يقترح هيكل الصفقة
|
||||
"""
|
||||
result = await db.execute(select(DealMatch).where(DealMatch.id == match_id))
|
||||
match = result.scalar_one_or_none()
|
||||
if not match:
|
||||
raise ValueError(f"Match {match_id} not found")
|
||||
|
||||
# Load both company profiles
|
||||
a_result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == match.company_a_id))
|
||||
company_a = a_result.scalar_one_or_none()
|
||||
|
||||
company_b = None
|
||||
if match.company_b_id:
|
||||
b_result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == match.company_b_id))
|
||||
company_b = b_result.scalar_one_or_none()
|
||||
|
||||
company_b_name = company_b.company_name if company_b else (match.company_b_name or "شركة خارجية")
|
||||
company_b_caps = company_b.capabilities if company_b else []
|
||||
company_b_needs = company_b.needs if company_b else []
|
||||
|
||||
context = f"""Company A: {company_a.company_name}
|
||||
Industry: {company_a.industry or 'unknown'}
|
||||
Capabilities: {', '.join(company_a.capabilities or [])}
|
||||
Needs: {', '.join(company_a.needs or [])}
|
||||
Revenue SAR: {company_a.annual_revenue_sar or 'unknown'}
|
||||
|
||||
Company B: {company_b_name}
|
||||
Industry: {(company_b.industry if company_b else 'unknown')}
|
||||
Capabilities: {', '.join(company_b_caps)}
|
||||
Needs: {', '.join(company_b_needs)}
|
||||
|
||||
Match score: {match.match_score}
|
||||
Match reasons: {', '.join(match.match_reasons or [])}
|
||||
Suggested deal type: {match.deal_type_suggested}"""
|
||||
|
||||
system_prompt = """أنت مستشار صفقات استراتيجية سعودي خبير. اقترح هيكل صفقة مفصل بين الشركتين.
|
||||
|
||||
Return JSON:
|
||||
{
|
||||
"deal_type": "partnership/distribution/franchise/jv/referral/acquisition/barter",
|
||||
"deal_title_ar": "عنوان الصفقة بالعربي",
|
||||
"deal_title_en": "Deal title in English",
|
||||
"proposed_terms": {
|
||||
"equity_split": "50/50 or other",
|
||||
"revenue_share": "percentage or fixed",
|
||||
"territory": "geographic scope",
|
||||
"exclusivity": true/false,
|
||||
"duration_months": 12,
|
||||
"payment_terms": "description",
|
||||
"kpis": ["key performance indicators"]
|
||||
},
|
||||
"estimated_value_sar": 0,
|
||||
"timeline": {
|
||||
"negotiation_weeks": 2,
|
||||
"due_diligence_weeks": 4,
|
||||
"launch_weeks": 8
|
||||
},
|
||||
"mutual_benefits_ar": ["المنفعة المشتركة 1", "المنفعة المشتركة 2"],
|
||||
"risks_ar": ["المخاطر المحتملة"],
|
||||
"proposal_summary_ar": "ملخص المقترح بالعربي",
|
||||
"next_steps_ar": ["الخطوة التالية 1", "الخطوة التالية 2"]
|
||||
}"""
|
||||
|
||||
llm_response = await self.llm.complete(
|
||||
system_prompt=system_prompt,
|
||||
user_message=context,
|
||||
json_mode=True,
|
||||
temperature=0.4,
|
||||
)
|
||||
structure = llm_response.parse_json() or {}
|
||||
|
||||
# Persist suggested terms on the match
|
||||
if structure.get("proposed_terms"):
|
||||
match.terms_suggested = structure
|
||||
await db.flush()
|
||||
|
||||
logger.info("Suggested deal structure for match %s", match_id)
|
||||
return structure
|
||||
|
||||
# ── Barter Chain Discovery ───────────────────────────────────────────────
|
||||
|
||||
async def find_barter_chains(
|
||||
self,
|
||||
profile_id,
|
||||
db: AsyncSession,
|
||||
) -> list[list[dict]]:
|
||||
"""
|
||||
Discover multi-party barter opportunities: A->B->C->A circular trades.
|
||||
اكتشاف فرص المقايضة المتعددة الأطراف: سلاسل تبادل دائرية
|
||||
شركتك عندها تسويق، الشركة ب تحتاج تسويق وعندها مساحات،
|
||||
والشركة ج تحتاج مساحات وعندها تطوير برمجي اللي أنت تحتاجه
|
||||
"""
|
||||
result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == profile_id))
|
||||
source = result.scalar_one_or_none()
|
||||
if not source:
|
||||
raise ValueError(f"Profile {profile_id} not found")
|
||||
|
||||
source_caps = set(c.lower() for c in (source.capabilities or []))
|
||||
source_needs = set(n.lower() for n in (source.needs or []))
|
||||
|
||||
if not source_caps or not source_needs:
|
||||
return []
|
||||
|
||||
# Fetch all profiles in tenant
|
||||
all_q = select(CompanyProfile).where(
|
||||
CompanyProfile.tenant_id == source.tenant_id,
|
||||
CompanyProfile.id != profile_id,
|
||||
)
|
||||
all_result = await db.execute(all_q)
|
||||
all_profiles = all_result.scalars().all()
|
||||
|
||||
# Build capability/need index
|
||||
profiles_by_cap: dict[str, list[CompanyProfile]] = {}
|
||||
profiles_by_need: dict[str, list[CompanyProfile]] = {}
|
||||
for p in all_profiles:
|
||||
for cap in (p.capabilities or []):
|
||||
profiles_by_cap.setdefault(cap.lower(), []).append(p)
|
||||
for need in (p.needs or []):
|
||||
profiles_by_need.setdefault(need.lower(), []).append(p)
|
||||
|
||||
chains: list[list[dict]] = []
|
||||
|
||||
# Find 3-party chains: source->B->C->source
|
||||
for source_cap in source_caps:
|
||||
# B needs what source offers
|
||||
for b_profile in profiles_by_need.get(source_cap, []):
|
||||
b_caps = set(c.lower() for c in (b_profile.capabilities or []))
|
||||
for b_cap in b_caps:
|
||||
# C needs what B offers and C has what source needs
|
||||
for c_profile in profiles_by_need.get(b_cap, []):
|
||||
if c_profile.id == source.id or c_profile.id == b_profile.id:
|
||||
continue
|
||||
c_caps = set(c.lower() for c in (c_profile.capabilities or []))
|
||||
# Does C offer what source needs?
|
||||
overlap = c_caps & source_needs
|
||||
if overlap:
|
||||
chain = [
|
||||
{
|
||||
"company_id": str(source.id),
|
||||
"company_name": source.company_name,
|
||||
"offers": source_cap,
|
||||
"receives": list(overlap)[0],
|
||||
},
|
||||
{
|
||||
"company_id": str(b_profile.id),
|
||||
"company_name": b_profile.company_name,
|
||||
"offers": b_cap,
|
||||
"receives": source_cap,
|
||||
},
|
||||
{
|
||||
"company_id": str(c_profile.id),
|
||||
"company_name": c_profile.company_name,
|
||||
"offers": list(overlap)[0],
|
||||
"receives": b_cap,
|
||||
},
|
||||
]
|
||||
chains.append(chain)
|
||||
if len(chains) >= 10:
|
||||
break
|
||||
if len(chains) >= 10:
|
||||
break
|
||||
if len(chains) >= 10:
|
||||
break
|
||||
if len(chains) >= 10:
|
||||
break
|
||||
|
||||
# Deduplicate chains by company set
|
||||
seen_sets: set[frozenset] = set()
|
||||
unique_chains = []
|
||||
for chain in chains:
|
||||
company_set = frozenset(link["company_id"] for link in chain)
|
||||
if company_set not in seen_sets:
|
||||
seen_sets.add(company_set)
|
||||
unique_chains.append(chain)
|
||||
|
||||
logger.info(
|
||||
"Found %d barter chains for profile %s", len(unique_chains), profile_id,
|
||||
)
|
||||
return unique_chains
|
||||
|
||||
# ── Private Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
def _score_overlap(self, offers: list[str], needs: list[str]) -> float:
|
||||
"""Score overlap between what one company offers and another needs."""
|
||||
if not offers or not needs:
|
||||
return 0.0
|
||||
offers_lower = {o.lower().strip() for o in offers}
|
||||
needs_lower = {n.lower().strip() for n in needs}
|
||||
if not needs_lower:
|
||||
return 0.0
|
||||
overlap = offers_lower & needs_lower
|
||||
# Also check partial substring matches
|
||||
partial = 0
|
||||
for o in offers_lower:
|
||||
for n in needs_lower:
|
||||
if n not in overlap and o not in overlap:
|
||||
if o in n or n in o:
|
||||
partial += 0.5
|
||||
total_matches = len(overlap) + partial
|
||||
return min(1.0, total_matches / len(needs_lower))
|
||||
|
||||
def _score_industry_fit(self, ind_a: Optional[str], ind_b: Optional[str]) -> float:
|
||||
"""Score industry compatibility."""
|
||||
if not ind_a or not ind_b:
|
||||
return 0.3 # Unknown = neutral
|
||||
if ind_a == ind_b:
|
||||
return 1.0
|
||||
adjacent = INDUSTRY_ADJACENCY.get(ind_a, [])
|
||||
if ind_b in adjacent:
|
||||
return 0.7
|
||||
# Check reverse
|
||||
adjacent_b = INDUSTRY_ADJACENCY.get(ind_b, [])
|
||||
if ind_a in adjacent_b:
|
||||
return 0.7
|
||||
return 0.2
|
||||
|
||||
def _score_geographic_fit(self, region_a: Optional[str], region_b: Optional[str]) -> float:
|
||||
"""Score geographic proximity."""
|
||||
if not region_a or not region_b:
|
||||
return 0.5 # Unknown = neutral
|
||||
if region_a == region_b:
|
||||
return 1.0
|
||||
nearby = REGION_PROXIMITY.get(region_a, [])
|
||||
if region_b in nearby:
|
||||
return 0.7
|
||||
return 0.3
|
||||
|
||||
def _score_size_compatibility(
|
||||
self, a: CompanyProfile, b: CompanyProfile,
|
||||
) -> float:
|
||||
"""Score size compatibility for deals. Very large + very small = low fit."""
|
||||
emp_a = float(a.employee_count or 0)
|
||||
emp_b = float(b.employee_count or 0)
|
||||
if emp_a == 0 or emp_b == 0:
|
||||
return 0.5 # Unknown = neutral
|
||||
ratio = min(emp_a, emp_b) / max(emp_a, emp_b)
|
||||
# Ratios > 0.1 are generally workable
|
||||
if ratio >= 0.3:
|
||||
return 1.0
|
||||
elif ratio >= 0.1:
|
||||
return 0.7
|
||||
elif ratio >= 0.01:
|
||||
return 0.4
|
||||
return 0.2
|
||||
|
||||
def _suggest_deal_type(self, a: CompanyProfile, b: CompanyProfile) -> str:
|
||||
"""Heuristic deal-type suggestion based on profiles."""
|
||||
prefs_a = a.deal_preferences or {}
|
||||
prefs_b = b.deal_preferences or {}
|
||||
|
||||
# Find mutually preferred deal type
|
||||
all_types = set(list(prefs_a.keys()) + list(prefs_b.keys()))
|
||||
if all_types:
|
||||
best_type = max(
|
||||
all_types,
|
||||
key=lambda t: (prefs_a.get(t, 0) + prefs_b.get(t, 0)),
|
||||
)
|
||||
return best_type
|
||||
|
||||
# Default based on industry relationship
|
||||
if a.industry == b.industry:
|
||||
return DealType.REFERRAL.value
|
||||
return DealType.PARTNERSHIP.value
|
||||
|
||||
async def _semantic_match(
|
||||
self,
|
||||
company_a: CompanyProfile,
|
||||
company_b: CompanyProfile,
|
||||
deal_type: Optional[str],
|
||||
) -> dict:
|
||||
"""Use LLM for semantic matching when keyword overlap is low."""
|
||||
context = f"""Company A: {company_a.company_name}
|
||||
Capabilities: {', '.join(company_a.capabilities or ['unknown'])}
|
||||
Needs: {', '.join(company_a.needs or ['unknown'])}
|
||||
Industry: {company_a.industry or 'unknown'}
|
||||
|
||||
Company B: {company_b.company_name}
|
||||
Capabilities: {', '.join(company_b.capabilities or ['unknown'])}
|
||||
Needs: {', '.join(company_b.needs or ['unknown'])}
|
||||
Industry: {company_b.industry or 'unknown'}
|
||||
|
||||
Deal type: {deal_type or 'any'}"""
|
||||
|
||||
system_prompt = """أنت محلل مطابقة بين الشركات. قيم مدى تكامل هاتين الشركتين.
|
||||
|
||||
Return JSON:
|
||||
{
|
||||
"cap": 0.0 to 1.0 (capability complementarity),
|
||||
"need": 0.0 to 1.0 (need alignment),
|
||||
"reason_ar": "سبب التكامل بالعربي أو null إذا لا يوجد تكامل"
|
||||
}"""
|
||||
|
||||
try:
|
||||
llm_response = await self.llm.complete(
|
||||
system_prompt=system_prompt,
|
||||
user_message=context,
|
||||
json_mode=True,
|
||||
temperature=0.2,
|
||||
fast=True,
|
||||
)
|
||||
result = llm_response.parse_json()
|
||||
return result if result else {"cap": 0, "need": 0, "reason_ar": None}
|
||||
except Exception as e:
|
||||
logger.warning("Semantic match failed: %s", e)
|
||||
return {"cap": 0, "need": 0, "reason_ar": None}
|
||||
@ -0,0 +1,479 @@
|
||||
"""
|
||||
Deal Negotiator — Autonomous AI negotiator for B2B deals.
|
||||
المفاوض الذكي: مفاوض آلي بالذكاء الاصطناعي للصفقات بين الشركات
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.strategic_deal import (
|
||||
StrategicDeal, CompanyProfile, DealStatus, DealType,
|
||||
)
|
||||
from app.services.llm.provider import get_llm
|
||||
|
||||
logger = logging.getLogger("dealix.strategic_deals.negotiator")
|
||||
|
||||
|
||||
# ── Models ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class NegotiationStrategy(BaseModel):
|
||||
"""Strategy configuration for autonomous negotiation."""
|
||||
target_terms: dict = {} # Ideal outcome
|
||||
acceptable_range: dict = {} # Min/max for each variable
|
||||
walk_away_point: dict = {} # Absolute limits / deal breakers
|
||||
priorities: list[str] = [] # Ordered from most to least important
|
||||
style: str = "collaborative" # collaborative, competitive, accommodating
|
||||
|
||||
|
||||
@dataclass
|
||||
class NegotiationRound:
|
||||
"""Result of a single negotiation round."""
|
||||
round_number: int = 0
|
||||
action: str = "" # opening_offer, counter_offer, acceptance, rejection, escalation
|
||||
our_terms: dict = field(default_factory=dict)
|
||||
their_terms: dict = field(default_factory=dict)
|
||||
message_ar: str = ""
|
||||
message_en: str = ""
|
||||
concessions_made: list[str] = field(default_factory=list)
|
||||
concessions_gained: list[str] = field(default_factory=list)
|
||||
within_range: bool = True
|
||||
confidence: float = 0.0
|
||||
timestamp: str = ""
|
||||
|
||||
|
||||
# ── Escalation thresholds ────────────────────────────────────────────────────
|
||||
|
||||
ESCALATION_VALUE_SAR = 500_000 # Deals above this need human oversight
|
||||
MAX_AUTO_ROUNDS = 5 # After this many rounds, escalate
|
||||
STALL_THRESHOLD = 3 # Same terms repeated this many times = stall
|
||||
|
||||
|
||||
class DealNegotiator:
|
||||
"""
|
||||
Autonomous AI negotiator that handles B2B deal negotiations.
|
||||
Respects Saudi business culture: relationship-first, patience, mutual respect.
|
||||
مفاوض ذكي يحترم ثقافة الأعمال السعودية
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.llm = get_llm()
|
||||
|
||||
# ── Start Negotiation ────────────────────────────────────────────────────
|
||||
|
||||
async def start_negotiation(
|
||||
self,
|
||||
deal_id,
|
||||
strategy: NegotiationStrategy,
|
||||
db: AsyncSession,
|
||||
) -> NegotiationRound:
|
||||
"""
|
||||
Generate opening offer based on strategy and Saudi negotiation culture.
|
||||
إنشاء العرض الأولي بناءً على الاستراتيجية وثقافة التفاوض السعودية
|
||||
"""
|
||||
deal = await self._load_deal(deal_id, db)
|
||||
|
||||
initiator = await self._load_profile(deal.initiator_profile_id, db)
|
||||
target_name = deal.target_company_name or "الطرف الآخر"
|
||||
if deal.target_profile_id:
|
||||
target = await self._load_profile(deal.target_profile_id, db)
|
||||
target_name = target.company_name if target else target_name
|
||||
|
||||
context = f"""Deal: {deal.deal_title}
|
||||
Deal type: {deal.deal_type}
|
||||
Our company: {initiator.company_name}
|
||||
Target company: {target_name}
|
||||
Our offer: {deal.our_offer or 'not specified'}
|
||||
Our need: {deal.our_need or 'not specified'}
|
||||
Strategy style: {strategy.style}
|
||||
Target terms: {json.dumps(strategy.target_terms, ensure_ascii=False)}
|
||||
Priorities: {', '.join(strategy.priorities)}"""
|
||||
|
||||
style_guidance = {
|
||||
"collaborative": "ابدأ بعرض عادل ومتوازن يظهر الرغبة في شراكة طويلة المدى",
|
||||
"competitive": "ابدأ بعرض طموح لكن معقول مع ترك مساحة للتفاوض",
|
||||
"accommodating": "ابدأ بعرض سخي يظهر حسن النية والرغبة في بناء علاقة",
|
||||
}
|
||||
|
||||
system_prompt = f"""أنت مفاوض أعمال سعودي محترف. أنشئ عرضاً أولياً للصفقة.
|
||||
|
||||
التوجيه: {style_guidance.get(strategy.style, style_guidance['collaborative'])}
|
||||
|
||||
Important Saudi negotiation culture:
|
||||
- Start with relationship building (سلامات واستفسار عن الأحوال)
|
||||
- Show respect for the other party
|
||||
- Be patient, don't rush to numbers
|
||||
- Present win-win framing
|
||||
|
||||
Return JSON:
|
||||
{{
|
||||
"opening_terms": {{"key": "value for each negotiable item"}},
|
||||
"message_ar": "رسالة العرض الأولي بالعربي (تبدأ بالسلام والتحية)",
|
||||
"message_en": "Opening message in English",
|
||||
"rationale_ar": "مبررات العرض",
|
||||
"confidence": 0.0 to 1.0
|
||||
}}"""
|
||||
|
||||
llm_response = await self.llm.complete(
|
||||
system_prompt=system_prompt,
|
||||
user_message=context,
|
||||
json_mode=True,
|
||||
temperature=0.4,
|
||||
)
|
||||
result = llm_response.parse_json() or {}
|
||||
|
||||
now_str = datetime.now(timezone.utc).isoformat()
|
||||
round_data = NegotiationRound(
|
||||
round_number=1,
|
||||
action="opening_offer",
|
||||
our_terms=result.get("opening_terms", strategy.target_terms),
|
||||
their_terms={},
|
||||
message_ar=result.get("message_ar", ""),
|
||||
message_en=result.get("message_en", ""),
|
||||
concessions_made=[],
|
||||
concessions_gained=[],
|
||||
within_range=True,
|
||||
confidence=result.get("confidence", 0.5),
|
||||
timestamp=now_str,
|
||||
)
|
||||
|
||||
# Update deal
|
||||
deal.proposed_terms = round_data.our_terms
|
||||
deal.status = DealStatus.NEGOTIATING.value
|
||||
deal.ai_confidence = round_data.confidence
|
||||
history = list(deal.negotiation_history or [])
|
||||
history.append({
|
||||
"round": round_data.round_number,
|
||||
"action": round_data.action,
|
||||
"our_terms": round_data.our_terms,
|
||||
"their_terms": round_data.their_terms,
|
||||
"message_ar": round_data.message_ar,
|
||||
"timestamp": now_str,
|
||||
})
|
||||
deal.negotiation_history = history
|
||||
await db.flush()
|
||||
|
||||
logger.info("Started negotiation for deal %s (round 1)", deal_id)
|
||||
return round_data
|
||||
|
||||
# ── Handle Counter-Offer ─────────────────────────────────────────────────
|
||||
|
||||
async def handle_counter_offer(
|
||||
self,
|
||||
deal_id,
|
||||
their_terms: dict,
|
||||
db: AsyncSession,
|
||||
) -> NegotiationRound:
|
||||
"""
|
||||
Analyze a counter-offer and generate a response.
|
||||
تحليل عرض مضاد وتوليد رد مناسب
|
||||
"""
|
||||
deal = await self._load_deal(deal_id, db)
|
||||
history = list(deal.negotiation_history or [])
|
||||
round_num = len(history) + 1
|
||||
|
||||
# Get the latest strategy from proposed terms
|
||||
our_latest = deal.proposed_terms or {}
|
||||
|
||||
context = f"""Deal: {deal.deal_title}
|
||||
Deal type: {deal.deal_type}
|
||||
Our latest terms: {json.dumps(our_latest, ensure_ascii=False)}
|
||||
Their counter-offer: {json.dumps(their_terms, ensure_ascii=False)}
|
||||
Negotiation history (rounds): {len(history)}
|
||||
Estimated value SAR: {deal.estimated_value_sar or 'unknown'}"""
|
||||
|
||||
system_prompt = """أنت مفاوض أعمال سعودي محترف. الطرف الآخر قدم عرضاً مضاداً.
|
||||
|
||||
حلل العرض وقرر:
|
||||
1. هل العرض مقبول؟
|
||||
2. هل نحتاج عرض مضاد؟
|
||||
3. هل يجب رفع الموضوع لإنسان؟
|
||||
|
||||
Saudi culture: never be aggressive. Show appreciation for their offer before countering.
|
||||
Handle common responses: "غالي" (too expensive), "نبي نفكر" (need to think), "عندنا عرض ثاني" (we have another offer)
|
||||
|
||||
Return JSON:
|
||||
{
|
||||
"action": "accept/counter/reject/escalate",
|
||||
"counter_terms": {"key": "value"},
|
||||
"message_ar": "الرد بالعربي",
|
||||
"message_en": "Response in English",
|
||||
"concessions_made": ["what we gave up"],
|
||||
"concessions_gained": ["what we got"],
|
||||
"within_acceptable_range": true/false,
|
||||
"confidence": 0.0 to 1.0,
|
||||
"analysis_ar": "تحليل العرض المضاد"
|
||||
}"""
|
||||
|
||||
llm_response = await self.llm.complete(
|
||||
system_prompt=system_prompt,
|
||||
user_message=context,
|
||||
json_mode=True,
|
||||
temperature=0.3,
|
||||
)
|
||||
result = llm_response.parse_json() or {}
|
||||
|
||||
action = result.get("action", "counter")
|
||||
now_str = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
round_data = NegotiationRound(
|
||||
round_number=round_num,
|
||||
action=action,
|
||||
our_terms=result.get("counter_terms", our_latest),
|
||||
their_terms=their_terms,
|
||||
message_ar=result.get("message_ar", ""),
|
||||
message_en=result.get("message_en", ""),
|
||||
concessions_made=result.get("concessions_made", []),
|
||||
concessions_gained=result.get("concessions_gained", []),
|
||||
within_range=result.get("within_acceptable_range", True),
|
||||
confidence=result.get("confidence", 0.5),
|
||||
timestamp=now_str,
|
||||
)
|
||||
|
||||
# Update deal state
|
||||
if action == "accept":
|
||||
deal.agreed_terms = their_terms
|
||||
deal.status = DealStatus.TERM_SHEET.value
|
||||
elif action == "reject":
|
||||
deal.status = DealStatus.CLOSED_LOST.value
|
||||
deal.closed_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
deal.proposed_terms = round_data.our_terms
|
||||
|
||||
deal.ai_confidence = round_data.confidence
|
||||
history.append({
|
||||
"round": round_data.round_number,
|
||||
"action": action,
|
||||
"our_terms": round_data.our_terms,
|
||||
"their_terms": their_terms,
|
||||
"message_ar": round_data.message_ar,
|
||||
"timestamp": now_str,
|
||||
})
|
||||
deal.negotiation_history = history
|
||||
await db.flush()
|
||||
|
||||
logger.info("Handled counter-offer for deal %s (round %d, action=%s)", deal_id, round_num, action)
|
||||
return round_data
|
||||
|
||||
# ── Generate Negotiation Response ────────────────────────────────────────
|
||||
|
||||
async def generate_response(
|
||||
self,
|
||||
deal_id,
|
||||
message: str,
|
||||
db: AsyncSession,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a culturally appropriate Arabic/English negotiation response.
|
||||
توليد رد تفاوضي مناسب ثقافياً بالعربي أو الإنجليزي
|
||||
"""
|
||||
deal = await self._load_deal(deal_id, db)
|
||||
history = deal.negotiation_history or []
|
||||
|
||||
# Summarize negotiation context
|
||||
history_summary = ""
|
||||
for h in history[-3:]: # Last 3 rounds
|
||||
history_summary += f"Round {h.get('round', '?')}: {h.get('action', '?')} - {h.get('message_ar', '')[:100]}\n"
|
||||
|
||||
context = f"""Deal: {deal.deal_title}
|
||||
Deal type: {deal.deal_type}
|
||||
Current status: {deal.status}
|
||||
Our proposed terms: {json.dumps(deal.proposed_terms or {}, ensure_ascii=False)}
|
||||
Recent history:
|
||||
{history_summary}
|
||||
|
||||
Incoming message from counter-party: {message}"""
|
||||
|
||||
system_prompt = """أنت مفاوض أعمال سعودي محترف. رد على رسالة الطرف الآخر بشكل مناسب.
|
||||
|
||||
Rules:
|
||||
- إذا الرسالة بالعربي، رد بالعربي
|
||||
- إذا الرسالة بالإنجليزي، رد بالإنجليزي
|
||||
- كن محترماً وودوداً دائماً
|
||||
- لا تكن عدوانياً أبداً
|
||||
- حافظ على العلاقة حتى لو الصفقة لم تنجح
|
||||
|
||||
Handle:
|
||||
- "غالي" → أظهر المرونة واعرض بدائل
|
||||
- "نبي نفكر" → أعطهم وقت مع اقتراح موعد متابعة
|
||||
- "عندنا عرض ثاني" → أبرز المميزات الفريدة بدون تقليل المنافسين
|
||||
- "ما يناسبنا" → اسأل عن التفاصيل واعرض تعديلات
|
||||
|
||||
Return the response message directly as text (not JSON)."""
|
||||
|
||||
llm_response = await self.llm.complete(
|
||||
system_prompt=system_prompt,
|
||||
user_message=context,
|
||||
temperature=0.5,
|
||||
)
|
||||
response_text = llm_response.content.strip()
|
||||
|
||||
# Log the exchange in negotiation history
|
||||
history = list(deal.negotiation_history or [])
|
||||
history.append({
|
||||
"round": len(history) + 1,
|
||||
"action": "response",
|
||||
"their_message": message[:500],
|
||||
"our_response": response_text[:500],
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
deal.negotiation_history = history
|
||||
await db.flush()
|
||||
|
||||
logger.info("Generated negotiation response for deal %s", deal_id)
|
||||
return response_text
|
||||
|
||||
# ── Should Escalate? ─────────────────────────────────────────────────────
|
||||
|
||||
async def should_escalate(
|
||||
self,
|
||||
deal_id,
|
||||
db: AsyncSession,
|
||||
) -> bool:
|
||||
"""
|
||||
Determine if a human should take over the negotiation.
|
||||
تحديد ما إذا كان يجب تصعيد التفاوض لإنسان
|
||||
"""
|
||||
deal = await self._load_deal(deal_id, db)
|
||||
history = deal.negotiation_history or []
|
||||
round_count = len(history)
|
||||
|
||||
# Rule 1: High-value deals
|
||||
value = float(deal.estimated_value_sar or 0)
|
||||
if value > ESCALATION_VALUE_SAR:
|
||||
logger.info("Escalation: deal %s value (%.0f SAR) exceeds threshold", deal_id, value)
|
||||
return True
|
||||
|
||||
# Rule 2: Too many rounds without resolution
|
||||
if round_count >= MAX_AUTO_ROUNDS:
|
||||
logger.info("Escalation: deal %s reached %d rounds", deal_id, round_count)
|
||||
return True
|
||||
|
||||
# Rule 3: Stalled negotiation (same terms repeating)
|
||||
if round_count >= STALL_THRESHOLD:
|
||||
recent_terms = [
|
||||
json.dumps(h.get("our_terms", {}), sort_keys=True)
|
||||
for h in history[-STALL_THRESHOLD:]
|
||||
]
|
||||
if len(set(recent_terms)) == 1:
|
||||
logger.info("Escalation: deal %s stalled for %d rounds", deal_id, STALL_THRESHOLD)
|
||||
return True
|
||||
|
||||
# Rule 4: Low confidence
|
||||
if deal.ai_confidence is not None and deal.ai_confidence < 0.3:
|
||||
logger.info("Escalation: deal %s AI confidence too low (%.2f)", deal_id, deal.ai_confidence)
|
||||
return True
|
||||
|
||||
# Rule 5: Counter-party explicitly requested human contact
|
||||
if history:
|
||||
last_msg = (history[-1].get("their_message", "") or "").lower()
|
||||
human_keywords = [
|
||||
"أبي أكلم شخص", "أبي أكلم المدير", "ابي اتكلم مع انسان",
|
||||
"speak to someone", "talk to a person", "human", "manager",
|
||||
"مدير", "مسؤول",
|
||||
]
|
||||
for kw in human_keywords:
|
||||
if kw in last_msg:
|
||||
logger.info("Escalation: deal %s counter-party requested human", deal_id)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# ── Generate Term Sheet ──────────────────────────────────────────────────
|
||||
|
||||
async def generate_term_sheet(
|
||||
self,
|
||||
deal_id,
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""
|
||||
Generate a formal Arabic term sheet from agreed terms.
|
||||
إنشاء ورقة شروط رسمية بالعربي من الشروط المتفق عليها
|
||||
"""
|
||||
deal = await self._load_deal(deal_id, db)
|
||||
initiator = await self._load_profile(deal.initiator_profile_id, db)
|
||||
|
||||
target_name = deal.target_company_name or "الطرف الثاني"
|
||||
target_cr = ""
|
||||
if deal.target_profile_id:
|
||||
target = await self._load_profile(deal.target_profile_id, db)
|
||||
if target:
|
||||
target_name = target.company_name
|
||||
target_cr = target.cr_number or ""
|
||||
|
||||
terms = deal.agreed_terms or deal.proposed_terms or {}
|
||||
|
||||
context = f"""Parties:
|
||||
- Party A: {initiator.company_name} (CR: {initiator.cr_number or 'N/A'})
|
||||
- Party B: {target_name} (CR: {target_cr or 'N/A'})
|
||||
|
||||
Deal: {deal.deal_title}
|
||||
Deal type: {deal.deal_type}
|
||||
Agreed terms: {json.dumps(terms, ensure_ascii=False)}
|
||||
Estimated value: {deal.estimated_value_sar or 'TBD'} SAR
|
||||
Our offer: {deal.our_offer or 'N/A'}
|
||||
Our need: {deal.our_need or 'N/A'}"""
|
||||
|
||||
system_prompt = """أنت مستشار قانوني سعودي متخصص في صياغة أوراق الشروط.
|
||||
أنشئ ورقة شروط رسمية باللغة العربية.
|
||||
|
||||
Return JSON:
|
||||
{
|
||||
"title_ar": "عنوان ورقة الشروط",
|
||||
"date": "التاريخ",
|
||||
"parties": [
|
||||
{"name": "اسم الطرف", "role": "الطرف الأول/الطرف الثاني", "cr": "رقم السجل التجاري"}
|
||||
],
|
||||
"preamble_ar": "مقدمة ورقة الشروط",
|
||||
"scope_ar": "نطاق الاتفاقية",
|
||||
"terms": [
|
||||
{"title_ar": "عنوان البند", "description_ar": "تفاصيل البند"}
|
||||
],
|
||||
"obligations_party_a_ar": ["التزامات الطرف الأول"],
|
||||
"obligations_party_b_ar": ["التزامات الطرف الثاني"],
|
||||
"financial_terms_ar": "الشروط المالية",
|
||||
"duration_ar": "مدة الاتفاقية",
|
||||
"termination_ar": "شروط الإنهاء",
|
||||
"confidentiality_ar": "شرط السرية",
|
||||
"dispute_resolution_ar": "حل النزاعات",
|
||||
"governing_law_ar": "القانون الحاكم: أنظمة المملكة العربية السعودية",
|
||||
"next_steps_ar": ["الخطوات التالية"]
|
||||
}"""
|
||||
|
||||
llm_response = await self.llm.complete(
|
||||
system_prompt=system_prompt,
|
||||
user_message=context,
|
||||
json_mode=True,
|
||||
temperature=0.2,
|
||||
)
|
||||
term_sheet = llm_response.parse_json() or {}
|
||||
|
||||
# Update deal status
|
||||
if deal.status == DealStatus.NEGOTIATING.value:
|
||||
deal.status = DealStatus.TERM_SHEET.value
|
||||
await db.flush()
|
||||
|
||||
logger.info("Generated term sheet for deal %s", deal_id)
|
||||
return term_sheet
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def _load_deal(self, deal_id, db: AsyncSession) -> StrategicDeal:
|
||||
result = await db.execute(select(StrategicDeal).where(StrategicDeal.id == deal_id))
|
||||
deal = result.scalar_one_or_none()
|
||||
if not deal:
|
||||
raise ValueError(f"Deal {deal_id} not found")
|
||||
return deal
|
||||
|
||||
async def _load_profile(self, profile_id, db: AsyncSession) -> Optional[CompanyProfile]:
|
||||
if not profile_id:
|
||||
return None
|
||||
result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == profile_id))
|
||||
return result.scalar_one_or_none()
|
||||
674
salesflow-saas/backend/app/services/strategic_deals/deal_room.py
Normal file
674
salesflow-saas/backend/app/services/strategic_deals/deal_room.py
Normal file
@ -0,0 +1,674 @@
|
||||
"""
|
||||
Deal Room — Central workspace for managing an active B2B deal through all stages.
|
||||
غرفة الصفقة: مساحة العمل المركزية لإدارة صفقة B2B عبر جميع المراحل
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.strategic_deal import StrategicDeal, CompanyProfile, DealStatus
|
||||
from app.services.llm.provider import get_llm
|
||||
|
||||
logger = logging.getLogger("dealix.strategic_deals.deal_room")
|
||||
|
||||
|
||||
# ── Room Stages ─────────────────────────────────────────────────────────────
|
||||
|
||||
ROOM_STAGES = [
|
||||
"discovery",
|
||||
"qualification",
|
||||
"proposal",
|
||||
"negotiation",
|
||||
"legal",
|
||||
"approval",
|
||||
"closed_won",
|
||||
"closed_lost",
|
||||
]
|
||||
|
||||
STAGE_LABELS_AR = {
|
||||
"discovery": "اكتشاف",
|
||||
"qualification": "تأهيل",
|
||||
"proposal": "مقترح",
|
||||
"negotiation": "تفاوض",
|
||||
"legal": "مراجعة قانونية",
|
||||
"approval": "موافقة",
|
||||
"closed_won": "تمت بنجاح",
|
||||
"closed_lost": "لم تتم",
|
||||
}
|
||||
|
||||
|
||||
# ── Pydantic Models ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ConcessionRecord(BaseModel):
|
||||
"""Record of a single concession given or received."""
|
||||
what: str
|
||||
value_sar: float = 0.0
|
||||
direction: str = "given" # given or received
|
||||
timestamp: str = ""
|
||||
rationale: str = ""
|
||||
|
||||
|
||||
class ApprovalRequest(BaseModel):
|
||||
"""An approval request within a deal room."""
|
||||
approval_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
action: str
|
||||
details: str = ""
|
||||
requested_by: str = "ai_agent"
|
||||
requested_at: str = ""
|
||||
status: str = "pending" # pending, granted, denied
|
||||
decided_by: str = ""
|
||||
decided_at: str = ""
|
||||
notes: str = ""
|
||||
|
||||
|
||||
class AuditEntry(BaseModel):
|
||||
"""Immutable audit log entry."""
|
||||
timestamp: str
|
||||
actor: str # user_id or "ai_agent"
|
||||
action: str
|
||||
details: str = ""
|
||||
metadata: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RoomMessage(BaseModel):
|
||||
"""A message within the deal room conversation."""
|
||||
message_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
direction: str # inbound or outbound
|
||||
channel: str # email, whatsapp, internal
|
||||
content: str
|
||||
sender: str = ""
|
||||
timestamp: str = ""
|
||||
metadata: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class DealRoom(BaseModel):
|
||||
"""
|
||||
Central workspace for managing a B2B deal.
|
||||
غرفة الصفقة: مساحة العمل المركزية لصفقة B2B
|
||||
"""
|
||||
room_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
deal_id: str
|
||||
tenant_id: str
|
||||
|
||||
# Parties
|
||||
our_twin_id: str = ""
|
||||
their_profile: dict = Field(default_factory=dict)
|
||||
|
||||
# Deal context
|
||||
deal_type: str = ""
|
||||
hypothesis: str = "" # Why this deal makes sense (Arabic)
|
||||
mutual_value: dict = Field(
|
||||
default_factory=lambda: {"us": [], "them": []},
|
||||
)
|
||||
|
||||
# Negotiation state
|
||||
current_offer: dict = Field(default_factory=dict)
|
||||
their_last_response: dict = Field(default_factory=dict)
|
||||
concessions_made: list[ConcessionRecord] = Field(default_factory=list)
|
||||
concessions_received: list[ConcessionRecord] = Field(default_factory=list)
|
||||
batna: dict = Field(default_factory=dict) # Best alternative if deal fails
|
||||
walk_away_threshold: dict = Field(default_factory=dict)
|
||||
|
||||
# Conversation
|
||||
messages: list[RoomMessage] = Field(default_factory=list)
|
||||
channel: str = "email"
|
||||
|
||||
# Status
|
||||
stage: str = "discovery"
|
||||
blockers: list[str] = Field(default_factory=list)
|
||||
next_action: str = ""
|
||||
next_action_ar: str = ""
|
||||
|
||||
# Governance
|
||||
approvals_pending: list[ApprovalRequest] = Field(default_factory=list)
|
||||
approvals_granted: list[ApprovalRequest] = Field(default_factory=list)
|
||||
red_line_violations: list[dict] = Field(default_factory=list)
|
||||
audit_log: list[AuditEntry] = Field(default_factory=list)
|
||||
|
||||
# Metadata
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
|
||||
|
||||
# ── Service ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class DealRoomService:
|
||||
"""
|
||||
Manages DealRoom lifecycle: creation, stage transitions, messaging, governance.
|
||||
إدارة دورة حياة غرفة الصفقة: الإنشاء، والانتقال بين المراحل، والرسائل، والحوكمة
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.llm = get_llm()
|
||||
|
||||
# ── Create Room ─────────────────────────────────────────────────────────
|
||||
|
||||
async def create_room(
|
||||
self,
|
||||
deal_id: str,
|
||||
our_twin_id: str,
|
||||
their_profile: dict,
|
||||
db: AsyncSession,
|
||||
) -> DealRoom:
|
||||
"""
|
||||
Create a new deal room linked to a StrategicDeal.
|
||||
إنشاء غرفة صفقة جديدة مرتبطة بصفقة استراتيجية
|
||||
"""
|
||||
deal_result = await db.execute(
|
||||
select(StrategicDeal).where(StrategicDeal.id == deal_id)
|
||||
)
|
||||
deal = deal_result.scalar_one_or_none()
|
||||
if not deal:
|
||||
raise ValueError(f"الصفقة غير موجودة: {deal_id}")
|
||||
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
room = DealRoom(
|
||||
deal_id=str(deal.id),
|
||||
tenant_id=str(deal.tenant_id),
|
||||
our_twin_id=our_twin_id,
|
||||
their_profile=their_profile,
|
||||
deal_type=deal.deal_type or "",
|
||||
channel=deal.channel or "email",
|
||||
stage="discovery",
|
||||
next_action="research_target",
|
||||
next_action_ar="بحث عن الطرف الآخر وتحليل احتياجاته",
|
||||
created_at=now_iso,
|
||||
updated_at=now_iso,
|
||||
audit_log=[
|
||||
AuditEntry(
|
||||
timestamp=now_iso,
|
||||
actor="ai_agent",
|
||||
action="room_created",
|
||||
details=f"غرفة صفقة جديدة — نوع: {deal.deal_type or 'غير محدد'}",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# Persist room data on the deal
|
||||
history = list(deal.negotiation_history or [])
|
||||
history.append({
|
||||
"round": 0,
|
||||
"action": "room_created",
|
||||
"room_id": room.room_id,
|
||||
"timestamp": now_iso,
|
||||
})
|
||||
deal.negotiation_history = history
|
||||
|
||||
# Store room in proposed_terms as a nested structure
|
||||
existing_terms = dict(deal.proposed_terms or {})
|
||||
existing_terms["_deal_room"] = room.model_dump(mode="json")
|
||||
deal.proposed_terms = existing_terms
|
||||
await db.flush()
|
||||
|
||||
logger.info("Created deal room %s for deal %s", room.room_id, deal_id)
|
||||
return room
|
||||
|
||||
# ── Load Room ───────────────────────────────────────────────────────────
|
||||
|
||||
async def _load_room(self, room_id: str, db: AsyncSession) -> tuple[DealRoom, StrategicDeal]:
|
||||
"""Load a DealRoom and its parent StrategicDeal."""
|
||||
# Scan deals for the room
|
||||
all_deals = await db.execute(select(StrategicDeal))
|
||||
for deal in all_deals.scalars():
|
||||
terms = deal.proposed_terms or {}
|
||||
room_data = terms.get("_deal_room")
|
||||
if room_data and room_data.get("room_id") == room_id:
|
||||
return DealRoom(**room_data), deal
|
||||
raise ValueError(f"غرفة الصفقة غير موجودة: {room_id}")
|
||||
|
||||
async def _persist_room(self, room: DealRoom, deal: StrategicDeal, db: AsyncSession):
|
||||
"""Persist the room state back onto the deal."""
|
||||
room.updated_at = datetime.now(timezone.utc).isoformat()
|
||||
existing_terms = dict(deal.proposed_terms or {})
|
||||
existing_terms["_deal_room"] = room.model_dump(mode="json")
|
||||
deal.proposed_terms = existing_terms
|
||||
await db.flush()
|
||||
|
||||
# ── Update Stage ────────────────────────────────────────────────────────
|
||||
|
||||
async def update_stage(
|
||||
self,
|
||||
room_id: str,
|
||||
new_stage: str,
|
||||
reason: str,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""
|
||||
Transition the deal room to a new stage with audit logging.
|
||||
نقل غرفة الصفقة إلى مرحلة جديدة مع تسجيل في سجل المراجعة
|
||||
"""
|
||||
if new_stage not in ROOM_STAGES:
|
||||
raise ValueError(f"مرحلة غير صالحة: {new_stage}. المراحل المتاحة: {', '.join(ROOM_STAGES)}")
|
||||
|
||||
room, deal = await self._load_room(room_id, db)
|
||||
old_stage = room.stage
|
||||
|
||||
# Validate forward-only transition (except to closed_lost which can happen from any stage)
|
||||
if new_stage != "closed_lost":
|
||||
old_idx = ROOM_STAGES.index(old_stage) if old_stage in ROOM_STAGES else 0
|
||||
new_idx = ROOM_STAGES.index(new_stage)
|
||||
if new_idx < old_idx:
|
||||
raise ValueError(
|
||||
f"لا يمكن الرجوع من {STAGE_LABELS_AR.get(old_stage, old_stage)} "
|
||||
f"إلى {STAGE_LABELS_AR.get(new_stage, new_stage)}"
|
||||
)
|
||||
|
||||
room.stage = new_stage
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
room.audit_log.append(
|
||||
AuditEntry(
|
||||
timestamp=now_iso,
|
||||
actor="ai_agent",
|
||||
action="stage_changed",
|
||||
details=f"انتقال من {STAGE_LABELS_AR.get(old_stage, old_stage)} إلى {STAGE_LABELS_AR.get(new_stage, new_stage)}: {reason}",
|
||||
metadata={"old_stage": old_stage, "new_stage": new_stage},
|
||||
)
|
||||
)
|
||||
|
||||
# Sync deal status
|
||||
stage_to_status = {
|
||||
"discovery": DealStatus.DISCOVERY.value,
|
||||
"qualification": DealStatus.DISCOVERY.value,
|
||||
"proposal": DealStatus.OUTREACH.value,
|
||||
"negotiation": DealStatus.NEGOTIATING.value,
|
||||
"legal": DealStatus.TERM_SHEET.value,
|
||||
"approval": DealStatus.DUE_DILIGENCE.value,
|
||||
"closed_won": DealStatus.CLOSED_WON.value,
|
||||
"closed_lost": DealStatus.CLOSED_LOST.value,
|
||||
}
|
||||
mapped_status = stage_to_status.get(new_stage)
|
||||
if mapped_status:
|
||||
deal.status = mapped_status
|
||||
if new_stage in ("closed_won", "closed_lost"):
|
||||
deal.closed_at = datetime.now(timezone.utc)
|
||||
|
||||
await self._persist_room(room, deal, db)
|
||||
logger.info("Room %s stage: %s -> %s (%s)", room_id, old_stage, new_stage, reason)
|
||||
|
||||
# ── Add Message ─────────────────────────────────────────────────────────
|
||||
|
||||
async def add_message(
|
||||
self,
|
||||
room_id: str,
|
||||
message: str,
|
||||
direction: str,
|
||||
channel: str,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""
|
||||
Record a message in the deal room conversation.
|
||||
تسجيل رسالة في محادثة غرفة الصفقة
|
||||
"""
|
||||
room, deal = await self._load_room(room_id, db)
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
room.messages.append(
|
||||
RoomMessage(
|
||||
direction=direction,
|
||||
channel=channel,
|
||||
content=message,
|
||||
sender="ai_agent" if direction == "outbound" else "counterparty",
|
||||
timestamp=now_iso,
|
||||
)
|
||||
)
|
||||
|
||||
if direction == "inbound":
|
||||
room.their_last_response = {
|
||||
"content": message,
|
||||
"channel": channel,
|
||||
"timestamp": now_iso,
|
||||
}
|
||||
|
||||
room.audit_log.append(
|
||||
AuditEntry(
|
||||
timestamp=now_iso,
|
||||
actor="ai_agent" if direction == "outbound" else "counterparty",
|
||||
action=f"message_{direction}",
|
||||
details=message[:200],
|
||||
metadata={"channel": channel},
|
||||
)
|
||||
)
|
||||
|
||||
await self._persist_room(room, deal, db)
|
||||
logger.info("Added %s message to room %s via %s", direction, room_id, channel)
|
||||
|
||||
# ── Record Concession ───────────────────────────────────────────────────
|
||||
|
||||
async def record_concession(
|
||||
self,
|
||||
room_id: str,
|
||||
what: str,
|
||||
value: float,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""
|
||||
Record a concession made during negotiation.
|
||||
تسجيل تنازل تم خلال التفاوض
|
||||
"""
|
||||
room, deal = await self._load_room(room_id, db)
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
record = ConcessionRecord(
|
||||
what=what,
|
||||
value_sar=value,
|
||||
direction="given",
|
||||
timestamp=now_iso,
|
||||
)
|
||||
room.concessions_made.append(record)
|
||||
|
||||
room.audit_log.append(
|
||||
AuditEntry(
|
||||
timestamp=now_iso,
|
||||
actor="ai_agent",
|
||||
action="concession_made",
|
||||
details=f"تنازل: {what} (قيمة: {value:,.0f} ريال)",
|
||||
metadata={"value_sar": value},
|
||||
)
|
||||
)
|
||||
|
||||
await self._persist_room(room, deal, db)
|
||||
logger.info("Recorded concession in room %s: %s (%.0f SAR)", room_id, what, value)
|
||||
|
||||
# ── Check Red Lines ─────────────────────────────────────────────────────
|
||||
|
||||
async def check_red_lines(
|
||||
self,
|
||||
room_id: str,
|
||||
proposed_terms: dict,
|
||||
db: AsyncSession,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Check proposed terms against the company's red lines.
|
||||
التحقق من الشروط المقترحة مقابل الخطوط الحمراء للشركة
|
||||
"""
|
||||
room, deal = await self._load_room(room_id, db)
|
||||
|
||||
# Load the company twin to get red lines
|
||||
from app.services.strategic_deals.company_twin import CompanyTwinBuilder
|
||||
builder = CompanyTwinBuilder()
|
||||
twin = None
|
||||
|
||||
if room.our_twin_id:
|
||||
twin = await builder.get_twin_by_id(room.our_twin_id, db)
|
||||
|
||||
if not twin:
|
||||
# Try loading by company_id from the deal initiator
|
||||
if deal.initiator_profile_id:
|
||||
twin = await builder.get_twin(str(deal.initiator_profile_id), db)
|
||||
|
||||
red_lines = twin.red_lines if twin else []
|
||||
if not red_lines:
|
||||
return []
|
||||
|
||||
violations: list[str] = []
|
||||
terms_text = str(proposed_terms).lower()
|
||||
|
||||
# Static keyword check
|
||||
keyword_checks = {
|
||||
"حصرية": "exclusivity",
|
||||
"حقوق ملكية": "equity",
|
||||
"ضمان": "guarantee",
|
||||
"تعويض": "compensation",
|
||||
"غرامة": "penalty",
|
||||
}
|
||||
|
||||
for red_line in red_lines:
|
||||
red_line_lower = red_line.lower()
|
||||
# Direct keyword match
|
||||
if red_line_lower in terms_text:
|
||||
violations.append(f"خط أحمر: {red_line}")
|
||||
continue
|
||||
# Check Arabic keywords
|
||||
for ar_kw, en_kw in keyword_checks.items():
|
||||
if ar_kw in red_line_lower and en_kw in terms_text:
|
||||
violations.append(f"خط أحمر: {red_line}")
|
||||
break
|
||||
|
||||
# If there are potential concerns, use LLM for deeper analysis
|
||||
if not violations and red_lines:
|
||||
system_prompt = """أنت مراجع عقود سعودي. تحقق من الشروط المقترحة مقابل الخطوط الحمراء.
|
||||
|
||||
أعد النتائج بصيغة JSON:
|
||||
{
|
||||
"violations": ["وصف الانتهاك 1", "وصف الانتهاك 2"],
|
||||
"warnings": ["تحذير 1"]
|
||||
}
|
||||
|
||||
إذا لم يكن هناك انتهاكات، أعد قوائم فارغة."""
|
||||
|
||||
context = (
|
||||
f"الخطوط الحمراء:\n" + "\n".join(f"- {rl}" for rl in red_lines)
|
||||
+ f"\n\nالشروط المقترحة:\n{str(proposed_terms)}"
|
||||
)
|
||||
|
||||
try:
|
||||
llm_response = await self.llm.complete(
|
||||
system_prompt=system_prompt,
|
||||
user_message=context,
|
||||
json_mode=True,
|
||||
temperature=0.1,
|
||||
)
|
||||
result = llm_response.parse_json()
|
||||
if result and result.get("violations"):
|
||||
violations.extend(result["violations"])
|
||||
except Exception as exc:
|
||||
logger.warning("LLM red-line check failed: %s", exc)
|
||||
|
||||
# Record violations
|
||||
if violations:
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
for v in violations:
|
||||
room.red_line_violations.append({
|
||||
"violation": v,
|
||||
"proposed_terms": proposed_terms,
|
||||
"timestamp": now_iso,
|
||||
})
|
||||
room.audit_log.append(
|
||||
AuditEntry(
|
||||
timestamp=now_iso,
|
||||
actor="ai_agent",
|
||||
action="red_line_violation",
|
||||
details=f"تم اكتشاف {len(violations)} انتهاك للخطوط الحمراء",
|
||||
metadata={"violations": violations},
|
||||
)
|
||||
)
|
||||
await self._persist_room(room, deal, db)
|
||||
|
||||
logger.info("Red line check for room %s: %d violations", room_id, len(violations))
|
||||
return violations
|
||||
|
||||
# ── Request Approval ────────────────────────────────────────────────────
|
||||
|
||||
async def request_approval(
|
||||
self,
|
||||
room_id: str,
|
||||
action: str,
|
||||
details: str,
|
||||
db: AsyncSession,
|
||||
) -> str:
|
||||
"""
|
||||
Create an approval request that pauses AI action until a human decides.
|
||||
إنشاء طلب موافقة يوقف عمل الذكاء الاصطناعي حتى يقرر إنسان
|
||||
"""
|
||||
room, deal = await self._load_room(room_id, db)
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
approval = ApprovalRequest(
|
||||
action=action,
|
||||
details=details,
|
||||
requested_at=now_iso,
|
||||
)
|
||||
room.approvals_pending.append(approval)
|
||||
|
||||
room.blockers.append(f"بانتظار موافقة على: {action}")
|
||||
room.audit_log.append(
|
||||
AuditEntry(
|
||||
timestamp=now_iso,
|
||||
actor="ai_agent",
|
||||
action="approval_requested",
|
||||
details=f"طلب موافقة: {action} — {details}",
|
||||
metadata={"approval_id": approval.approval_id},
|
||||
)
|
||||
)
|
||||
|
||||
await self._persist_room(room, deal, db)
|
||||
logger.info("Approval requested in room %s: %s (id=%s)", room_id, action, approval.approval_id)
|
||||
return approval.approval_id
|
||||
|
||||
# ── Grant Approval ──────────────────────────────────────────────────────
|
||||
|
||||
async def grant_approval(
|
||||
self,
|
||||
room_id: str,
|
||||
approval_id: str,
|
||||
user_id: str,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""
|
||||
Grant a pending approval request.
|
||||
منح موافقة على طلب معلق
|
||||
"""
|
||||
room, deal = await self._load_room(room_id, db)
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
granted = None
|
||||
remaining_pending = []
|
||||
for req in room.approvals_pending:
|
||||
if req.approval_id == approval_id:
|
||||
req.status = "granted"
|
||||
req.decided_by = user_id
|
||||
req.decided_at = now_iso
|
||||
granted = req
|
||||
else:
|
||||
remaining_pending.append(req)
|
||||
|
||||
if not granted:
|
||||
raise ValueError(f"طلب الموافقة غير موجود: {approval_id}")
|
||||
|
||||
room.approvals_pending = remaining_pending
|
||||
room.approvals_granted.append(granted)
|
||||
|
||||
# Remove related blocker
|
||||
blocker_prefix = f"بانتظار موافقة على: {granted.action}"
|
||||
room.blockers = [b for b in room.blockers if b != blocker_prefix]
|
||||
|
||||
room.audit_log.append(
|
||||
AuditEntry(
|
||||
timestamp=now_iso,
|
||||
actor=user_id,
|
||||
action="approval_granted",
|
||||
details=f"تمت الموافقة على: {granted.action}",
|
||||
metadata={"approval_id": approval_id},
|
||||
)
|
||||
)
|
||||
|
||||
await self._persist_room(room, deal, db)
|
||||
logger.info("Approval %s granted by %s in room %s", approval_id, user_id, room_id)
|
||||
|
||||
# ── Deal Summary ────────────────────────────────────────────────────────
|
||||
|
||||
async def get_deal_summary(
|
||||
self,
|
||||
room_id: str,
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""
|
||||
Generate an Arabic summary of the deal room status.
|
||||
إنشاء ملخص عربي لحالة غرفة الصفقة
|
||||
"""
|
||||
room, deal = await self._load_room(room_id, db)
|
||||
|
||||
# Gather data for LLM summary
|
||||
msg_count = len(room.messages)
|
||||
inbound = sum(1 for m in room.messages if m.direction == "inbound")
|
||||
outbound = msg_count - inbound
|
||||
concessions_given = len(room.concessions_made)
|
||||
concessions_got = len(room.concessions_received)
|
||||
total_concession_value = sum(c.value_sar for c in room.concessions_made)
|
||||
pending_approvals = len(room.approvals_pending)
|
||||
violations = len(room.red_line_violations)
|
||||
|
||||
stage_ar = STAGE_LABELS_AR.get(room.stage, room.stage)
|
||||
their_name = room.their_profile.get("company_name", room.their_profile.get("name", "الطرف الآخر"))
|
||||
|
||||
summary = {
|
||||
"room_id": room.room_id,
|
||||
"deal_id": room.deal_id,
|
||||
"stage": room.stage,
|
||||
"stage_ar": stage_ar,
|
||||
"their_name": their_name,
|
||||
"deal_type": room.deal_type,
|
||||
"channel": room.channel,
|
||||
"statistics": {
|
||||
"total_messages": msg_count,
|
||||
"inbound_messages": inbound,
|
||||
"outbound_messages": outbound,
|
||||
"concessions_given": concessions_given,
|
||||
"concessions_received": concessions_got,
|
||||
"total_concession_value_sar": total_concession_value,
|
||||
"pending_approvals": pending_approvals,
|
||||
"red_line_violations": violations,
|
||||
},
|
||||
"blockers": room.blockers,
|
||||
"next_action": room.next_action,
|
||||
"next_action_ar": room.next_action_ar,
|
||||
"current_offer": room.current_offer,
|
||||
"their_last_response": room.their_last_response,
|
||||
"summary_ar": (
|
||||
f"صفقة مع {their_name} — المرحلة: {stage_ar}\n"
|
||||
f"عدد الرسائل: {msg_count} ({inbound} واردة، {outbound} صادرة)\n"
|
||||
f"التنازلات المقدمة: {concessions_given} (بقيمة {total_concession_value:,.0f} ريال)\n"
|
||||
+ (f"موافقات معلقة: {pending_approvals}\n" if pending_approvals else "")
|
||||
+ (f"تحذير: {violations} انتهاك للخطوط الحمراء\n" if violations else "")
|
||||
+ (f"الخطوة التالية: {room.next_action_ar}" if room.next_action_ar else "")
|
||||
),
|
||||
}
|
||||
|
||||
logger.info("Generated deal summary for room %s", room_id)
|
||||
return summary
|
||||
|
||||
# ── Get Rooms ───────────────────────────────────────────────────────────
|
||||
|
||||
async def get_rooms(
|
||||
self,
|
||||
tenant_id: str,
|
||||
stage: Optional[str] = None,
|
||||
db: AsyncSession = None,
|
||||
) -> list[DealRoom]:
|
||||
"""
|
||||
List all deal rooms for a tenant, optionally filtered by stage.
|
||||
عرض جميع غرف الصفقات لمستأجر معين مع إمكانية الفلترة بالمرحلة
|
||||
"""
|
||||
query = select(StrategicDeal).where(
|
||||
StrategicDeal.tenant_id == tenant_id
|
||||
)
|
||||
result = await db.execute(query)
|
||||
deals = result.scalars().all()
|
||||
|
||||
rooms: list[DealRoom] = []
|
||||
for deal in deals:
|
||||
terms = deal.proposed_terms or {}
|
||||
room_data = terms.get("_deal_room")
|
||||
if not room_data:
|
||||
continue
|
||||
try:
|
||||
room = DealRoom(**room_data)
|
||||
if stage and room.stage != stage:
|
||||
continue
|
||||
rooms.append(room)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to deserialize room from deal %s: %s", deal.id, exc)
|
||||
|
||||
logger.info(
|
||||
"Retrieved %d deal rooms for tenant %s (stage=%s)",
|
||||
len(rooms), tenant_id, stage or "all",
|
||||
)
|
||||
return rooms
|
||||
@ -0,0 +1,573 @@
|
||||
"""
|
||||
Deal Taxonomy — Complete taxonomy of 15 B2B deal types with templates and qualification flows.
|
||||
تصنيف الصفقات: 15 نوعاً من صفقات الشراكات بين الشركات مع قوالب وأسئلة تأهيل
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger("dealix.strategic_deals.taxonomy")
|
||||
|
||||
|
||||
# ── Taxonomy Schema ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class DealTypeSpec(BaseModel):
|
||||
"""Full specification for a deal type in the taxonomy."""
|
||||
id: str
|
||||
name: str
|
||||
name_ar: str
|
||||
description: str
|
||||
description_ar: str
|
||||
qualification_questions: list[str] # Arabic questions
|
||||
typical_terms: list[str]
|
||||
risk_level: str # low, medium, high
|
||||
approval_level: str # mode_0 through mode_4
|
||||
need_categories: list[str] # Which need categories this deal type addresses
|
||||
example_ar: str # Real-world Saudi example
|
||||
|
||||
|
||||
# ── The 15-Type Taxonomy ────────────────────────────────────────────────────
|
||||
|
||||
DEAL_TAXONOMY: dict[str, dict] = {
|
||||
"service_barter": {
|
||||
"name": "Service-for-Service Exchange",
|
||||
"name_ar": "تبادل خدمات",
|
||||
"description": "Exchange services of equivalent value without cash transactions",
|
||||
"description_ar": "تبادل خدمات بقيمة متساوية بين شركتين بدون تدفقات نقدية",
|
||||
"qualification_questions": [
|
||||
"ما الخدمة التي تقدمونها للتبادل؟",
|
||||
"ما القيمة التقديرية لهذه الخدمة بالريال السعودي؟",
|
||||
"ما الخدمة التي تحتاجونها بالمقابل؟",
|
||||
"ما المدة المتوقعة لهذا التبادل؟",
|
||||
"هل لديكم خبرة سابقة في تبادل الخدمات؟",
|
||||
],
|
||||
"typical_terms": [
|
||||
"duration",
|
||||
"scope",
|
||||
"quality_sla",
|
||||
"cancellation",
|
||||
"value_equivalence_method",
|
||||
"dispute_resolution",
|
||||
],
|
||||
"risk_level": "low",
|
||||
"approval_level": "mode_2",
|
||||
"need_categories": ["marketing", "technology", "delivery"],
|
||||
"example_ar": "شركة تسويق تقدم حملات رقمية لشركة برمجيات مقابل تطوير موقع إلكتروني",
|
||||
},
|
||||
"referral_partnership": {
|
||||
"name": "Referral Partnership",
|
||||
"name_ar": "شراكة إحالة",
|
||||
"description": "Earn commission by referring qualified leads to each other",
|
||||
"description_ar": "كسب عمولة من خلال إحالة عملاء مؤهلين بين الشركتين",
|
||||
"qualification_questions": [
|
||||
"ما نوع العملاء الذين تحيلونهم عادة؟",
|
||||
"ما نسبة العمولة المتوقعة؟",
|
||||
"كيف يتم تتبع الإحالات؟",
|
||||
"ما هو متوسط حجم الصفقة لعملائكم؟",
|
||||
],
|
||||
"typical_terms": [
|
||||
"commission_rate",
|
||||
"tracking_method",
|
||||
"payment_schedule",
|
||||
"exclusivity",
|
||||
"minimum_referrals",
|
||||
"non_compete",
|
||||
],
|
||||
"risk_level": "low",
|
||||
"approval_level": "mode_2",
|
||||
"need_categories": ["sales", "marketing"],
|
||||
"example_ar": "مكتب محاماة يحيل عملاءه لشركة محاسبة مقابل 10% من قيمة أول عقد",
|
||||
},
|
||||
"co_selling": {
|
||||
"name": "Co-Selling Agreement",
|
||||
"name_ar": "بيع مشترك",
|
||||
"description": "Joint sales efforts targeting shared opportunities",
|
||||
"description_ar": "جهود بيع مشتركة لاستهداف فرص مشتركة بين الشركتين",
|
||||
"qualification_questions": [
|
||||
"ما المنتجات أو الخدمات التي ستباع بشكل مشترك؟",
|
||||
"كيف سيتم تقسيم الإيرادات؟",
|
||||
"من يقود عملية البيع؟",
|
||||
"ما القطاعات المستهدفة؟",
|
||||
"هل لديكم فريق مبيعات مخصص لهذا الغرض؟",
|
||||
],
|
||||
"typical_terms": [
|
||||
"revenue_split",
|
||||
"lead_ownership",
|
||||
"territory",
|
||||
"sales_process",
|
||||
"brand_usage",
|
||||
"training_requirements",
|
||||
],
|
||||
"risk_level": "medium",
|
||||
"approval_level": "mode_3",
|
||||
"need_categories": ["sales", "distribution"],
|
||||
"example_ar": "شركة برمجيات وشركة استشارات يبيعون حلولاً متكاملة لقطاع الصحة",
|
||||
},
|
||||
"co_marketing": {
|
||||
"name": "Co-Marketing Campaign",
|
||||
"name_ar": "تسويق مشترك",
|
||||
"description": "Joint marketing campaigns sharing costs and audiences",
|
||||
"description_ar": "حملات تسويقية مشتركة مع تقاسم التكاليف والجمهور المستهدف",
|
||||
"qualification_questions": [
|
||||
"ما القنوات التسويقية المستهدفة؟",
|
||||
"ما الميزانية المتوقعة من كل طرف؟",
|
||||
"من الجمهور المستهدف المشترك؟",
|
||||
"ما مؤشرات النجاح المتفق عليها؟",
|
||||
],
|
||||
"typical_terms": [
|
||||
"budget_split",
|
||||
"channels",
|
||||
"brand_guidelines",
|
||||
"content_approval",
|
||||
"lead_sharing",
|
||||
"duration",
|
||||
],
|
||||
"risk_level": "low",
|
||||
"approval_level": "mode_2",
|
||||
"need_categories": ["marketing"],
|
||||
"example_ar": "شركتا تقنية تشتركان في رعاية مؤتمر قطاع التجزئة وتتقاسمان العملاء المحتملين",
|
||||
},
|
||||
"subcontracting": {
|
||||
"name": "Subcontracting Agreement",
|
||||
"name_ar": "عقد باطن (مقاولة فرعية)",
|
||||
"description": "Outsource specific project scope to a specialized partner",
|
||||
"description_ar": "إسناد جزء من نطاق المشروع لشريك متخصص كمقاول فرعي",
|
||||
"qualification_questions": [
|
||||
"ما نطاق العمل المطلوب إسناده؟",
|
||||
"ما المهارات والشهادات المطلوبة؟",
|
||||
"ما الجدول الزمني للتسليم؟",
|
||||
"هل المشروع حكومي أو خاص؟",
|
||||
"ما شروط الضمان والجودة؟",
|
||||
],
|
||||
"typical_terms": [
|
||||
"scope_of_work",
|
||||
"payment_milestones",
|
||||
"quality_standards",
|
||||
"liability",
|
||||
"insurance",
|
||||
"confidentiality",
|
||||
"penalties",
|
||||
],
|
||||
"risk_level": "medium",
|
||||
"approval_level": "mode_3",
|
||||
"need_categories": ["delivery", "talent"],
|
||||
"example_ar": "شركة مقاولات كبرى تسند أعمال الكهرباء لشركة متخصصة في مشروع حكومي",
|
||||
},
|
||||
"white_label": {
|
||||
"name": "White-Label / Private Label",
|
||||
"name_ar": "علامة بيضاء",
|
||||
"description": "Provide products or services under the partner's brand",
|
||||
"description_ar": "تقديم منتجات أو خدمات تحت العلامة التجارية للشريك",
|
||||
"qualification_questions": [
|
||||
"ما المنتج أو الخدمة المراد تقديمها تحت علامتهم؟",
|
||||
"ما مستوى التخصيص المطلوب؟",
|
||||
"كيف سيتم التسعير والهوامش؟",
|
||||
"ما متطلبات الجودة والدعم الفني؟",
|
||||
],
|
||||
"typical_terms": [
|
||||
"branding_rights",
|
||||
"customization_scope",
|
||||
"pricing_structure",
|
||||
"minimum_volume",
|
||||
"exclusivity",
|
||||
"support_sla",
|
||||
"ip_ownership",
|
||||
],
|
||||
"risk_level": "medium",
|
||||
"approval_level": "mode_3",
|
||||
"need_categories": ["technology", "delivery"],
|
||||
"example_ar": "شركة برمجيات سعودية توفر نظام CRM تحت العلامة التجارية لشركة اتصالات",
|
||||
},
|
||||
"reseller": {
|
||||
"name": "Reseller Agreement",
|
||||
"name_ar": "اتفاقية موزع معتمد",
|
||||
"description": "Authorized resale of products or services with margin",
|
||||
"description_ar": "إعادة بيع منتجات أو خدمات الشريك بصفة موزع معتمد مع هامش ربح",
|
||||
"qualification_questions": [
|
||||
"ما المنتجات المراد توزيعها؟",
|
||||
"ما المنطقة الجغرافية المستهدفة؟",
|
||||
"هل التوزيع حصري أم غير حصري؟",
|
||||
"ما هامش الربح المتوقع؟",
|
||||
"ما حجم المبيعات المتوقع سنوياً؟",
|
||||
],
|
||||
"typical_terms": [
|
||||
"territory",
|
||||
"exclusivity",
|
||||
"margin_structure",
|
||||
"minimum_purchase",
|
||||
"payment_terms",
|
||||
"marketing_support",
|
||||
"training",
|
||||
"return_policy",
|
||||
],
|
||||
"risk_level": "medium",
|
||||
"approval_level": "mode_3",
|
||||
"need_categories": ["distribution", "sales"],
|
||||
"example_ar": "شركة سعودية توزع حلول أمن سيبراني لشركة أمريكية في منطقة الخليج",
|
||||
},
|
||||
"strategic_alliance": {
|
||||
"name": "Strategic Alliance",
|
||||
"name_ar": "تحالف استراتيجي",
|
||||
"description": "Long-term strategic collaboration without equity exchange",
|
||||
"description_ar": "تعاون استراتيجي طويل الأمد بدون تبادل حصص ملكية",
|
||||
"qualification_questions": [
|
||||
"ما الأهداف الاستراتيجية المشتركة؟",
|
||||
"ما مدة التحالف المتوقعة؟",
|
||||
"كيف ستتم الحوكمة واتخاذ القرارات؟",
|
||||
"ما الموارد التي سيساهم بها كل طرف؟",
|
||||
"هل هناك اتفاقيات عدم منافسة؟",
|
||||
],
|
||||
"typical_terms": [
|
||||
"strategic_objectives",
|
||||
"governance_structure",
|
||||
"resource_commitments",
|
||||
"non_compete",
|
||||
"exit_terms",
|
||||
"ip_sharing",
|
||||
"confidentiality",
|
||||
],
|
||||
"risk_level": "high",
|
||||
"approval_level": "mode_4",
|
||||
"need_categories": ["capital", "distribution", "technology"],
|
||||
"example_ar": "شركة لوجستية وشركة تقنية يتحالفان لتقديم حلول سلسلة إمداد ذكية للسوق السعودي",
|
||||
},
|
||||
"channel_partnership": {
|
||||
"name": "Channel Partnership",
|
||||
"name_ar": "شراكة قنوات توزيع",
|
||||
"description": "Leverage partner's sales channels for distribution",
|
||||
"description_ar": "الاستفادة من قنوات بيع الشريك لتوزيع منتجاتك وخدماتك",
|
||||
"qualification_questions": [
|
||||
"ما القنوات التي يمتلكها الشريك؟",
|
||||
"ما حجم قاعدة عملائهم؟",
|
||||
"كيف سيتم تقسيم المسؤوليات؟",
|
||||
"ما الدعم المطلوب للقناة (تدريب، مواد تسويقية)؟",
|
||||
],
|
||||
"typical_terms": [
|
||||
"channel_type",
|
||||
"commission_structure",
|
||||
"training_requirements",
|
||||
"marketing_support",
|
||||
"performance_targets",
|
||||
"reporting_frequency",
|
||||
],
|
||||
"risk_level": "medium",
|
||||
"approval_level": "mode_3",
|
||||
"need_categories": ["distribution", "sales"],
|
||||
"example_ar": "شركة SaaS تستخدم شبكة استشاري إداريين لبيع منتجها في المملكة",
|
||||
},
|
||||
"joint_venture": {
|
||||
"name": "Joint Venture",
|
||||
"name_ar": "مشروع مشترك",
|
||||
"description": "Create a new entity jointly owned by both parties",
|
||||
"description_ar": "إنشاء كيان جديد مملوك بشكل مشترك بين الطرفين",
|
||||
"qualification_questions": [
|
||||
"ما هدف المشروع المشترك؟",
|
||||
"ما نسبة مساهمة كل طرف؟",
|
||||
"ما الشكل القانوني المقترح (شركة ذات مسؤولية محدودة، شراكة)؟",
|
||||
"من سيتولى الإدارة اليومية؟",
|
||||
"ما استراتيجية الخروج؟",
|
||||
"كيف ستوزع الأرباح والخسائر؟",
|
||||
],
|
||||
"typical_terms": [
|
||||
"equity_split",
|
||||
"capital_contributions",
|
||||
"governance",
|
||||
"management_structure",
|
||||
"profit_distribution",
|
||||
"exit_strategy",
|
||||
"non_compete",
|
||||
"dispute_resolution",
|
||||
],
|
||||
"risk_level": "high",
|
||||
"approval_level": "mode_4",
|
||||
"need_categories": ["capital", "technology", "distribution"],
|
||||
"example_ar": "مستثمر سعودي وشركة تقنية أجنبية ينشئون شركة مشتركة لتقديم حلول الذكاء الاصطناعي محلياً",
|
||||
},
|
||||
"acquisition_scouting": {
|
||||
"name": "Acquisition Scouting",
|
||||
"name_ar": "استكشاف استحواذ",
|
||||
"description": "Identify and qualify potential acquisition targets",
|
||||
"description_ar": "تحديد وتأهيل الشركات المرشحة للاستحواذ",
|
||||
"qualification_questions": [
|
||||
"ما القطاع المستهدف للاستحواذ؟",
|
||||
"ما الحجم المثالي للشركة المستهدفة (إيرادات، موظفين)؟",
|
||||
"ما الميزانية المتاحة للاستحواذ؟",
|
||||
"هل تبحثون عن استحواذ كامل أو حصة جزئية؟",
|
||||
"ما الأصول الاستراتيجية المطلوبة (تقنية، عملاء، تراخيص)؟",
|
||||
],
|
||||
"typical_terms": [
|
||||
"target_criteria",
|
||||
"valuation_method",
|
||||
"due_diligence_scope",
|
||||
"exclusivity_period",
|
||||
"advisory_fees",
|
||||
"confidentiality",
|
||||
],
|
||||
"risk_level": "high",
|
||||
"approval_level": "mode_4",
|
||||
"need_categories": ["capital", "technology"],
|
||||
"example_ar": "مجموعة سعودية تبحث عن شركات تقنية ناشئة للاستحواذ بميزانية 5-20 مليون ريال",
|
||||
},
|
||||
"investment_intro": {
|
||||
"name": "Investment Introduction",
|
||||
"name_ar": "تقديم فرصة استثمارية",
|
||||
"description": "Connect companies with investors or investment opportunities",
|
||||
"description_ar": "ربط الشركات بمستثمرين أو فرص استثمارية مناسبة",
|
||||
"qualification_questions": [
|
||||
"هل تبحثون عن استثمار أم مستثمر؟",
|
||||
"ما حجم التمويل المطلوب أو المتاح؟",
|
||||
"ما مرحلة نمو الشركة؟",
|
||||
"ما العائد المتوقع على الاستثمار؟",
|
||||
"هل لديكم عرض تقديمي (Pitch Deck) جاهز؟",
|
||||
],
|
||||
"typical_terms": [
|
||||
"investment_size",
|
||||
"valuation",
|
||||
"equity_offered",
|
||||
"use_of_funds",
|
||||
"board_representation",
|
||||
"anti_dilution",
|
||||
"introducer_fee",
|
||||
],
|
||||
"risk_level": "high",
|
||||
"approval_level": "mode_4",
|
||||
"need_categories": ["capital"],
|
||||
"example_ar": "شركة ناشئة سعودية تبحث عن جولة تمويل Series A بقيمة 10 مليون ريال",
|
||||
},
|
||||
"vendor_replacement": {
|
||||
"name": "Vendor Replacement",
|
||||
"name_ar": "استبدال مورد",
|
||||
"description": "Replace an existing vendor with a better-fit partner",
|
||||
"description_ar": "استبدال مورد حالي بشريك أفضل من حيث الجودة أو السعر أو الخدمة",
|
||||
"qualification_questions": [
|
||||
"ما الخدمة أو المنتج الذي يقدمه المورد الحالي؟",
|
||||
"ما أسباب الرغبة في التغيير؟",
|
||||
"ما معايير اختيار المورد الجديد؟",
|
||||
"ما الميزانية المتاحة؟",
|
||||
"ما الجدول الزمني المطلوب للانتقال؟",
|
||||
],
|
||||
"typical_terms": [
|
||||
"transition_plan",
|
||||
"pricing_comparison",
|
||||
"service_level_agreement",
|
||||
"contract_duration",
|
||||
"penalty_clauses",
|
||||
"data_migration",
|
||||
],
|
||||
"risk_level": "medium",
|
||||
"approval_level": "mode_3",
|
||||
"need_categories": ["delivery", "technology"],
|
||||
"example_ar": "مستشفى يبحث عن مورد جديد لمستلزمات طبية بعد انتهاء عقد المورد الحالي",
|
||||
},
|
||||
"capability_gap_fill": {
|
||||
"name": "Capability Gap Fill",
|
||||
"name_ar": "سد فجوة القدرات",
|
||||
"description": "Partner with a company to fill a specific capability gap",
|
||||
"description_ar": "التعاون مع شركة متخصصة لسد فجوة في قدرات شركتك",
|
||||
"qualification_questions": [
|
||||
"ما الفجوة التي تحتاجون سدها؟",
|
||||
"هل هي فجوة مؤقتة أم دائمة؟",
|
||||
"ما مستوى التخصص المطلوب؟",
|
||||
"هل تفضلون شريكاً محلياً أم دولياً؟",
|
||||
"ما ميزانية سد هذه الفجوة؟",
|
||||
],
|
||||
"typical_terms": [
|
||||
"gap_definition",
|
||||
"duration",
|
||||
"knowledge_transfer",
|
||||
"performance_metrics",
|
||||
"pricing",
|
||||
"confidentiality",
|
||||
"training_commitment",
|
||||
],
|
||||
"risk_level": "low",
|
||||
"approval_level": "mode_2",
|
||||
"need_categories": ["talent", "technology", "delivery"],
|
||||
"example_ar": "شركة مقاولات تتعاون مع شركة تصميم معماري لتقديم عروض متكاملة",
|
||||
},
|
||||
"tender_consortium": {
|
||||
"name": "Tender Consortium",
|
||||
"name_ar": "تحالف مناقصات",
|
||||
"description": "Form a consortium to jointly bid on large tenders",
|
||||
"description_ar": "تشكيل تحالف للتقدم بعرض مشترك في المناقصات الكبرى",
|
||||
"qualification_questions": [
|
||||
"ما المناقصة أو المشروع المستهدف؟",
|
||||
"ما الجهة المالكة للمناقصة؟",
|
||||
"ما التخصصات المطلوبة لتكوين التحالف؟",
|
||||
"ما الموعد النهائي لتقديم العرض؟",
|
||||
"هل لديكم خبرة سابقة في المناقصات الحكومية؟",
|
||||
"ما نسبة المحتوى المحلي المطلوبة؟",
|
||||
],
|
||||
"typical_terms": [
|
||||
"scope_allocation",
|
||||
"revenue_split",
|
||||
"lead_partner",
|
||||
"joint_liability",
|
||||
"bid_bond",
|
||||
"performance_bond",
|
||||
"local_content",
|
||||
"governance",
|
||||
],
|
||||
"risk_level": "high",
|
||||
"approval_level": "mode_4",
|
||||
"need_categories": ["delivery", "capital", "talent"],
|
||||
"example_ar": "ثلاث شركات سعودية تتحالف للتقدم لمناقصة مشروع بنية تحتية حكومي بقيمة 50 مليون ريال",
|
||||
},
|
||||
}
|
||||
|
||||
# ── Mapping from need categories to deal types ──────────────────────────────
|
||||
|
||||
_NEED_TO_DEAL_MAP: dict[str, list[str]] = {}
|
||||
for _deal_id, _spec in DEAL_TAXONOMY.items():
|
||||
for _cat in _spec["need_categories"]:
|
||||
_NEED_TO_DEAL_MAP.setdefault(_cat, []).append(_deal_id)
|
||||
|
||||
|
||||
# ── Service ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class DealTaxonomyService:
|
||||
"""
|
||||
Provides lookup and intelligence over the 15-type deal taxonomy.
|
||||
خدمة تصنيف الصفقات: بحث واقتراحات ذكية لأنواع الصفقات الخمسة عشر
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_deal_type(type_id: str) -> Optional[DealTypeSpec]:
|
||||
"""Return full spec for a deal type, or None if not found."""
|
||||
raw = DEAL_TAXONOMY.get(type_id)
|
||||
if not raw:
|
||||
return None
|
||||
return DealTypeSpec(id=type_id, **raw)
|
||||
|
||||
@staticmethod
|
||||
def get_all_types() -> list[DealTypeSpec]:
|
||||
"""Return all 15 deal types as structured specs."""
|
||||
return [
|
||||
DealTypeSpec(id=type_id, **spec)
|
||||
for type_id, spec in DEAL_TAXONOMY.items()
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_types_for_need(need_category: str) -> list[str]:
|
||||
"""
|
||||
Return deal type IDs that address a given need category.
|
||||
إرجاع أنواع الصفقات التي تلبي فئة احتياج معينة
|
||||
"""
|
||||
return _NEED_TO_DEAL_MAP.get(need_category, [])
|
||||
|
||||
@staticmethod
|
||||
def get_qualification_questions(type_id: str, language: str = "ar") -> list[str]:
|
||||
"""
|
||||
Return qualification questions for a deal type.
|
||||
إرجاع أسئلة التأهيل لنوع صفقة معين
|
||||
"""
|
||||
spec = DEAL_TAXONOMY.get(type_id)
|
||||
if not spec:
|
||||
return []
|
||||
questions = spec["qualification_questions"]
|
||||
if language == "ar":
|
||||
return questions
|
||||
# English placeholders — in production these would be translated
|
||||
return [f"[Q{i+1}] {q}" for i, q in enumerate(questions)]
|
||||
|
||||
@staticmethod
|
||||
def get_typical_terms(type_id: str) -> list[str]:
|
||||
"""Return typical negotiation terms for a deal type."""
|
||||
spec = DEAL_TAXONOMY.get(type_id)
|
||||
if not spec:
|
||||
return []
|
||||
return spec["typical_terms"]
|
||||
|
||||
@staticmethod
|
||||
def suggest_deal_type(
|
||||
capability_category: str,
|
||||
need_category: str,
|
||||
) -> str:
|
||||
"""
|
||||
Suggest the best deal type given a capability and a need.
|
||||
اقتراح أفضل نوع صفقة بناءً على القدرة والاحتياج
|
||||
"""
|
||||
# Priority matrix: (capability_cat, need_cat) -> preferred deal type
|
||||
priority_map: dict[tuple[str, str], str] = {
|
||||
("service", "marketing"): "co_marketing",
|
||||
("service", "sales"): "co_selling",
|
||||
("service", "delivery"): "subcontracting",
|
||||
("service", "technology"): "capability_gap_fill",
|
||||
("product", "distribution"): "reseller",
|
||||
("product", "sales"): "channel_partnership",
|
||||
("expertise", "talent"): "capability_gap_fill",
|
||||
("expertise", "technology"): "white_label",
|
||||
("capacity", "delivery"): "subcontracting",
|
||||
("capacity", "capital"): "joint_venture",
|
||||
("distribution", "marketing"): "co_marketing",
|
||||
("distribution", "sales"): "channel_partnership",
|
||||
("distribution", "distribution"): "reseller",
|
||||
("technology", "technology"): "white_label",
|
||||
("technology", "capital"): "investment_intro",
|
||||
}
|
||||
|
||||
specific = priority_map.get((capability_category, need_category))
|
||||
if specific:
|
||||
logger.info(
|
||||
"Suggested deal type %s for capability=%s, need=%s",
|
||||
specific, capability_category, need_category,
|
||||
)
|
||||
return specific
|
||||
|
||||
# Fallback: find deal types matching the need category
|
||||
candidates = _NEED_TO_DEAL_MAP.get(need_category, [])
|
||||
if candidates:
|
||||
# Prefer lower-risk options first
|
||||
risk_order = {"low": 0, "medium": 1, "high": 2}
|
||||
candidates_sorted = sorted(
|
||||
candidates,
|
||||
key=lambda t: risk_order.get(DEAL_TAXONOMY[t]["risk_level"], 1),
|
||||
)
|
||||
result = candidates_sorted[0]
|
||||
logger.info(
|
||||
"Fallback deal type %s for capability=%s, need=%s",
|
||||
result, capability_category, need_category,
|
||||
)
|
||||
return result
|
||||
|
||||
logger.info(
|
||||
"No specific deal type found for capability=%s, need=%s; defaulting to referral_partnership",
|
||||
capability_category, need_category,
|
||||
)
|
||||
return "referral_partnership"
|
||||
|
||||
@staticmethod
|
||||
def get_risk_level(type_id: str) -> str:
|
||||
"""Return the risk level for a deal type."""
|
||||
spec = DEAL_TAXONOMY.get(type_id)
|
||||
return spec["risk_level"] if spec else "medium"
|
||||
|
||||
@staticmethod
|
||||
def get_approval_level(type_id: str) -> str:
|
||||
"""Return the minimum operating mode required for this deal type."""
|
||||
spec = DEAL_TAXONOMY.get(type_id)
|
||||
return spec["approval_level"] if spec else "mode_3"
|
||||
|
||||
@staticmethod
|
||||
def search_types(query: str) -> list[DealTypeSpec]:
|
||||
"""
|
||||
Search deal types by keyword (English or Arabic).
|
||||
بحث في أنواع الصفقات بكلمة مفتاحية
|
||||
"""
|
||||
query_lower = query.lower().strip()
|
||||
results = []
|
||||
for type_id, spec in DEAL_TAXONOMY.items():
|
||||
searchable = " ".join([
|
||||
type_id,
|
||||
spec["name"].lower(),
|
||||
spec["name_ar"],
|
||||
spec["description"].lower(),
|
||||
spec["description_ar"],
|
||||
])
|
||||
if query_lower in searchable:
|
||||
results.append(DealTypeSpec(id=type_id, **spec))
|
||||
return results
|
||||
@ -0,0 +1,568 @@
|
||||
"""
|
||||
Ecosystem Mapper — Maps and analyzes B2B partner ecosystems in the Saudi market.
|
||||
خريطة المنظومة: رسم وتحليل منظومة الشركاء في السوق السعودي
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.strategic_deal import CompanyProfile
|
||||
from app.services.llm.provider import get_llm
|
||||
|
||||
logger = logging.getLogger("dealix.strategic_deals.ecosystem_mapper")
|
||||
|
||||
# ── Entity type definitions ─────────────────────────────────────────────────
|
||||
|
||||
ENTITY_TYPES = {
|
||||
"agency": "وكالة",
|
||||
"integrator": "مُدمج أنظمة",
|
||||
"reseller": "موزع معتمد",
|
||||
"consultant": "مستشار",
|
||||
"distributor": "موزع",
|
||||
"supplier": "مورد",
|
||||
"customer": "عميل",
|
||||
"competitor": "منافس",
|
||||
}
|
||||
|
||||
LINK_TYPES = ("partner", "competitor", "vendor", "client", "referral", "subsidiary")
|
||||
|
||||
# ── Capability clusters for gap analysis ────────────────────────────────────
|
||||
|
||||
CAPABILITY_CLUSTERS = {
|
||||
"تقنية": ["تطوير برمجيات", "حوسبة سحابية", "أمن سيبراني", "ذكاء اصطناعي", "تحليل بيانات"],
|
||||
"تسويق": ["تسويق رقمي", "إعلان", "علاقات عامة", "إدارة محتوى", "سوشل ميديا"],
|
||||
"عمليات": ["لوجستيات", "سلسلة إمداد", "إدارة مخازن", "نقل", "توزيع"],
|
||||
"مالية": ["محاسبة", "تدقيق", "استشارات مالية", "تمويل", "إدارة مخاطر"],
|
||||
"موارد بشرية": ["توظيف", "تدريب", "تطوير مهني", "رواتب", "شؤون موظفين"],
|
||||
"قانونية": ["استشارات قانونية", "عقود", "ملكية فكرية", "امتثال", "تراخيص"],
|
||||
"مبيعات": ["مبيعات مباشرة", "مبيعات قنوات", "تطوير أعمال", "إدارة حسابات", "عروض أسعار"],
|
||||
}
|
||||
|
||||
|
||||
# ── Models ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class EcosystemEntity(BaseModel):
|
||||
"""A node in the ecosystem graph representing a company or organization."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
name: str
|
||||
name_ar: str = ""
|
||||
entity_type: str = "partner" # agency, integrator, reseller, consultant, distributor
|
||||
industry: str = ""
|
||||
city: str = ""
|
||||
capabilities: list[str] = Field(default_factory=list)
|
||||
relationship_strength: float = Field(0.0, ge=0.0, le=1.0)
|
||||
partner_potential: float = Field(0.0, ge=0.0, le=1.0)
|
||||
profile_id: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"name": "DataSphere Solutions",
|
||||
"name_ar": "حلول داتا سفير",
|
||||
"entity_type": "integrator",
|
||||
"industry": "technology",
|
||||
"city": "الرياض",
|
||||
"capabilities": ["حوسبة سحابية", "أمن سيبراني"],
|
||||
"relationship_strength": 0.8,
|
||||
"partner_potential": 0.75,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class EcosystemLink(BaseModel):
|
||||
"""An edge in the ecosystem graph representing a relationship."""
|
||||
source_id: str
|
||||
target_id: str
|
||||
link_type: str = "partner" # partner, competitor, vendor, client
|
||||
strength: float = Field(0.5, ge=0.0, le=1.0)
|
||||
description_ar: str = ""
|
||||
|
||||
|
||||
# ── Ecosystem Mapper Engine ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class EcosystemMapper:
|
||||
"""
|
||||
Builds, analyzes, and visualizes B2B ecosystem maps.
|
||||
Identifies gaps, suggests partners, and monitors ecosystem health.
|
||||
بناء وتحليل وعرض خرائط منظومة الأعمال — تحديد الفجوات واقتراح الشركاء
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.llm = get_llm()
|
||||
|
||||
# ── Build Map ───────────────────────────────────────────────────────────
|
||||
|
||||
async def build_map(
|
||||
self,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""
|
||||
Build a complete ecosystem map from company profiles and deal history.
|
||||
بناء خريطة منظومة كاملة من ملفات الشركات وتاريخ الصفقات
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(CompanyProfile).where(CompanyProfile.tenant_id == tenant_id)
|
||||
)
|
||||
profiles = result.scalars().all()
|
||||
|
||||
if not profiles:
|
||||
logger.info("No profiles found for tenant %s", tenant_id)
|
||||
return {"entities": [], "links": [], "stats": {}}
|
||||
|
||||
entities: list[EcosystemEntity] = []
|
||||
links: list[EcosystemLink] = []
|
||||
|
||||
# Build entity nodes from profiles
|
||||
entity_map: dict[str, EcosystemEntity] = {}
|
||||
for profile in profiles:
|
||||
entity_type = self._infer_entity_type(profile)
|
||||
entity = EcosystemEntity(
|
||||
name=profile.company_name or "",
|
||||
name_ar=profile.company_name_ar if hasattr(profile, "company_name_ar") else "",
|
||||
entity_type=entity_type,
|
||||
industry=profile.industry or "",
|
||||
city=profile.region or "",
|
||||
capabilities=[c for c in (profile.capabilities or [])],
|
||||
relationship_strength=float(profile.trust_score or 0.5),
|
||||
partner_potential=0.0,
|
||||
profile_id=str(profile.id),
|
||||
)
|
||||
entities.append(entity)
|
||||
entity_map[str(profile.id)] = entity
|
||||
|
||||
# Infer links based on capability/need overlap and industry relationships
|
||||
profile_list = list(profiles)
|
||||
for i, prof_a in enumerate(profile_list):
|
||||
for prof_b in profile_list[i + 1:]:
|
||||
link_type, strength = self._infer_link(prof_a, prof_b)
|
||||
if strength >= 0.2:
|
||||
entity_a = entity_map.get(str(prof_a.id))
|
||||
entity_b = entity_map.get(str(prof_b.id))
|
||||
if entity_a and entity_b:
|
||||
link = EcosystemLink(
|
||||
source_id=entity_a.id,
|
||||
target_id=entity_b.id,
|
||||
link_type=link_type,
|
||||
strength=round(strength, 4),
|
||||
description_ar=self._link_description(
|
||||
prof_a.company_name, prof_b.company_name, link_type
|
||||
),
|
||||
)
|
||||
links.append(link)
|
||||
|
||||
# Compute partner potential for each entity
|
||||
for entity in entities:
|
||||
incoming = [lk for lk in links if lk.target_id == entity.id]
|
||||
outgoing = [lk for lk in links if lk.source_id == entity.id]
|
||||
partner_links = [
|
||||
lk for lk in incoming + outgoing
|
||||
if lk.link_type in ("partner", "referral")
|
||||
]
|
||||
if partner_links:
|
||||
entity.partner_potential = round(
|
||||
sum(lk.strength for lk in partner_links) / len(partner_links), 4
|
||||
)
|
||||
|
||||
stats = {
|
||||
"total_entities": len(entities),
|
||||
"total_links": len(links),
|
||||
"entity_types": defaultdict(int),
|
||||
"link_types": defaultdict(int),
|
||||
"avg_relationship_strength": 0.0,
|
||||
}
|
||||
for e in entities:
|
||||
stats["entity_types"][e.entity_type] += 1
|
||||
for lk in links:
|
||||
stats["link_types"][lk.link_type] += 1
|
||||
if entities:
|
||||
stats["avg_relationship_strength"] = round(
|
||||
sum(e.relationship_strength for e in entities) / len(entities), 4
|
||||
)
|
||||
stats["entity_types"] = dict(stats["entity_types"])
|
||||
stats["link_types"] = dict(stats["link_types"])
|
||||
|
||||
logger.info(
|
||||
"Built ecosystem map for tenant %s: %d entities, %d links",
|
||||
tenant_id, len(entities), len(links),
|
||||
)
|
||||
|
||||
return {
|
||||
"entities": [e.model_dump() for e in entities],
|
||||
"links": [lk.model_dump() for lk in links],
|
||||
"stats": stats,
|
||||
}
|
||||
|
||||
# ── Find Gaps ───────────────────────────────────────────────────────────
|
||||
|
||||
async def find_gaps(
|
||||
self,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Identify underserved areas in the ecosystem where partners are missing.
|
||||
تحديد المناطق غير المخدومة في المنظومة حيث ينقص الشركاء
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(CompanyProfile).where(CompanyProfile.tenant_id == tenant_id)
|
||||
)
|
||||
profiles = result.scalars().all()
|
||||
|
||||
if not profiles:
|
||||
return []
|
||||
|
||||
# Collect all capabilities and all needs across the ecosystem
|
||||
all_capabilities: set[str] = set()
|
||||
all_needs: set[str] = set()
|
||||
for profile in profiles:
|
||||
for cap in (profile.capabilities or []):
|
||||
all_capabilities.add(cap.lower().strip())
|
||||
for need in (profile.needs or []):
|
||||
all_needs.add(need.lower().strip())
|
||||
|
||||
# Gaps: needs that no one in the ecosystem can fulfill
|
||||
unmet_needs = all_needs - all_capabilities
|
||||
|
||||
# Cluster-level gaps: entire capability clusters with low coverage
|
||||
cluster_gaps: list[dict] = []
|
||||
for cluster_name, cluster_caps in CAPABILITY_CLUSTERS.items():
|
||||
cluster_lower = {c.lower() for c in cluster_caps}
|
||||
covered = cluster_lower & all_capabilities
|
||||
coverage = len(covered) / len(cluster_lower) if cluster_lower else 0
|
||||
if coverage < 0.3:
|
||||
cluster_gaps.append({
|
||||
"gap_type": "cluster",
|
||||
"cluster_name_ar": cluster_name,
|
||||
"coverage": round(coverage, 4),
|
||||
"missing_capabilities": list(cluster_lower - all_capabilities),
|
||||
"recommendation_ar": f"المنظومة تفتقر لشركاء في مجال {cluster_name} — التغطية {coverage:.0%} فقط",
|
||||
})
|
||||
|
||||
# Individual unmet needs
|
||||
individual_gaps = [
|
||||
{
|
||||
"gap_type": "unmet_need",
|
||||
"need": need,
|
||||
"recommendation_ar": f"لا يوجد شريك يقدم: {need}",
|
||||
}
|
||||
for need in sorted(unmet_needs)[:20]
|
||||
]
|
||||
|
||||
gaps = cluster_gaps + individual_gaps
|
||||
|
||||
logger.info(
|
||||
"Found %d ecosystem gaps for tenant %s (%d cluster, %d individual)",
|
||||
len(gaps), tenant_id, len(cluster_gaps), len(individual_gaps),
|
||||
)
|
||||
return gaps
|
||||
|
||||
# ── Suggest Partners ────────────────────────────────────────────────────
|
||||
|
||||
async def suggest_partners(
|
||||
self,
|
||||
gap_type: str,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> list[EcosystemEntity]:
|
||||
"""
|
||||
Suggest potential partners to fill an ecosystem gap.
|
||||
اقتراح شركاء محتملين لسد فجوة في المنظومة
|
||||
"""
|
||||
gaps = await self.find_gaps(tenant_id, db)
|
||||
|
||||
matching_gaps = [g for g in gaps if g.get("gap_type") == gap_type]
|
||||
if not matching_gaps:
|
||||
matching_gaps = gaps[:3]
|
||||
|
||||
gap_summary = json.dumps(matching_gaps[:5], ensure_ascii=False)
|
||||
|
||||
context = f"""فجوات المنظومة:
|
||||
{gap_summary}
|
||||
|
||||
نوع الفجوة المطلوب: {gap_type}"""
|
||||
|
||||
system_prompt = """أنت مستشار تطوير أعمال سعودي. بناءً على فجوات المنظومة، اقترح شركاء محتملين.
|
||||
|
||||
Return JSON:
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"name": "اسم النوع المقترح بالإنجليزي",
|
||||
"name_ar": "اسم النوع المقترح بالعربي",
|
||||
"entity_type": "agency/integrator/reseller/consultant/distributor",
|
||||
"industry": "القطاع",
|
||||
"capabilities": ["قدرة ١", "قدرة ٢"],
|
||||
"rationale_ar": "سبب الاقتراح بالعربي",
|
||||
"partner_potential": 0.0 to 1.0
|
||||
}
|
||||
]
|
||||
}"""
|
||||
|
||||
try:
|
||||
llm_response = await self.llm.complete(
|
||||
system_prompt=system_prompt,
|
||||
user_message=context,
|
||||
json_mode=True,
|
||||
temperature=0.4,
|
||||
)
|
||||
result = llm_response.parse_json() or {}
|
||||
suggestions_data = result.get("suggestions", [])
|
||||
except Exception as exc:
|
||||
logger.warning("LLM partner suggestion failed: %s", exc)
|
||||
suggestions_data = [
|
||||
{
|
||||
"name": f"Partner for {gap_type}",
|
||||
"name_ar": f"شريك لسد فجوة {gap_type}",
|
||||
"entity_type": "consultant",
|
||||
"industry": "consulting",
|
||||
"capabilities": [g.get("need", "") for g in matching_gaps if g.get("need")],
|
||||
"rationale_ar": "اقتراح تلقائي بناءً على الفجوات المكتشفة",
|
||||
"partner_potential": 0.5,
|
||||
}
|
||||
]
|
||||
|
||||
entities: list[EcosystemEntity] = []
|
||||
for s in suggestions_data:
|
||||
entity = EcosystemEntity(
|
||||
name=s.get("name", ""),
|
||||
name_ar=s.get("name_ar", ""),
|
||||
entity_type=s.get("entity_type", "consultant"),
|
||||
industry=s.get("industry", ""),
|
||||
capabilities=s.get("capabilities", []),
|
||||
relationship_strength=0.0,
|
||||
partner_potential=min(1.0, max(0.0, float(s.get("partner_potential", 0.5)))),
|
||||
)
|
||||
entities.append(entity)
|
||||
|
||||
logger.info(
|
||||
"Suggested %d partners for gap '%s' in tenant %s",
|
||||
len(entities), gap_type, tenant_id,
|
||||
)
|
||||
return entities
|
||||
|
||||
# ── Get Clusters ────────────────────────────────────────────────────────
|
||||
|
||||
async def get_clusters(
|
||||
self,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Identify clusters of related entities in the ecosystem.
|
||||
تحديد تجمعات الكيانات المترابطة في المنظومة
|
||||
"""
|
||||
eco_map = await self.build_map(tenant_id, db)
|
||||
entities = eco_map.get("entities", [])
|
||||
links = eco_map.get("links", [])
|
||||
|
||||
if not entities:
|
||||
return []
|
||||
|
||||
# Group entities by industry
|
||||
industry_groups: dict[str, list[dict]] = defaultdict(list)
|
||||
for entity in entities:
|
||||
industry_groups[entity.get("industry", "other")].append(entity)
|
||||
|
||||
clusters: list[dict] = []
|
||||
for industry, members in industry_groups.items():
|
||||
if not members:
|
||||
continue
|
||||
|
||||
member_ids = {m["id"] for m in members}
|
||||
internal_links = [
|
||||
lk for lk in links
|
||||
if lk.get("source_id") in member_ids and lk.get("target_id") in member_ids
|
||||
]
|
||||
external_links = [
|
||||
lk for lk in links
|
||||
if (lk.get("source_id") in member_ids) != (lk.get("target_id") in member_ids)
|
||||
]
|
||||
|
||||
avg_strength = 0.0
|
||||
if internal_links:
|
||||
avg_strength = sum(lk.get("strength", 0) for lk in internal_links) / len(internal_links)
|
||||
|
||||
all_caps: set[str] = set()
|
||||
for m in members:
|
||||
all_caps.update(m.get("capabilities", []))
|
||||
|
||||
clusters.append({
|
||||
"cluster_name": industry,
|
||||
"cluster_name_ar": ENTITY_TYPES.get(industry, industry),
|
||||
"member_count": len(members),
|
||||
"internal_links": len(internal_links),
|
||||
"external_links": len(external_links),
|
||||
"avg_internal_strength": round(avg_strength, 4),
|
||||
"capabilities": sorted(all_caps),
|
||||
"members": [{"id": m["id"], "name": m["name"]} for m in members],
|
||||
})
|
||||
|
||||
clusters.sort(key=lambda c: c["member_count"], reverse=True)
|
||||
|
||||
logger.info("Identified %d clusters for tenant %s", len(clusters), tenant_id)
|
||||
return clusters
|
||||
|
||||
# ── Ecosystem Health ────────────────────────────────────────────────────
|
||||
|
||||
async def get_ecosystem_health(
|
||||
self,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""
|
||||
Calculate ecosystem health metrics: coverage, concentration, resilience.
|
||||
حساب مؤشرات صحة المنظومة: التغطية والتركيز والمرونة
|
||||
"""
|
||||
eco_map = await self.build_map(tenant_id, db)
|
||||
entities = eco_map.get("entities", [])
|
||||
links = eco_map.get("links", [])
|
||||
gaps = await self.find_gaps(tenant_id, db)
|
||||
|
||||
total_entities = len(entities)
|
||||
total_links = len(links)
|
||||
total_gaps = len(gaps)
|
||||
|
||||
if total_entities == 0:
|
||||
return {
|
||||
"overall_score": 0.0,
|
||||
"coverage": 0.0,
|
||||
"concentration_risk": 1.0,
|
||||
"resilience": 0.0,
|
||||
"diversity": 0.0,
|
||||
"gap_count": 0,
|
||||
"recommendations_ar": ["لا توجد بيانات كافية لتحليل صحة المنظومة"],
|
||||
}
|
||||
|
||||
# Coverage: ratio of cluster gaps (lower = better coverage)
|
||||
cluster_gaps = [g for g in gaps if g.get("gap_type") == "cluster"]
|
||||
total_clusters = len(CAPABILITY_CLUSTERS)
|
||||
coverage = 1.0 - (len(cluster_gaps) / total_clusters) if total_clusters > 0 else 0.0
|
||||
|
||||
# Concentration risk: how dependent the ecosystem is on few entities
|
||||
type_counts = defaultdict(int)
|
||||
for e in entities:
|
||||
type_counts[e.get("entity_type", "unknown")] += 1
|
||||
max_type_share = max(type_counts.values()) / total_entities if total_entities > 0 else 1.0
|
||||
concentration_risk = max_type_share
|
||||
|
||||
# Diversity: number of distinct entity types / total possible
|
||||
diversity = len(type_counts) / len(ENTITY_TYPES) if ENTITY_TYPES else 0.0
|
||||
|
||||
# Resilience: avg links per entity (more links = more resilient)
|
||||
avg_links = total_links / total_entities if total_entities > 0 else 0.0
|
||||
resilience = min(1.0, avg_links / 3.0) # 3+ links per entity = max resilience
|
||||
|
||||
# Overall health score
|
||||
overall = round(
|
||||
coverage * 0.35
|
||||
+ (1.0 - concentration_risk) * 0.25
|
||||
+ resilience * 0.25
|
||||
+ diversity * 0.15,
|
||||
4,
|
||||
)
|
||||
|
||||
# Generate recommendations
|
||||
recommendations_ar: list[str] = []
|
||||
if coverage < 0.5:
|
||||
recommendations_ar.append("تغطية المنظومة ضعيفة — يُنصح بإضافة شركاء في القطاعات الناقصة")
|
||||
if concentration_risk > 0.6:
|
||||
recommendations_ar.append("تركيز عالٍ على نوع واحد من الشركاء — يُنصح بالتنويع")
|
||||
if resilience < 0.4:
|
||||
recommendations_ar.append("مرونة المنظومة منخفضة — يُنصح بتعزيز الروابط بين الشركاء")
|
||||
if diversity < 0.5:
|
||||
recommendations_ar.append("تنوع أنواع الشركاء محدود — يُنصح بإضافة أنواع جديدة")
|
||||
if total_gaps > 10:
|
||||
recommendations_ar.append(f"يوجد {total_gaps} فجوة في المنظومة — يُنصح بمعالجة الفجوات الحرجة أولاً")
|
||||
if not recommendations_ar:
|
||||
recommendations_ar.append("المنظومة في حالة صحية جيدة — استمر في المراقبة الدورية")
|
||||
|
||||
health = {
|
||||
"overall_score": overall,
|
||||
"coverage": round(coverage, 4),
|
||||
"concentration_risk": round(concentration_risk, 4),
|
||||
"resilience": round(resilience, 4),
|
||||
"diversity": round(diversity, 4),
|
||||
"gap_count": total_gaps,
|
||||
"total_entities": total_entities,
|
||||
"total_links": total_links,
|
||||
"entity_type_distribution": dict(type_counts),
|
||||
"recommendations_ar": recommendations_ar,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Ecosystem health for tenant %s: overall=%.2f coverage=%.2f risk=%.2f",
|
||||
tenant_id, overall, coverage, concentration_risk,
|
||||
)
|
||||
return health
|
||||
|
||||
# ── Private Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
def _infer_entity_type(self, profile: CompanyProfile) -> str:
|
||||
"""Infer entity type from company profile characteristics."""
|
||||
caps = {c.lower() for c in (profile.capabilities or [])}
|
||||
industry = (profile.industry or "").lower()
|
||||
|
||||
if industry == "consulting" or "استشارات" in caps:
|
||||
return "consultant"
|
||||
if "توزيع" in caps or "distribution" in industry:
|
||||
return "distributor"
|
||||
if "تكامل" in caps or "integration" in industry or "تكامل أنظمة" in caps:
|
||||
return "integrator"
|
||||
if "إعادة بيع" in caps or "reselling" in industry:
|
||||
return "reseller"
|
||||
if industry in ("marketing", "media") or "تسويق" in caps:
|
||||
return "agency"
|
||||
return "partner"
|
||||
|
||||
def _infer_link(
|
||||
self, prof_a: CompanyProfile, prof_b: CompanyProfile,
|
||||
) -> tuple[str, float]:
|
||||
"""Infer the link type and strength between two profiles."""
|
||||
caps_a = {c.lower() for c in (prof_a.capabilities or [])}
|
||||
caps_b = {c.lower() for c in (prof_b.capabilities or [])}
|
||||
needs_a = {n.lower() for n in (prof_a.needs or [])}
|
||||
needs_b = {n.lower() for n in (prof_b.needs or [])}
|
||||
|
||||
# Check if they are in the same industry (potential competitors)
|
||||
same_industry = (prof_a.industry or "") == (prof_b.industry or "") and prof_a.industry
|
||||
|
||||
# Check vendor/client: A offers what B needs
|
||||
a_serves_b = len(caps_a & needs_b)
|
||||
b_serves_a = len(caps_b & needs_a)
|
||||
|
||||
if a_serves_b > 0 and b_serves_a > 0:
|
||||
# Mutual exchange = partnership
|
||||
strength = min(1.0, (a_serves_b + b_serves_a) / max(len(needs_a | needs_b), 1) * 2)
|
||||
return "partner", round(strength, 4)
|
||||
elif a_serves_b > 0:
|
||||
strength = min(1.0, a_serves_b / max(len(needs_b), 1))
|
||||
return "vendor", round(strength, 4)
|
||||
elif b_serves_a > 0:
|
||||
strength = min(1.0, b_serves_a / max(len(needs_a), 1))
|
||||
return "client", round(strength, 4)
|
||||
elif same_industry and caps_a & caps_b:
|
||||
overlap = len(caps_a & caps_b) / max(len(caps_a | caps_b), 1)
|
||||
return "competitor", round(overlap, 4)
|
||||
else:
|
||||
return "partner", 0.1
|
||||
|
||||
def _link_description(self, name_a: str, name_b: str, link_type: str) -> str:
|
||||
"""Generate Arabic description for a link."""
|
||||
descriptions = {
|
||||
"partner": f"{name_a} و{name_b} شركاء محتملون",
|
||||
"competitor": f"{name_a} و{name_b} في نفس المجال التنافسي",
|
||||
"vendor": f"{name_a} مورد محتمل لـ{name_b}",
|
||||
"client": f"{name_a} عميل محتمل لـ{name_b}",
|
||||
"referral": f"{name_a} و{name_b} في شبكة إحالات مشتركة",
|
||||
}
|
||||
return descriptions.get(link_type, f"علاقة بين {name_a} و{name_b}")
|
||||
@ -0,0 +1,429 @@
|
||||
"""
|
||||
Operating Modes — Five levels of AI autonomy for deal management.
|
||||
أوضاع التشغيل: خمسة مستويات لصلاحيات الذكاء الاصطناعي في إدارة الصفقات
|
||||
"""
|
||||
|
||||
import enum
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.strategic_deal import CompanyProfile
|
||||
|
||||
logger = logging.getLogger("dealix.strategic_deals.operating_modes")
|
||||
|
||||
|
||||
# ── Operating Modes ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class OperatingMode(int, enum.Enum):
|
||||
"""
|
||||
Five escalating levels of AI autonomy.
|
||||
خمسة مستويات تصاعدية لاستقلالية الذكاء الاصطناعي
|
||||
"""
|
||||
MANUAL = 0 # AI analyzes, human does everything / الذكاء الاصطناعي يحلل والإنسان ينفذ
|
||||
DRAFT = 1 # AI writes drafts, human sends / الذكاء الاصطناعي يكتب والإنسان يرسل
|
||||
ASSISTED = 2 # AI sends approved templates via email / الذكاء الاصطناعي يرسل قوالب معتمدة بالإيميل
|
||||
NEGOTIATION = 3 # AI negotiates within defined gates / الذكاء الاصطناعي يفاوض ضمن حدود محددة
|
||||
STRATEGIC = 4 # Full workflow with mandatory escalation for commitments / سير عمل كامل مع تصعيد إلزامي للالتزامات
|
||||
|
||||
|
||||
MODE_LABELS_AR = {
|
||||
OperatingMode.MANUAL: "يدوي",
|
||||
OperatingMode.DRAFT: "مسودات",
|
||||
OperatingMode.ASSISTED: "مساعد",
|
||||
OperatingMode.NEGOTIATION: "تفاوض",
|
||||
OperatingMode.STRATEGIC: "استراتيجي",
|
||||
}
|
||||
|
||||
MODE_DESCRIPTIONS_AR = {
|
||||
OperatingMode.MANUAL: "الذكاء الاصطناعي يحلل ويقترح فقط — أنت تنفذ كل شيء",
|
||||
OperatingMode.DRAFT: "الذكاء الاصطناعي يكتب المسودات — أنت تراجع وترسل",
|
||||
OperatingMode.ASSISTED: "الذكاء الاصطناعي يرسل القوالب المعتمدة عبر البريد الإلكتروني تلقائياً",
|
||||
OperatingMode.NEGOTIATION: "الذكاء الاصطناعي يتفاوض ضمن الحدود المحددة — يصعّد عند الحاجة",
|
||||
OperatingMode.STRATEGIC: "سير عمل كامل مع تصعيد إلزامي لأي التزام مالي أو قانوني",
|
||||
}
|
||||
|
||||
|
||||
# ── Mode Policy ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ModePolicy(BaseModel):
|
||||
"""Policy governing what an AI agent can do in a given operating mode."""
|
||||
mode: int # OperatingMode value
|
||||
allowed_channels: list[str] = Field(default_factory=list)
|
||||
allowed_actions: list[str] = Field(default_factory=list)
|
||||
auto_send: bool = False
|
||||
auto_negotiate: bool = False
|
||||
escalation_triggers: list[str] = Field(default_factory=list)
|
||||
max_auto_commitment_sar: float = 0.0
|
||||
|
||||
# Labels
|
||||
label_ar: str = ""
|
||||
description_ar: str = ""
|
||||
|
||||
|
||||
# ── Predefined Policies ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
MODE_POLICIES: dict[OperatingMode, ModePolicy] = {
|
||||
OperatingMode.MANUAL: ModePolicy(
|
||||
mode=OperatingMode.MANUAL.value,
|
||||
allowed_channels=[],
|
||||
allowed_actions=[
|
||||
"analyze",
|
||||
"suggest",
|
||||
"draft",
|
||||
"score_match",
|
||||
"generate_report",
|
||||
],
|
||||
auto_send=False,
|
||||
auto_negotiate=False,
|
||||
escalation_triggers=["all"],
|
||||
max_auto_commitment_sar=0,
|
||||
label_ar="يدوي",
|
||||
description_ar="الذكاء الاصطناعي يحلل ويقترح فقط — أنت تنفذ كل شيء",
|
||||
),
|
||||
OperatingMode.DRAFT: ModePolicy(
|
||||
mode=OperatingMode.DRAFT.value,
|
||||
allowed_channels=[],
|
||||
allowed_actions=[
|
||||
"analyze",
|
||||
"suggest",
|
||||
"draft",
|
||||
"score_match",
|
||||
"generate_report",
|
||||
"craft_introduction",
|
||||
"draft_proposal",
|
||||
"draft_counter_offer",
|
||||
],
|
||||
auto_send=False,
|
||||
auto_negotiate=False,
|
||||
escalation_triggers=["all"],
|
||||
max_auto_commitment_sar=0,
|
||||
label_ar="مسودات",
|
||||
description_ar="الذكاء الاصطناعي يكتب المسودات — أنت تراجع وترسل",
|
||||
),
|
||||
OperatingMode.ASSISTED: ModePolicy(
|
||||
mode=OperatingMode.ASSISTED.value,
|
||||
allowed_channels=["email"],
|
||||
allowed_actions=[
|
||||
"analyze",
|
||||
"suggest",
|
||||
"draft",
|
||||
"score_match",
|
||||
"generate_report",
|
||||
"craft_introduction",
|
||||
"draft_proposal",
|
||||
"send_template",
|
||||
"send_follow_up",
|
||||
"schedule_reminder",
|
||||
],
|
||||
auto_send=True,
|
||||
auto_negotiate=False,
|
||||
escalation_triggers=[
|
||||
"reply_received",
|
||||
"objection",
|
||||
"pricing_question",
|
||||
"meeting_request",
|
||||
"negative_sentiment",
|
||||
],
|
||||
max_auto_commitment_sar=0,
|
||||
label_ar="مساعد",
|
||||
description_ar="الذكاء الاصطناعي يرسل القوالب المعتمدة عبر البريد الإلكتروني تلقائياً",
|
||||
),
|
||||
OperatingMode.NEGOTIATION: ModePolicy(
|
||||
mode=OperatingMode.NEGOTIATION.value,
|
||||
allowed_channels=["email", "whatsapp"],
|
||||
allowed_actions=[
|
||||
"analyze",
|
||||
"suggest",
|
||||
"draft",
|
||||
"score_match",
|
||||
"generate_report",
|
||||
"craft_introduction",
|
||||
"draft_proposal",
|
||||
"send_template",
|
||||
"send_follow_up",
|
||||
"schedule_reminder",
|
||||
"send_custom_message",
|
||||
"handle_response",
|
||||
"counter_offer",
|
||||
"negotiate_terms",
|
||||
"record_concession",
|
||||
],
|
||||
auto_send=True,
|
||||
auto_negotiate=True,
|
||||
escalation_triggers=[
|
||||
"pricing_change",
|
||||
"exclusivity",
|
||||
"equity",
|
||||
"legal_terms",
|
||||
"value_above_threshold",
|
||||
"human_requested",
|
||||
"stall_detected",
|
||||
],
|
||||
max_auto_commitment_sar=50_000,
|
||||
label_ar="تفاوض",
|
||||
description_ar="الذكاء الاصطناعي يتفاوض ضمن الحدود المحددة — يصعّد عند الحاجة",
|
||||
),
|
||||
OperatingMode.STRATEGIC: ModePolicy(
|
||||
mode=OperatingMode.STRATEGIC.value,
|
||||
allowed_channels=["email", "whatsapp"],
|
||||
allowed_actions=[
|
||||
"analyze",
|
||||
"suggest",
|
||||
"draft",
|
||||
"score_match",
|
||||
"generate_report",
|
||||
"craft_introduction",
|
||||
"draft_proposal",
|
||||
"send_template",
|
||||
"send_follow_up",
|
||||
"schedule_reminder",
|
||||
"send_custom_message",
|
||||
"handle_response",
|
||||
"counter_offer",
|
||||
"negotiate_terms",
|
||||
"record_concession",
|
||||
"request_approval",
|
||||
"generate_term_sheet",
|
||||
"run_discovery_scan",
|
||||
"run_outreach_campaign",
|
||||
],
|
||||
auto_send=True,
|
||||
auto_negotiate=True,
|
||||
escalation_triggers=[
|
||||
"commitment",
|
||||
"exclusivity",
|
||||
"equity",
|
||||
"legal",
|
||||
"data_sharing",
|
||||
"ip_licensing",
|
||||
"territory_change",
|
||||
"value_above_threshold",
|
||||
"human_requested",
|
||||
],
|
||||
max_auto_commitment_sar=100_000,
|
||||
label_ar="استراتيجي",
|
||||
description_ar="سير عمل كامل مع تصعيد إلزامي لأي التزام مالي أو قانوني",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ── Mode Enforcer ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ModeEnforcer:
|
||||
"""
|
||||
Enforces operating mode policies before any AI action is executed.
|
||||
يفرض سياسات وضع التشغيل قبل تنفيذ أي إجراء للذكاء الاصطناعي
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def check_action(
|
||||
mode: OperatingMode,
|
||||
action: str,
|
||||
deal_value: float,
|
||||
db: AsyncSession,
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Check whether an action is allowed under the current operating mode.
|
||||
Returns (allowed, reason_ar).
|
||||
|
||||
التحقق مما إذا كان الإجراء مسموحاً في وضع التشغيل الحالي
|
||||
يرجع (مسموح، السبب_بالعربي)
|
||||
"""
|
||||
policy = MODE_POLICIES.get(mode)
|
||||
if not policy:
|
||||
return False, f"وضع التشغيل غير معروف: {mode}"
|
||||
|
||||
# Check if action is in allowed list
|
||||
if action not in policy.allowed_actions:
|
||||
mode_label = MODE_LABELS_AR.get(mode, str(mode))
|
||||
return False, (
|
||||
f"الإجراء '{action}' غير مسموح في وضع '{mode_label}'. "
|
||||
f"الإجراءات المتاحة: {', '.join(policy.allowed_actions)}"
|
||||
)
|
||||
|
||||
# Check if deal value exceeds auto-commitment threshold
|
||||
if deal_value > 0 and deal_value > policy.max_auto_commitment_sar:
|
||||
return False, (
|
||||
f"قيمة الصفقة ({deal_value:,.0f} ريال) تتجاوز الحد الأقصى للالتزام التلقائي "
|
||||
f"({policy.max_auto_commitment_sar:,.0f} ريال). يلزم تصعيد للإنسان."
|
||||
)
|
||||
|
||||
# Check escalation triggers
|
||||
escalation_actions = {
|
||||
"counter_offer": ["pricing_change"],
|
||||
"negotiate_terms": ["pricing_change", "legal_terms"],
|
||||
"send_custom_message": [],
|
||||
"handle_response": ["reply_received"],
|
||||
"generate_term_sheet": ["legal_terms", "commitment"],
|
||||
"run_outreach_campaign": [],
|
||||
}
|
||||
|
||||
action_triggers = escalation_actions.get(action, [])
|
||||
for trigger in action_triggers:
|
||||
if trigger in policy.escalation_triggers:
|
||||
if not policy.auto_negotiate:
|
||||
mode_label = MODE_LABELS_AR.get(mode, str(mode))
|
||||
return False, (
|
||||
f"الإجراء '{action}' يستلزم تصعيداً بسبب: {trigger}. "
|
||||
f"وضع '{mode_label}' لا يسمح بالتفاوض التلقائي."
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Action '%s' allowed in mode %s (deal_value=%.0f SAR)",
|
||||
action, mode.name, deal_value,
|
||||
)
|
||||
return True, "مسموح"
|
||||
|
||||
@staticmethod
|
||||
async def check_channel(
|
||||
mode: OperatingMode,
|
||||
channel: str,
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Check whether a communication channel is allowed under the current mode.
|
||||
التحقق مما إذا كانت قناة الاتصال مسموحة في الوضع الحالي
|
||||
"""
|
||||
policy = MODE_POLICIES.get(mode)
|
||||
if not policy:
|
||||
return False, f"وضع التشغيل غير معروف: {mode}"
|
||||
|
||||
if not policy.allowed_channels:
|
||||
mode_label = MODE_LABELS_AR.get(mode, str(mode))
|
||||
return False, f"وضع '{mode_label}' لا يسمح بأي قناة اتصال. الإرسال يتم يدوياً."
|
||||
|
||||
if channel not in policy.allowed_channels:
|
||||
mode_label = MODE_LABELS_AR.get(mode, str(mode))
|
||||
return False, (
|
||||
f"القناة '{channel}' غير مسموحة في وضع '{mode_label}'. "
|
||||
f"القنوات المتاحة: {', '.join(policy.allowed_channels)}"
|
||||
)
|
||||
|
||||
return True, "مسموح"
|
||||
|
||||
@staticmethod
|
||||
async def get_current_mode(
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> OperatingMode:
|
||||
"""
|
||||
Get the current operating mode for a tenant.
|
||||
الحصول على وضع التشغيل الحالي للمستأجر
|
||||
"""
|
||||
# Mode is stored in the tenant's first company profile deal_preferences
|
||||
result = await db.execute(
|
||||
select(CompanyProfile).where(
|
||||
CompanyProfile.tenant_id == tenant_id
|
||||
).limit(1)
|
||||
)
|
||||
profile = result.scalar_one_or_none()
|
||||
if not profile:
|
||||
logger.info("No profile found for tenant %s, defaulting to MANUAL", tenant_id)
|
||||
return OperatingMode.MANUAL
|
||||
|
||||
prefs = profile.deal_preferences or {}
|
||||
mode_value = prefs.get("_operating_mode", OperatingMode.MANUAL.value)
|
||||
|
||||
try:
|
||||
return OperatingMode(mode_value)
|
||||
except ValueError:
|
||||
logger.warning("Invalid operating mode %s for tenant %s, defaulting to MANUAL", mode_value, tenant_id)
|
||||
return OperatingMode.MANUAL
|
||||
|
||||
@staticmethod
|
||||
async def set_mode(
|
||||
tenant_id: str,
|
||||
mode: OperatingMode,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""
|
||||
Set the operating mode for a tenant.
|
||||
تعيين وضع التشغيل للمستأجر
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(CompanyProfile).where(
|
||||
CompanyProfile.tenant_id == tenant_id
|
||||
).limit(1)
|
||||
)
|
||||
profile = result.scalar_one_or_none()
|
||||
if not profile:
|
||||
raise ValueError(f"لا يوجد ملف شركة للمستأجر: {tenant_id}")
|
||||
|
||||
prefs = dict(profile.deal_preferences or {})
|
||||
old_mode = prefs.get("_operating_mode", OperatingMode.MANUAL.value)
|
||||
prefs["_operating_mode"] = mode.value
|
||||
profile.deal_preferences = prefs
|
||||
await db.flush()
|
||||
|
||||
old_label = MODE_LABELS_AR.get(OperatingMode(old_mode), str(old_mode))
|
||||
new_label = MODE_LABELS_AR.get(mode, str(mode))
|
||||
logger.info(
|
||||
"Operating mode for tenant %s changed: %s -> %s",
|
||||
tenant_id, old_label, new_label,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_mode_policy(mode: OperatingMode) -> ModePolicy:
|
||||
"""
|
||||
Get the policy for a specific operating mode.
|
||||
الحصول على سياسة وضع تشغيل محدد
|
||||
"""
|
||||
policy = MODE_POLICIES.get(mode)
|
||||
if not policy:
|
||||
raise ValueError(f"وضع التشغيل غير معروف: {mode}")
|
||||
return policy
|
||||
|
||||
@staticmethod
|
||||
def get_all_modes() -> list[dict]:
|
||||
"""
|
||||
List all operating modes with their labels and descriptions.
|
||||
عرض جميع أوضاع التشغيل مع التسميات والأوصاف
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"mode": mode.value,
|
||||
"name": mode.name,
|
||||
"label_ar": MODE_LABELS_AR[mode],
|
||||
"description_ar": MODE_DESCRIPTIONS_AR[mode],
|
||||
"auto_send": MODE_POLICIES[mode].auto_send,
|
||||
"auto_negotiate": MODE_POLICIES[mode].auto_negotiate,
|
||||
"max_auto_commitment_sar": MODE_POLICIES[mode].max_auto_commitment_sar,
|
||||
"allowed_channels": MODE_POLICIES[mode].allowed_channels,
|
||||
}
|
||||
for mode in OperatingMode
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def should_escalate(
|
||||
mode: OperatingMode,
|
||||
trigger: str,
|
||||
deal_value: float,
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Determine if a specific trigger requires human escalation.
|
||||
تحديد ما إذا كان المحفز يستلزم تصعيداً للإنسان
|
||||
"""
|
||||
policy = MODE_POLICIES.get(mode)
|
||||
if not policy:
|
||||
return True, "وضع التشغيل غير معروف — يجب التصعيد"
|
||||
|
||||
# "all" trigger means everything escalates
|
||||
if "all" in policy.escalation_triggers:
|
||||
return True, f"وضع '{MODE_LABELS_AR.get(mode, '')}' يتطلب تصعيد كل الإجراءات"
|
||||
|
||||
if trigger in policy.escalation_triggers:
|
||||
return True, f"المحفز '{trigger}' يتطلب تصعيداً في وضع '{MODE_LABELS_AR.get(mode, '')}'"
|
||||
|
||||
if deal_value > policy.max_auto_commitment_sar > 0:
|
||||
return True, (
|
||||
f"قيمة الصفقة ({deal_value:,.0f} ريال) تتجاوز الحد ({policy.max_auto_commitment_sar:,.0f} ريال)"
|
||||
)
|
||||
|
||||
return False, "لا يلزم تصعيد"
|
||||
@ -0,0 +1,573 @@
|
||||
"""
|
||||
Portfolio Intelligence — AI-driven insights across the deal portfolio.
|
||||
ذكاء المحفظة: رؤى مدعومة بالذكاء الاصطناعي عبر محفظة الصفقات
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.strategic_deal import CompanyProfile, DealMatch, StrategicDeal
|
||||
from app.services.llm.provider import get_llm
|
||||
|
||||
logger = logging.getLogger("dealix.strategic_deals.portfolio_intelligence")
|
||||
|
||||
# ── Vertical definitions (Saudi market) ─────────────────────────────────────
|
||||
|
||||
VERTICALS = {
|
||||
"technology": "تقنية المعلومات",
|
||||
"construction": "مقاولات وبناء",
|
||||
"real_estate": "عقارات",
|
||||
"retail": "تجارة تجزئة",
|
||||
"wholesale": "تجارة جملة",
|
||||
"healthcare": "رعاية صحية",
|
||||
"education": "تعليم وتدريب",
|
||||
"food_beverage": "أغذية ومشروبات",
|
||||
"logistics": "نقل ولوجستيات",
|
||||
"finance": "خدمات مالية",
|
||||
"energy": "طاقة",
|
||||
"tourism": "سياحة وضيافة",
|
||||
"consulting": "استشارات",
|
||||
"marketing": "تسويق وإعلان",
|
||||
"manufacturing": "صناعة",
|
||||
"telecom": "اتصالات",
|
||||
"media": "إعلام وترفيه",
|
||||
"agriculture": "زراعة",
|
||||
"automotive": "سيارات",
|
||||
"government": "قطاع حكومي",
|
||||
}
|
||||
|
||||
DEAL_TYPE_LABELS = {
|
||||
"partnership": "شراكة",
|
||||
"distribution": "توزيع",
|
||||
"franchise": "امتياز",
|
||||
"jv": "مشروع مشترك",
|
||||
"referral": "إحالة",
|
||||
"acquisition": "استحواذ",
|
||||
"barter": "مقايضة",
|
||||
"reseller": "إعادة بيع",
|
||||
}
|
||||
|
||||
|
||||
# ── Models ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class PortfolioInsight(BaseModel):
|
||||
"""A single intelligence insight derived from portfolio analysis."""
|
||||
insight_type: str # top_vertical, best_deal_type, best_partner_archetype, gap, productization
|
||||
title: str = ""
|
||||
title_ar: str = ""
|
||||
data: dict = Field(default_factory=dict)
|
||||
confidence: float = Field(0.5, ge=0.0, le=1.0)
|
||||
recommendation: str = ""
|
||||
recommendation_ar: str = ""
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"insight_type": "top_vertical",
|
||||
"title": "Technology is the best-performing vertical",
|
||||
"title_ar": "قطاع التقنية هو الأفضل أداءً",
|
||||
"data": {"vertical": "technology", "deal_count": 15, "avg_score": 0.82},
|
||||
"confidence": 0.85,
|
||||
"recommendation_ar": "زيادة التركيز على صفقات قطاع التقنية",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ── Portfolio Intelligence Engine ───────────────────────────────────────────
|
||||
|
||||
|
||||
class PortfolioIntelligence:
|
||||
"""
|
||||
Analyzes the entire deal portfolio to surface actionable insights.
|
||||
Identifies top verticals, best deal structures, gaps, and productization opportunities.
|
||||
يحلل محفظة الصفقات بالكامل لاستخراج رؤى قابلة للتنفيذ
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.llm = get_llm()
|
||||
|
||||
# ── Full Analysis ───────────────────────────────────────────────────────
|
||||
|
||||
async def analyze(
|
||||
self,
|
||||
tenant_id: str,
|
||||
period: str = "quarterly",
|
||||
db: AsyncSession = None,
|
||||
) -> list[PortfolioInsight]:
|
||||
"""
|
||||
Run a complete portfolio analysis and return all insights.
|
||||
تحليل شامل للمحفظة واستخراج جميع الرؤى
|
||||
"""
|
||||
if db is None:
|
||||
raise ValueError("Database session is required")
|
||||
|
||||
insights: list[PortfolioInsight] = []
|
||||
|
||||
# Run all analysis types in sequence
|
||||
verticals = await self.get_top_verticals(tenant_id, db)
|
||||
if verticals:
|
||||
top = verticals[0]
|
||||
insights.append(PortfolioInsight(
|
||||
insight_type="top_vertical",
|
||||
title=f"Top vertical: {top.get('vertical', 'unknown')}",
|
||||
title_ar=f"القطاع الأفضل: {top.get('vertical_ar', 'غير محدد')}",
|
||||
data=top,
|
||||
confidence=min(0.95, top.get("deal_count", 0) / 20),
|
||||
recommendation=f"Increase focus on {top.get('vertical', '')} deals",
|
||||
recommendation_ar=f"زيادة التركيز على صفقات قطاع {top.get('vertical_ar', '')}",
|
||||
))
|
||||
|
||||
deal_types = await self.get_best_deal_types(tenant_id, db)
|
||||
if deal_types:
|
||||
best = deal_types[0]
|
||||
insights.append(PortfolioInsight(
|
||||
insight_type="best_deal_type",
|
||||
title=f"Best deal type: {best.get('deal_type', 'unknown')}",
|
||||
title_ar=f"أفضل نوع صفقة: {best.get('deal_type_ar', 'غير محدد')}",
|
||||
data=best,
|
||||
confidence=min(0.90, best.get("count", 0) / 15),
|
||||
recommendation=f"Prioritize {best.get('deal_type', '')} deals",
|
||||
recommendation_ar=f"إعطاء الأولوية لصفقات {best.get('deal_type_ar', '')}",
|
||||
))
|
||||
|
||||
archetypes = await self.get_best_partner_archetypes(tenant_id, db)
|
||||
if archetypes:
|
||||
best_arch = archetypes[0]
|
||||
insights.append(PortfolioInsight(
|
||||
insight_type="best_partner_archetype",
|
||||
title=f"Best partner type: {best_arch.get('archetype', 'unknown')}",
|
||||
title_ar=f"أفضل نوع شريك: {best_arch.get('archetype_ar', 'غير محدد')}",
|
||||
data=best_arch,
|
||||
confidence=min(0.85, best_arch.get("count", 0) / 10),
|
||||
recommendation_ar=f"البحث عن شركاء من نوع {best_arch.get('archetype_ar', '')}",
|
||||
))
|
||||
|
||||
gaps = await self.get_repeated_gaps(tenant_id, db)
|
||||
for gap in gaps[:3]:
|
||||
insights.append(PortfolioInsight(
|
||||
insight_type="repeated_gap",
|
||||
title=f"Repeated gap: {gap.get('gap', '')}",
|
||||
title_ar=f"فجوة متكررة: {gap.get('gap', '')}",
|
||||
data=gap,
|
||||
confidence=min(0.80, gap.get("frequency", 0) / 5),
|
||||
recommendation_ar=f"سد فجوة: {gap.get('gap', '')} — تكررت {gap.get('frequency', 0)} مرات",
|
||||
))
|
||||
|
||||
products = await self.get_productization_candidates(tenant_id, db)
|
||||
for prod in products[:2]:
|
||||
insights.append(PortfolioInsight(
|
||||
insight_type="productization",
|
||||
title=f"Productization candidate: {prod.get('capability', '')}",
|
||||
title_ar=f"فرصة تحويل لمنتج: {prod.get('capability', '')}",
|
||||
data=prod,
|
||||
confidence=min(0.75, prod.get("demand_count", 0) / 8),
|
||||
recommendation_ar=f"تحويل «{prod.get('capability', '')}» إلى منتج قابل للبيع",
|
||||
))
|
||||
|
||||
# Sort by confidence descending
|
||||
insights.sort(key=lambda i: i.confidence, reverse=True)
|
||||
|
||||
logger.info(
|
||||
"Portfolio analysis for tenant %s (%s): %d insights",
|
||||
tenant_id, period, len(insights),
|
||||
)
|
||||
return insights
|
||||
|
||||
# ── Top Verticals ───────────────────────────────────────────────────────
|
||||
|
||||
async def get_top_verticals(
|
||||
self,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Identify the highest-performing industry verticals by deal volume and score.
|
||||
تحديد القطاعات الصناعية الأفضل أداءً حسب حجم الصفقات والتقييم
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(CompanyProfile).where(CompanyProfile.tenant_id == tenant_id)
|
||||
)
|
||||
profiles = result.scalars().all()
|
||||
|
||||
# Count deals and avg scores per industry
|
||||
industry_stats: dict[str, dict] = defaultdict(
|
||||
lambda: {"deal_count": 0, "total_score": 0.0, "total_revenue": 0.0, "companies": 0}
|
||||
)
|
||||
|
||||
for profile in profiles:
|
||||
industry = profile.industry or "other"
|
||||
industry_stats[industry]["companies"] += 1
|
||||
industry_stats[industry]["total_revenue"] += float(profile.annual_revenue_sar or 0)
|
||||
industry_stats[industry]["total_score"] += float(profile.trust_score or 0)
|
||||
|
||||
# Get match counts per industry
|
||||
matches_result = await db.execute(
|
||||
select(DealMatch).where(DealMatch.tenant_id == tenant_id)
|
||||
)
|
||||
matches = matches_result.scalars().all()
|
||||
|
||||
profile_industry: dict[str, str] = {}
|
||||
for p in profiles:
|
||||
profile_industry[str(p.id)] = p.industry or "other"
|
||||
|
||||
for match in matches:
|
||||
industry_a = profile_industry.get(str(match.company_a_id), "other")
|
||||
industry_stats[industry_a]["deal_count"] += 1
|
||||
|
||||
# Build ranked list
|
||||
verticals: list[dict] = []
|
||||
for industry, stats in industry_stats.items():
|
||||
companies = stats["companies"]
|
||||
avg_score = stats["total_score"] / companies if companies > 0 else 0
|
||||
verticals.append({
|
||||
"vertical": industry,
|
||||
"vertical_ar": VERTICALS.get(industry, industry),
|
||||
"deal_count": stats["deal_count"],
|
||||
"company_count": companies,
|
||||
"avg_trust_score": round(avg_score, 4),
|
||||
"total_revenue_sar": round(stats["total_revenue"], 2),
|
||||
"performance_score": round(
|
||||
stats["deal_count"] * 0.4 + avg_score * 0.3 + min(companies / 10, 1) * 0.3, 4
|
||||
),
|
||||
})
|
||||
|
||||
verticals.sort(key=lambda v: v["performance_score"], reverse=True)
|
||||
|
||||
logger.info("Top verticals for tenant %s: %d industries analyzed", tenant_id, len(verticals))
|
||||
return verticals
|
||||
|
||||
# ── Best Deal Types ─────────────────────────────────────────────────────
|
||||
|
||||
async def get_best_deal_types(
|
||||
self,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Determine which deal types yield the best results.
|
||||
تحديد أنواع الصفقات الأكثر نجاحاً
|
||||
"""
|
||||
matches_result = await db.execute(
|
||||
select(DealMatch).where(DealMatch.tenant_id == tenant_id)
|
||||
)
|
||||
matches = matches_result.scalars().all()
|
||||
|
||||
type_stats: dict[str, dict] = defaultdict(
|
||||
lambda: {"count": 0, "total_score": 0.0, "accepted": 0}
|
||||
)
|
||||
|
||||
for match in matches:
|
||||
deal_type = match.deal_type_suggested or "unknown"
|
||||
type_stats[deal_type]["count"] += 1
|
||||
type_stats[deal_type]["total_score"] += float(match.match_score or 0)
|
||||
if match.status in ("accepted", "signed", "active"):
|
||||
type_stats[deal_type]["accepted"] += 1
|
||||
|
||||
deal_types: list[dict] = []
|
||||
for dt, stats in type_stats.items():
|
||||
count = stats["count"]
|
||||
avg_score = stats["total_score"] / count if count > 0 else 0
|
||||
acceptance_rate = stats["accepted"] / count if count > 0 else 0
|
||||
|
||||
deal_types.append({
|
||||
"deal_type": dt,
|
||||
"deal_type_ar": DEAL_TYPE_LABELS.get(dt, dt),
|
||||
"count": count,
|
||||
"avg_match_score": round(avg_score, 4),
|
||||
"acceptance_rate": round(acceptance_rate, 4),
|
||||
"effectiveness_score": round(
|
||||
avg_score * 0.4 + acceptance_rate * 0.4 + min(count / 20, 1) * 0.2, 4
|
||||
),
|
||||
})
|
||||
|
||||
deal_types.sort(key=lambda d: d["effectiveness_score"], reverse=True)
|
||||
|
||||
logger.info("Best deal types for tenant %s: %d types analyzed", tenant_id, len(deal_types))
|
||||
return deal_types
|
||||
|
||||
# ── Best Partner Archetypes ─────────────────────────────────────────────
|
||||
|
||||
async def get_best_partner_archetypes(
|
||||
self,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Identify the most successful partner archetypes (size, industry, type).
|
||||
تحديد أنماط الشركاء الأكثر نجاحاً (الحجم، القطاع، النوع)
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(CompanyProfile).where(CompanyProfile.tenant_id == tenant_id)
|
||||
)
|
||||
profiles = result.scalars().all()
|
||||
|
||||
matches_result = await db.execute(
|
||||
select(DealMatch).where(DealMatch.tenant_id == tenant_id)
|
||||
)
|
||||
matches = matches_result.scalars().all()
|
||||
|
||||
# Build profile lookup
|
||||
profile_map: dict[str, CompanyProfile] = {}
|
||||
for p in profiles:
|
||||
profile_map[str(p.id)] = p
|
||||
|
||||
# Analyze successful matches to derive archetypes
|
||||
archetype_stats: dict[str, dict] = defaultdict(
|
||||
lambda: {"count": 0, "total_score": 0.0, "examples": []}
|
||||
)
|
||||
|
||||
for match in matches:
|
||||
partner_id = str(match.company_b_id) if match.company_b_id else None
|
||||
if not partner_id or partner_id not in profile_map:
|
||||
continue
|
||||
|
||||
partner = profile_map[partner_id]
|
||||
emp_count = int(partner.employee_count or 0)
|
||||
|
||||
if emp_count > 500:
|
||||
size_bucket = "enterprise"
|
||||
size_ar = "مؤسسة كبيرة"
|
||||
elif emp_count > 50:
|
||||
size_bucket = "mid_market"
|
||||
size_ar = "سوق متوسط"
|
||||
elif emp_count > 10:
|
||||
size_bucket = "smb"
|
||||
size_ar = "أعمال صغيرة ومتوسطة"
|
||||
else:
|
||||
size_bucket = "startup"
|
||||
size_ar = "شركة ناشئة"
|
||||
|
||||
archetype_key = f"{partner.industry or 'unknown'}_{size_bucket}"
|
||||
archetype_stats[archetype_key]["count"] += 1
|
||||
archetype_stats[archetype_key]["total_score"] += float(match.match_score or 0)
|
||||
archetype_stats[archetype_key]["industry"] = partner.industry or "unknown"
|
||||
archetype_stats[archetype_key]["size"] = size_bucket
|
||||
archetype_stats[archetype_key]["size_ar"] = size_ar
|
||||
archetype_stats[archetype_key]["industry_ar"] = VERTICALS.get(partner.industry or "", partner.industry or "")
|
||||
if len(archetype_stats[archetype_key]["examples"]) < 3:
|
||||
archetype_stats[archetype_key]["examples"].append(partner.company_name)
|
||||
|
||||
archetypes: list[dict] = []
|
||||
for key, stats in archetype_stats.items():
|
||||
count = stats["count"]
|
||||
avg_score = stats["total_score"] / count if count > 0 else 0
|
||||
archetype_label = f"{stats.get('industry_ar', '')} - {stats.get('size_ar', '')}"
|
||||
|
||||
archetypes.append({
|
||||
"archetype": key,
|
||||
"archetype_ar": archetype_label,
|
||||
"industry": stats.get("industry", ""),
|
||||
"size": stats.get("size", ""),
|
||||
"count": count,
|
||||
"avg_match_score": round(avg_score, 4),
|
||||
"examples": stats.get("examples", []),
|
||||
"score": round(avg_score * 0.6 + min(count / 10, 1) * 0.4, 4),
|
||||
})
|
||||
|
||||
archetypes.sort(key=lambda a: a["score"], reverse=True)
|
||||
|
||||
logger.info("Partner archetypes for tenant %s: %d archetypes", tenant_id, len(archetypes))
|
||||
return archetypes
|
||||
|
||||
# ── Repeated Gaps ───────────────────────────────────────────────────────
|
||||
|
||||
async def get_repeated_gaps(
|
||||
self,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Find needs that repeatedly appear but are never fulfilled in the portfolio.
|
||||
اكتشاف الاحتياجات التي تتكرر ولا يتم تلبيتها في المحفظة
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(CompanyProfile).where(CompanyProfile.tenant_id == tenant_id)
|
||||
)
|
||||
profiles = result.scalars().all()
|
||||
|
||||
all_needs: dict[str, int] = defaultdict(int)
|
||||
all_caps: set[str] = set()
|
||||
|
||||
for profile in profiles:
|
||||
for need in (profile.needs or []):
|
||||
all_needs[need.lower().strip()] += 1
|
||||
for cap in (profile.capabilities or []):
|
||||
all_caps.add(cap.lower().strip())
|
||||
|
||||
# Gaps: needs that appear multiple times but nobody offers
|
||||
gaps: list[dict] = []
|
||||
for need, frequency in sorted(all_needs.items(), key=lambda x: x[1], reverse=True):
|
||||
if need not in all_caps and frequency >= 2:
|
||||
gaps.append({
|
||||
"gap": need,
|
||||
"frequency": frequency,
|
||||
"severity": "high" if frequency >= 5 else ("medium" if frequency >= 3 else "low"),
|
||||
"severity_ar": "عالية" if frequency >= 5 else ("متوسطة" if frequency >= 3 else "منخفضة"),
|
||||
"recommendation_ar": f"البحث عن شريك يقدم «{need}» — مطلوب من {frequency} شركة",
|
||||
})
|
||||
|
||||
logger.info("Repeated gaps for tenant %s: %d gaps found", tenant_id, len(gaps))
|
||||
return gaps
|
||||
|
||||
# ── Productization Candidates ───────────────────────────────────────────
|
||||
|
||||
async def get_productization_candidates(
|
||||
self,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Identify capabilities with high demand that could become standalone products.
|
||||
تحديد القدرات ذات الطلب العالي التي يمكن تحويلها لمنتجات مستقلة
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(CompanyProfile).where(CompanyProfile.tenant_id == tenant_id)
|
||||
)
|
||||
profiles = result.scalars().all()
|
||||
|
||||
# Count how many companies need each capability vs how many offer it
|
||||
cap_supply: dict[str, int] = defaultdict(int)
|
||||
cap_demand: dict[str, int] = defaultdict(int)
|
||||
|
||||
for profile in profiles:
|
||||
for cap in (profile.capabilities or []):
|
||||
cap_supply[cap.lower().strip()] += 1
|
||||
for need in (profile.needs or []):
|
||||
cap_demand[need.lower().strip()] += 1
|
||||
|
||||
candidates: list[dict] = []
|
||||
for capability, demand_count in cap_demand.items():
|
||||
supply_count = cap_supply.get(capability, 0)
|
||||
if demand_count >= 3 and supply_count <= 1:
|
||||
demand_supply_ratio = demand_count / max(supply_count, 1)
|
||||
candidates.append({
|
||||
"capability": capability,
|
||||
"demand_count": demand_count,
|
||||
"supply_count": supply_count,
|
||||
"demand_supply_ratio": round(demand_supply_ratio, 2),
|
||||
"market_potential": "عالي" if demand_supply_ratio > 5 else ("متوسط" if demand_supply_ratio > 2 else "منخفض"),
|
||||
"recommendation_ar": (
|
||||
f"فرصة لتحويل «{capability}» إلى منتج — "
|
||||
f"مطلوب من {demand_count} شركة ومتوفر عند {supply_count} فقط"
|
||||
),
|
||||
})
|
||||
|
||||
candidates.sort(key=lambda c: c["demand_supply_ratio"], reverse=True)
|
||||
|
||||
logger.info(
|
||||
"Productization candidates for tenant %s: %d candidates",
|
||||
tenant_id, len(candidates),
|
||||
)
|
||||
return candidates
|
||||
|
||||
# ── Quarterly Report ────────────────────────────────────────────────────
|
||||
|
||||
async def generate_quarterly_report(
|
||||
self,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a comprehensive Arabic quarterly portfolio intelligence report.
|
||||
إنشاء تقرير ذكاء محفظة ربع سنوي شامل بالعربي
|
||||
"""
|
||||
insights = await self.analyze(tenant_id, period="quarterly", db=db)
|
||||
verticals = await self.get_top_verticals(tenant_id, db)
|
||||
deal_types = await self.get_best_deal_types(tenant_id, db)
|
||||
gaps = await self.get_repeated_gaps(tenant_id, db)
|
||||
products = await self.get_productization_candidates(tenant_id, db)
|
||||
|
||||
# Build context for LLM
|
||||
context_parts = [
|
||||
f"عدد الرؤى المستخرجة: {len(insights)}",
|
||||
f"القطاعات الأفضل أداءً: {json.dumps(verticals[:5], ensure_ascii=False)}",
|
||||
f"أنواع الصفقات الأنجح: {json.dumps(deal_types[:5], ensure_ascii=False)}",
|
||||
f"الفجوات المتكررة: {json.dumps(gaps[:5], ensure_ascii=False)}",
|
||||
f"فرص التحويل لمنتجات: {json.dumps(products[:5], ensure_ascii=False)}",
|
||||
]
|
||||
|
||||
top_insights = []
|
||||
for ins in insights[:5]:
|
||||
top_insights.append(f"- {ins.title_ar} (ثقة: {ins.confidence:.0%}): {ins.recommendation_ar}")
|
||||
|
||||
context_parts.append(f"أبرز الرؤى:\n" + "\n".join(top_insights))
|
||||
|
||||
context = "\n\n".join(context_parts)
|
||||
|
||||
system_prompt = """أنت محلل استراتيجي سعودي خبير. اكتب تقرير ذكاء محفظة ربع سنوي شامل بالعربي.
|
||||
|
||||
يجب أن يشمل التقرير:
|
||||
١. ملخص تنفيذي
|
||||
٢. أداء القطاعات — أي القطاعات تحقق أفضل النتائج
|
||||
٣. تحليل أنواع الصفقات — أي الهياكل أنجح
|
||||
٤. الفجوات الاستراتيجية — ما ينقص المنظومة
|
||||
٥. فرص التحويل لمنتجات — خدمات يمكن تعبئتها كمنتجات
|
||||
٦. التوصيات الاستراتيجية — ٣-٥ توصيات محددة
|
||||
٧. خطة العمل للربع القادم
|
||||
|
||||
اكتب بأسلوب تنفيذي رسمي مناسب لمجلس الإدارة. استخدم الأرقام والنسب."""
|
||||
|
||||
try:
|
||||
llm_response = await self.llm.complete(
|
||||
system_prompt=system_prompt,
|
||||
user_message=context,
|
||||
temperature=0.3,
|
||||
)
|
||||
report = llm_response.content.strip()
|
||||
except Exception as exc:
|
||||
logger.error("Quarterly report generation failed: %s", exc)
|
||||
# Build a structured fallback report
|
||||
report_parts = [
|
||||
"تقرير ذكاء المحفظة — الربع الحالي",
|
||||
"=" * 40,
|
||||
"",
|
||||
"ملخص تنفيذي:",
|
||||
f"تم تحليل المحفظة واستخراج {len(insights)} رؤية استراتيجية.",
|
||||
"",
|
||||
]
|
||||
|
||||
if verticals:
|
||||
report_parts.append("القطاعات الأفضل أداءً:")
|
||||
for v in verticals[:3]:
|
||||
report_parts.append(
|
||||
f" - {v.get('vertical_ar', '')}: "
|
||||
f"{v.get('deal_count', 0)} صفقة، "
|
||||
f"تقييم {v.get('avg_trust_score', 0):.2f}"
|
||||
)
|
||||
report_parts.append("")
|
||||
|
||||
if deal_types:
|
||||
report_parts.append("أنواع الصفقات الأنجح:")
|
||||
for dt in deal_types[:3]:
|
||||
report_parts.append(
|
||||
f" - {dt.get('deal_type_ar', '')}: "
|
||||
f"{dt.get('count', 0)} صفقة، "
|
||||
f"فعالية {dt.get('effectiveness_score', 0):.2f}"
|
||||
)
|
||||
report_parts.append("")
|
||||
|
||||
if gaps:
|
||||
report_parts.append("الفجوات المتكررة:")
|
||||
for g in gaps[:3]:
|
||||
report_parts.append(f" - {g.get('gap', '')}: تكررت {g.get('frequency', 0)} مرات")
|
||||
report_parts.append("")
|
||||
|
||||
if products:
|
||||
report_parts.append("فرص التحويل لمنتجات:")
|
||||
for p in products[:3]:
|
||||
report_parts.append(
|
||||
f" - {p.get('capability', '')}: "
|
||||
f"الطلب {p.get('demand_count', 0)} / العرض {p.get('supply_count', 0)}"
|
||||
)
|
||||
|
||||
report = "\n".join(report_parts)
|
||||
|
||||
logger.info("Generated quarterly report for tenant %s", tenant_id)
|
||||
return report
|
||||
@ -0,0 +1,484 @@
|
||||
"""
|
||||
ROI Engine — Return on Investment calculator for strategic B2B initiatives.
|
||||
محرك العائد على الاستثمار: حاسبة العائد على الاستثمار للمبادرات الاستراتيجية
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.strategic_deal import CompanyProfile
|
||||
from app.services.llm.provider import get_llm
|
||||
|
||||
logger = logging.getLogger("dealix.strategic_deals.roi_engine")
|
||||
|
||||
# ── Initiative type benchmarks (Saudi market) ───────────────────────────────
|
||||
|
||||
INITIATIVE_BENCHMARKS = {
|
||||
"partnership": {
|
||||
"avg_roi_pct": 0.45,
|
||||
"avg_payback_months": 8,
|
||||
"cac_reduction_range": (0.10, 0.30),
|
||||
"margin_impact_range": (0.02, 0.08),
|
||||
},
|
||||
"acquisition": {
|
||||
"avg_roi_pct": 0.25,
|
||||
"avg_payback_months": 24,
|
||||
"cac_reduction_range": (0.15, 0.40),
|
||||
"margin_impact_range": (0.05, 0.15),
|
||||
},
|
||||
"channel_expansion": {
|
||||
"avg_roi_pct": 0.60,
|
||||
"avg_payback_months": 6,
|
||||
"cac_reduction_range": (0.05, 0.20),
|
||||
"margin_impact_range": (0.01, 0.05),
|
||||
},
|
||||
"market_entry": {
|
||||
"avg_roi_pct": 0.30,
|
||||
"avg_payback_months": 18,
|
||||
"cac_reduction_range": (0.00, 0.10),
|
||||
"margin_impact_range": (0.03, 0.10),
|
||||
},
|
||||
"digital_transformation": {
|
||||
"avg_roi_pct": 0.55,
|
||||
"avg_payback_months": 12,
|
||||
"cac_reduction_range": (0.20, 0.50),
|
||||
"margin_impact_range": (0.05, 0.12),
|
||||
},
|
||||
"product_launch": {
|
||||
"avg_roi_pct": 0.40,
|
||||
"avg_payback_months": 10,
|
||||
"cac_reduction_range": (0.00, 0.15),
|
||||
"margin_impact_range": (0.05, 0.20),
|
||||
},
|
||||
"referral_program": {
|
||||
"avg_roi_pct": 0.80,
|
||||
"avg_payback_months": 3,
|
||||
"cac_reduction_range": (0.30, 0.60),
|
||||
"margin_impact_range": (0.01, 0.03),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Models ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ROICalculation(BaseModel):
|
||||
"""Complete ROI analysis for a strategic initiative."""
|
||||
initiative_type: str
|
||||
investment_sar: float = 0.0
|
||||
projected_return_sar: float = 0.0
|
||||
roi_percentage: float = 0.0
|
||||
payback_months: int = 0
|
||||
cac_reduction: float = Field(0.0, ge=0.0, le=1.0)
|
||||
distribution_value: float = 0.0
|
||||
margin_impact: float = 0.0
|
||||
risk_adjusted_roi: float = 0.0
|
||||
confidence: float = Field(0.5, ge=0.0, le=1.0)
|
||||
breakdown: dict = Field(default_factory=dict)
|
||||
summary_ar: str = ""
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"initiative_type": "partnership",
|
||||
"investment_sar": 100_000,
|
||||
"projected_return_sar": 250_000,
|
||||
"roi_percentage": 150.0,
|
||||
"payback_months": 6,
|
||||
"cac_reduction": 0.20,
|
||||
"risk_adjusted_roi": 97.5,
|
||||
"confidence": 0.75,
|
||||
"summary_ar": "شراكة مع عائد متوقع ٢٥٠ ألف ريال واسترداد خلال ٦ أشهر",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ── ROI Engine ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ROIEngine:
|
||||
"""
|
||||
Calculates, compares, and projects ROI for strategic B2B initiatives.
|
||||
يحسب ويقارن ويتوقع العائد على الاستثمار للمبادرات الاستراتيجية
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.llm = get_llm()
|
||||
self._active_calculations: dict[str, list[ROICalculation]] = {}
|
||||
|
||||
# ── Calculate ROI ───────────────────────────────────────────────────────
|
||||
|
||||
async def calculate(
|
||||
self,
|
||||
initiative_type: str,
|
||||
params: dict,
|
||||
db: AsyncSession,
|
||||
) -> ROICalculation:
|
||||
"""
|
||||
Calculate detailed ROI for a strategic initiative.
|
||||
حساب العائد على الاستثمار التفصيلي لمبادرة استراتيجية
|
||||
"""
|
||||
benchmark = INITIATIVE_BENCHMARKS.get(initiative_type, {})
|
||||
|
||||
investment = float(params.get("investment_sar", 0))
|
||||
if investment <= 0:
|
||||
raise ValueError("investment_sar must be positive")
|
||||
|
||||
projected_return = float(params.get("projected_return_sar", 0))
|
||||
monthly_return = float(params.get("monthly_return_sar", 0))
|
||||
duration_months = int(params.get("duration_months", 12))
|
||||
risk_factor = min(1.0, max(0.0, float(params.get("risk_factor", 0.3))))
|
||||
discount_rate = float(params.get("annual_discount_rate", 0.08))
|
||||
|
||||
# If projected_return not given, estimate from monthly
|
||||
if projected_return <= 0 and monthly_return > 0:
|
||||
projected_return = monthly_return * duration_months
|
||||
|
||||
# If still zero, use benchmark
|
||||
if projected_return <= 0:
|
||||
avg_roi = benchmark.get("avg_roi_pct", 0.30)
|
||||
projected_return = investment * (1 + avg_roi)
|
||||
|
||||
# Core ROI
|
||||
roi_percentage = ((projected_return - investment) / investment) * 100 if investment > 0 else 0.0
|
||||
|
||||
# Payback period
|
||||
if monthly_return > 0:
|
||||
payback_months = max(1, math.ceil(investment / monthly_return))
|
||||
elif projected_return > investment and duration_months > 0:
|
||||
monthly_est = (projected_return - investment) / duration_months
|
||||
payback_months = max(1, math.ceil(investment / monthly_est)) if monthly_est > 0 else duration_months
|
||||
else:
|
||||
payback_months = benchmark.get("avg_payback_months", 12)
|
||||
|
||||
# CAC reduction estimate
|
||||
cac_range = benchmark.get("cac_reduction_range", (0.0, 0.15))
|
||||
cac_reduction = float(params.get("cac_reduction", (cac_range[0] + cac_range[1]) / 2))
|
||||
cac_reduction = min(1.0, max(0.0, cac_reduction))
|
||||
|
||||
# Margin impact
|
||||
margin_range = benchmark.get("margin_impact_range", (0.01, 0.05))
|
||||
margin_impact = float(params.get("margin_impact", (margin_range[0] + margin_range[1]) / 2))
|
||||
|
||||
# Distribution / channel value
|
||||
distribution_value = float(params.get("distribution_value_sar", 0))
|
||||
if distribution_value <= 0 and initiative_type in ("channel_expansion", "partnership", "referral_program"):
|
||||
distribution_value = projected_return * 0.2
|
||||
|
||||
# Risk-adjusted ROI — discount by risk factor
|
||||
risk_adjusted_roi = roi_percentage * (1 - risk_factor)
|
||||
|
||||
# NPV-based confidence: higher NPV relative to investment = higher confidence
|
||||
monthly_discount = discount_rate / 12
|
||||
npv = 0.0
|
||||
if monthly_return > 0:
|
||||
for month in range(1, duration_months + 1):
|
||||
npv += monthly_return / ((1 + monthly_discount) ** month)
|
||||
else:
|
||||
monthly_est = projected_return / max(duration_months, 1)
|
||||
for month in range(1, duration_months + 1):
|
||||
npv += monthly_est / ((1 + monthly_discount) ** month)
|
||||
|
||||
npv -= investment
|
||||
npv_ratio = npv / investment if investment > 0 else 0
|
||||
confidence = min(0.95, max(0.1, 0.5 + npv_ratio * 0.3))
|
||||
|
||||
# Detailed breakdown
|
||||
breakdown = {
|
||||
"gross_return_sar": round(projected_return, 2),
|
||||
"net_return_sar": round(projected_return - investment, 2),
|
||||
"npv_sar": round(npv, 2),
|
||||
"monthly_return_sar": round(monthly_return or projected_return / max(duration_months, 1), 2),
|
||||
"duration_months": duration_months,
|
||||
"risk_factor": risk_factor,
|
||||
"discount_rate": discount_rate,
|
||||
"cac_savings_sar": round(investment * cac_reduction, 2),
|
||||
"distribution_value_sar": round(distribution_value, 2),
|
||||
"benchmark_avg_roi_pct": benchmark.get("avg_roi_pct", 0) * 100,
|
||||
"vs_benchmark": "أعلى من المتوسط" if roi_percentage > benchmark.get("avg_roi_pct", 0) * 100 else "أقل من المتوسط",
|
||||
}
|
||||
|
||||
# Generate Arabic summary
|
||||
summary_ar = await self._generate_summary(
|
||||
initiative_type, investment, projected_return,
|
||||
roi_percentage, payback_months, risk_adjusted_roi, confidence,
|
||||
)
|
||||
|
||||
calc = ROICalculation(
|
||||
initiative_type=initiative_type,
|
||||
investment_sar=round(investment, 2),
|
||||
projected_return_sar=round(projected_return, 2),
|
||||
roi_percentage=round(roi_percentage, 2),
|
||||
payback_months=payback_months,
|
||||
cac_reduction=round(cac_reduction, 4),
|
||||
distribution_value=round(distribution_value, 2),
|
||||
margin_impact=round(margin_impact, 4),
|
||||
risk_adjusted_roi=round(risk_adjusted_roi, 2),
|
||||
confidence=round(confidence, 4),
|
||||
breakdown=breakdown,
|
||||
summary_ar=summary_ar,
|
||||
)
|
||||
|
||||
# Store for tenant dashboard
|
||||
tenant_id = params.get("tenant_id", "default")
|
||||
self._active_calculations.setdefault(tenant_id, []).append(calc)
|
||||
|
||||
logger.info(
|
||||
"ROI calculated: type=%s investment=%.0f return=%.0f roi=%.1f%% payback=%dm",
|
||||
initiative_type, investment, projected_return, roi_percentage, payback_months,
|
||||
)
|
||||
return calc
|
||||
|
||||
# ── Compare Initiatives ─────────────────────────────────────────────────
|
||||
|
||||
async def compare_initiatives(
|
||||
self,
|
||||
calculations: list[ROICalculation],
|
||||
) -> dict:
|
||||
"""
|
||||
Rank and compare multiple initiatives by risk-adjusted ROI.
|
||||
ترتيب ومقارنة عدة مبادرات حسب العائد المعدل بالمخاطر
|
||||
"""
|
||||
if not calculations:
|
||||
return {"ranked": [], "summary_ar": "لا توجد مبادرات للمقارنة"}
|
||||
|
||||
ranked = []
|
||||
for calc in calculations:
|
||||
ranked.append({
|
||||
"initiative_type": calc.initiative_type,
|
||||
"investment_sar": calc.investment_sar,
|
||||
"projected_return_sar": calc.projected_return_sar,
|
||||
"roi_percentage": calc.roi_percentage,
|
||||
"risk_adjusted_roi": calc.risk_adjusted_roi,
|
||||
"payback_months": calc.payback_months,
|
||||
"confidence": calc.confidence,
|
||||
"cac_reduction": calc.cac_reduction,
|
||||
"margin_impact": calc.margin_impact,
|
||||
"npv_sar": calc.breakdown.get("npv_sar", 0),
|
||||
})
|
||||
|
||||
# Sort by risk-adjusted ROI descending
|
||||
ranked.sort(key=lambda x: x["risk_adjusted_roi"], reverse=True)
|
||||
|
||||
for i, item in enumerate(ranked):
|
||||
item["rank"] = i + 1
|
||||
|
||||
best = ranked[0]
|
||||
summary_ar = (
|
||||
f"تم مقارنة {len(ranked)} مبادرة. "
|
||||
f"الأفضل: {best['initiative_type']} بعائد معدل {best['risk_adjusted_roi']:.1f}% "
|
||||
f"واسترداد خلال {best['payback_months']} شهر "
|
||||
f"بدرجة ثقة {best['confidence']:.0%}."
|
||||
)
|
||||
|
||||
total_investment = sum(c.investment_sar for c in calculations)
|
||||
total_return = sum(c.projected_return_sar for c in calculations)
|
||||
portfolio_roi = ((total_return - total_investment) / total_investment * 100) if total_investment > 0 else 0
|
||||
|
||||
logger.info(
|
||||
"Compared %d initiatives. Best: %s (adj ROI=%.1f%%)",
|
||||
len(ranked), best["initiative_type"], best["risk_adjusted_roi"],
|
||||
)
|
||||
|
||||
return {
|
||||
"ranked": ranked,
|
||||
"portfolio_investment_sar": round(total_investment, 2),
|
||||
"portfolio_return_sar": round(total_return, 2),
|
||||
"portfolio_roi_pct": round(portfolio_roi, 2),
|
||||
"summary_ar": summary_ar,
|
||||
}
|
||||
|
||||
# ── Annual Projection ───────────────────────────────────────────────────
|
||||
|
||||
async def project_annual(
|
||||
self,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""
|
||||
Project annual returns across all active initiatives for a tenant.
|
||||
إسقاط العوائد السنوية لجميع المبادرات النشطة للمستأجر
|
||||
"""
|
||||
calculations = self._active_calculations.get(tenant_id, [])
|
||||
|
||||
if not calculations:
|
||||
return {
|
||||
"tenant_id": tenant_id,
|
||||
"total_investment_sar": 0,
|
||||
"projected_annual_return_sar": 0,
|
||||
"weighted_roi_pct": 0,
|
||||
"avg_payback_months": 0,
|
||||
"monthly_projections": [],
|
||||
"summary_ar": "لا توجد مبادرات نشطة لهذا المستأجر",
|
||||
}
|
||||
|
||||
total_investment = sum(c.investment_sar for c in calculations)
|
||||
total_return = sum(c.projected_return_sar for c in calculations)
|
||||
weighted_roi = ((total_return - total_investment) / total_investment * 100) if total_investment > 0 else 0
|
||||
avg_payback = sum(c.payback_months for c in calculations) / len(calculations)
|
||||
total_cac_savings = sum(c.investment_sar * c.cac_reduction for c in calculations)
|
||||
|
||||
# Monthly projection across all initiatives
|
||||
monthly_projections = []
|
||||
for month in range(1, 13):
|
||||
month_return = 0.0
|
||||
for calc in calculations:
|
||||
if month >= calc.payback_months:
|
||||
monthly_est = calc.breakdown.get("monthly_return_sar", 0)
|
||||
month_return += monthly_est
|
||||
else:
|
||||
ramp_ratio = month / max(calc.payback_months, 1)
|
||||
monthly_est = calc.breakdown.get("monthly_return_sar", 0) * ramp_ratio
|
||||
month_return += monthly_est
|
||||
|
||||
monthly_projections.append({
|
||||
"month": month,
|
||||
"projected_return_sar": round(month_return, 2),
|
||||
"cumulative_sar": round(
|
||||
sum(p["projected_return_sar"] for p in monthly_projections) + month_return, 2
|
||||
),
|
||||
})
|
||||
|
||||
summary_ar = (
|
||||
f"إجمالي الاستثمار: {total_investment:,.0f} ريال | "
|
||||
f"العائد السنوي المتوقع: {total_return:,.0f} ريال | "
|
||||
f"العائد على الاستثمار: {weighted_roi:.1f}% | "
|
||||
f"متوسط فترة الاسترداد: {avg_payback:.0f} شهر | "
|
||||
f"وفورات تكلفة الاستحواذ: {total_cac_savings:,.0f} ريال"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Annual projection for tenant %s: investment=%.0f return=%.0f roi=%.1f%%",
|
||||
tenant_id, total_investment, total_return, weighted_roi,
|
||||
)
|
||||
|
||||
return {
|
||||
"tenant_id": tenant_id,
|
||||
"total_investment_sar": round(total_investment, 2),
|
||||
"projected_annual_return_sar": round(total_return, 2),
|
||||
"weighted_roi_pct": round(weighted_roi, 2),
|
||||
"avg_payback_months": round(avg_payback, 1),
|
||||
"total_cac_savings_sar": round(total_cac_savings, 2),
|
||||
"initiative_count": len(calculations),
|
||||
"monthly_projections": monthly_projections,
|
||||
"summary_ar": summary_ar,
|
||||
}
|
||||
|
||||
# ── ROI Dashboard ───────────────────────────────────────────────────────
|
||||
|
||||
async def get_roi_dashboard(
|
||||
self,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""
|
||||
Get a comprehensive ROI dashboard for all active initiatives.
|
||||
الحصول على لوحة معلومات شاملة للعائد على الاستثمار لجميع المبادرات النشطة
|
||||
"""
|
||||
calculations = self._active_calculations.get(tenant_id, [])
|
||||
projection = await self.project_annual(tenant_id, db)
|
||||
|
||||
# Group by initiative type
|
||||
by_type: dict[str, list[ROICalculation]] = {}
|
||||
for calc in calculations:
|
||||
by_type.setdefault(calc.initiative_type, []).append(calc)
|
||||
|
||||
type_summaries = []
|
||||
for init_type, calcs in by_type.items():
|
||||
total_inv = sum(c.investment_sar for c in calcs)
|
||||
total_ret = sum(c.projected_return_sar for c in calcs)
|
||||
avg_roi = sum(c.roi_percentage for c in calcs) / len(calcs)
|
||||
avg_conf = sum(c.confidence for c in calcs) / len(calcs)
|
||||
|
||||
type_summaries.append({
|
||||
"initiative_type": init_type,
|
||||
"count": len(calcs),
|
||||
"total_investment_sar": round(total_inv, 2),
|
||||
"total_return_sar": round(total_ret, 2),
|
||||
"avg_roi_pct": round(avg_roi, 2),
|
||||
"avg_confidence": round(avg_conf, 4),
|
||||
})
|
||||
|
||||
type_summaries.sort(key=lambda x: x["avg_roi_pct"], reverse=True)
|
||||
|
||||
# Top performers
|
||||
top_performers = sorted(calculations, key=lambda c: c.risk_adjusted_roi, reverse=True)[:5]
|
||||
top_list = [
|
||||
{
|
||||
"initiative_type": c.initiative_type,
|
||||
"investment_sar": c.investment_sar,
|
||||
"roi_pct": c.roi_percentage,
|
||||
"risk_adjusted_roi": c.risk_adjusted_roi,
|
||||
"payback_months": c.payback_months,
|
||||
}
|
||||
for c in top_performers
|
||||
]
|
||||
|
||||
dashboard = {
|
||||
"tenant_id": tenant_id,
|
||||
"initiative_count": len(calculations),
|
||||
"projection": projection,
|
||||
"by_type": type_summaries,
|
||||
"top_performers": top_list,
|
||||
"health": {
|
||||
"avg_roi_pct": round(
|
||||
sum(c.roi_percentage for c in calculations) / max(len(calculations), 1), 2
|
||||
),
|
||||
"avg_confidence": round(
|
||||
sum(c.confidence for c in calculations) / max(len(calculations), 1), 4
|
||||
),
|
||||
"total_at_risk_sar": round(
|
||||
sum(c.investment_sar * (1 - c.confidence) for c in calculations), 2
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
logger.info("ROI dashboard for tenant %s: %d initiatives", tenant_id, len(calculations))
|
||||
return dashboard
|
||||
|
||||
# ── Private Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
async def _generate_summary(
|
||||
self,
|
||||
initiative_type: str,
|
||||
investment: float,
|
||||
projected_return: float,
|
||||
roi_pct: float,
|
||||
payback_months: int,
|
||||
risk_adjusted_roi: float,
|
||||
confidence: float,
|
||||
) -> str:
|
||||
"""Generate an Arabic summary for an ROI calculation."""
|
||||
context = f"""نوع المبادرة: {initiative_type}
|
||||
الاستثمار: {investment:,.0f} ريال
|
||||
العائد المتوقع: {projected_return:,.0f} ريال
|
||||
العائد على الاستثمار: {roi_pct:.1f}%
|
||||
فترة الاسترداد: {payback_months} شهر
|
||||
العائد المعدل بالمخاطر: {risk_adjusted_roi:.1f}%
|
||||
درجة الثقة: {confidence:.0%}"""
|
||||
|
||||
system_prompt = """أنت محلل مالي سعودي. اكتب ملخصاً موجزاً بالعربي (٢-٣ جمل) يشرح العائد على الاستثمار لهذه المبادرة.
|
||||
اذكر إذا كان العائد جيداً أو ضعيفاً مقارنة بالسوق وأعطِ توصية مختصرة.
|
||||
اكتب الملخص مباشرة بدون JSON."""
|
||||
|
||||
try:
|
||||
llm_response = await self.llm.complete(
|
||||
system_prompt=system_prompt,
|
||||
user_message=context,
|
||||
temperature=0.3,
|
||||
)
|
||||
return llm_response.content.strip()
|
||||
except Exception as exc:
|
||||
logger.warning("LLM summary generation failed: %s", exc)
|
||||
verdict = "عائد جيد" if roi_pct > 30 else ("عائد متوسط" if roi_pct > 10 else "عائد ضعيف")
|
||||
return (
|
||||
f"مبادرة {initiative_type}: استثمار {investment:,.0f} ريال "
|
||||
f"بعائد متوقع {projected_return:,.0f} ريال ({roi_pct:.1f}%). "
|
||||
f"فترة الاسترداد {payback_months} شهر. التقييم: {verdict}."
|
||||
)
|
||||
@ -0,0 +1,596 @@
|
||||
"""
|
||||
Strategic Simulator — Monte Carlo-style scenario modeling for B2B deals.
|
||||
المحاكي الاستراتيجي: نمذجة سيناريوهات بأسلوب مونت كارلو للصفقات بين الشركات
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.strategic_deal import CompanyProfile
|
||||
from app.services.llm.provider import get_llm
|
||||
|
||||
logger = logging.getLogger("dealix.strategic_deals.strategic_simulator")
|
||||
|
||||
# ── Scenario type definitions ───────────────────────────────────────────────
|
||||
|
||||
SCENARIO_TYPES = {
|
||||
"partnership": "شراكة استراتيجية",
|
||||
"acquisition": "استحواذ",
|
||||
"channel_expansion": "توسع قنوات التوزيع",
|
||||
"market_entry": "دخول سوق جديد",
|
||||
"joint_venture": "مشروع مشترك",
|
||||
"franchise": "امتياز تجاري",
|
||||
"divestiture": "تصفية أصول",
|
||||
}
|
||||
|
||||
# ── Default assumptions by scenario type ────────────────────────────────────
|
||||
|
||||
DEFAULT_ASSUMPTIONS = {
|
||||
"partnership": {
|
||||
"revenue_share_pct": 0.15,
|
||||
"setup_cost_sar": 50_000,
|
||||
"ramp_months": 3,
|
||||
"success_probability": 0.65,
|
||||
"annual_growth_pct": 0.10,
|
||||
},
|
||||
"acquisition": {
|
||||
"premium_pct": 0.25,
|
||||
"integration_cost_pct": 0.15,
|
||||
"synergy_savings_pct": 0.10,
|
||||
"ramp_months": 12,
|
||||
"success_probability": 0.50,
|
||||
"annual_growth_pct": 0.15,
|
||||
},
|
||||
"channel_expansion": {
|
||||
"channel_setup_sar": 100_000,
|
||||
"per_channel_cost_sar": 25_000,
|
||||
"channels_count": 3,
|
||||
"revenue_per_channel_sar": 200_000,
|
||||
"ramp_months": 6,
|
||||
"success_probability": 0.70,
|
||||
},
|
||||
"market_entry": {
|
||||
"entry_cost_sar": 500_000,
|
||||
"first_year_revenue_sar": 300_000,
|
||||
"market_share_target": 0.05,
|
||||
"ramp_months": 12,
|
||||
"success_probability": 0.45,
|
||||
"annual_growth_pct": 0.20,
|
||||
},
|
||||
"joint_venture": {
|
||||
"equity_split": 0.50,
|
||||
"total_investment_sar": 1_000_000,
|
||||
"projected_revenue_sar": 2_000_000,
|
||||
"ramp_months": 9,
|
||||
"success_probability": 0.55,
|
||||
"annual_growth_pct": 0.12,
|
||||
},
|
||||
"franchise": {
|
||||
"franchise_fee_sar": 200_000,
|
||||
"royalty_pct": 0.06,
|
||||
"unit_revenue_sar": 500_000,
|
||||
"units_count": 2,
|
||||
"ramp_months": 6,
|
||||
"success_probability": 0.60,
|
||||
},
|
||||
"divestiture": {
|
||||
"asset_value_sar": 1_000_000,
|
||||
"discount_pct": 0.10,
|
||||
"transaction_cost_pct": 0.05,
|
||||
"timeline_months": 6,
|
||||
"success_probability": 0.75,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Models ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class StrategicScenario(BaseModel):
|
||||
"""A fully modeled strategic scenario with financial projections."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
name: str = ""
|
||||
name_ar: str = ""
|
||||
scenario_type: str = "partnership"
|
||||
parties: list[str] = Field(default_factory=list)
|
||||
assumptions: dict = Field(default_factory=dict)
|
||||
upside: dict = Field(default_factory=dict)
|
||||
downside: dict = Field(default_factory=dict)
|
||||
timeline_months: int = 12
|
||||
probability: float = Field(0.5, ge=0.0, le=1.0)
|
||||
net_value_sar: float = 0.0
|
||||
recommendation: str = ""
|
||||
recommendation_ar: str = ""
|
||||
created_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"name": "Partnership with LogiPrime",
|
||||
"name_ar": "شراكة مع لوجي برايم",
|
||||
"scenario_type": "partnership",
|
||||
"parties": ["شركتنا", "لوجي برايم"],
|
||||
"probability": 0.65,
|
||||
"net_value_sar": 750_000,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ── Strategic Simulator Engine ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class StrategicSimulator:
|
||||
"""
|
||||
Simulates strategic scenarios, comparing outcomes and generating
|
||||
Arabic-language recommendations for Saudi B2B decision-makers.
|
||||
يحاكي السيناريوهات الاستراتيجية ويقارن النتائج ويولد توصيات بالعربي
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.llm = get_llm()
|
||||
self._scenarios: dict[str, StrategicScenario] = {}
|
||||
|
||||
# ── Simulate ────────────────────────────────────────────────────────────
|
||||
|
||||
async def simulate(
|
||||
self,
|
||||
scenario_type: str,
|
||||
params: dict,
|
||||
twin_id: Optional[str],
|
||||
db: AsyncSession,
|
||||
) -> StrategicScenario:
|
||||
"""
|
||||
Run a full strategic simulation for a given scenario type.
|
||||
تشغيل محاكاة استراتيجية كاملة لنوع سيناريو معين
|
||||
"""
|
||||
if scenario_type not in SCENARIO_TYPES:
|
||||
raise ValueError(
|
||||
f"Unknown scenario type '{scenario_type}'. "
|
||||
f"Valid types: {', '.join(SCENARIO_TYPES.keys())}"
|
||||
)
|
||||
|
||||
# Load acquirer profile if twin_id provided
|
||||
acquirer_name = params.get("acquirer_name", "الشركة")
|
||||
acquirer_revenue = float(params.get("acquirer_revenue_sar", 0))
|
||||
if twin_id:
|
||||
result = await db.execute(
|
||||
select(CompanyProfile).where(CompanyProfile.id == twin_id)
|
||||
)
|
||||
twin = result.scalar_one_or_none()
|
||||
if twin:
|
||||
acquirer_name = twin.company_name or acquirer_name
|
||||
acquirer_revenue = float(twin.annual_revenue_sar or acquirer_revenue)
|
||||
|
||||
# Merge defaults with user-provided params
|
||||
defaults = DEFAULT_ASSUMPTIONS.get(scenario_type, {}).copy()
|
||||
assumptions = {**defaults, **params.get("assumptions", {})}
|
||||
|
||||
# Compute financials based on scenario type
|
||||
upside, downside, net_value, timeline = self._compute_financials(
|
||||
scenario_type, assumptions, acquirer_revenue,
|
||||
)
|
||||
|
||||
probability = min(1.0, max(0.0, float(
|
||||
assumptions.get("success_probability",
|
||||
defaults.get("success_probability", 0.5))
|
||||
)))
|
||||
|
||||
# Build scenario
|
||||
parties = params.get("parties", [acquirer_name])
|
||||
scenario = StrategicScenario(
|
||||
name=params.get("name", f"{scenario_type} scenario"),
|
||||
name_ar=params.get("name_ar", SCENARIO_TYPES.get(scenario_type, scenario_type)),
|
||||
scenario_type=scenario_type,
|
||||
parties=parties,
|
||||
assumptions=assumptions,
|
||||
upside=upside,
|
||||
downside=downside,
|
||||
timeline_months=timeline,
|
||||
probability=probability,
|
||||
net_value_sar=round(net_value, 2),
|
||||
)
|
||||
|
||||
# Generate Arabic recommendation via LLM
|
||||
recommendation = await self._generate_scenario_recommendation(scenario)
|
||||
scenario.recommendation = recommendation
|
||||
scenario.recommendation_ar = recommendation
|
||||
|
||||
self._scenarios[scenario.id] = scenario
|
||||
|
||||
logger.info(
|
||||
"Simulated scenario '%s' (type=%s): net_value=%.0f SAR, probability=%.0%%",
|
||||
scenario.name, scenario_type, net_value, probability * 100,
|
||||
)
|
||||
return scenario
|
||||
|
||||
# ── Compare Scenarios ───────────────────────────────────────────────────
|
||||
|
||||
async def compare_scenarios(
|
||||
self,
|
||||
scenarios: list[StrategicScenario],
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""
|
||||
Rank and compare multiple scenarios by expected value and risk.
|
||||
ترتيب ومقارنة عدة سيناريوهات حسب القيمة المتوقعة والمخاطر
|
||||
"""
|
||||
if not scenarios:
|
||||
return {"ranked": [], "summary_ar": "لا توجد سيناريوهات للمقارنة"}
|
||||
|
||||
ranked = []
|
||||
for s in scenarios:
|
||||
expected_value = s.net_value_sar * s.probability
|
||||
risk_adjusted = expected_value * (1.0 - (1.0 - s.probability) * 0.5)
|
||||
ranked.append({
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"name_ar": s.name_ar,
|
||||
"scenario_type": s.scenario_type,
|
||||
"net_value_sar": s.net_value_sar,
|
||||
"probability": s.probability,
|
||||
"expected_value_sar": round(expected_value, 2),
|
||||
"risk_adjusted_value_sar": round(risk_adjusted, 2),
|
||||
"timeline_months": s.timeline_months,
|
||||
"upside_total": sum(
|
||||
float(v) for v in s.upside.values() if isinstance(v, (int, float))
|
||||
),
|
||||
"downside_total": sum(
|
||||
float(v) for v in s.downside.values() if isinstance(v, (int, float))
|
||||
),
|
||||
})
|
||||
|
||||
ranked.sort(key=lambda x: x["risk_adjusted_value_sar"], reverse=True)
|
||||
|
||||
# Add rank
|
||||
for i, item in enumerate(ranked):
|
||||
item["rank"] = i + 1
|
||||
|
||||
# Generate comparison summary
|
||||
best = ranked[0]
|
||||
worst = ranked[-1]
|
||||
|
||||
summary_ar = (
|
||||
f"تم مقارنة {len(ranked)} سيناريو. "
|
||||
f"الأفضل: {best['name_ar']} بقيمة متوقعة {best['expected_value_sar']:,.0f} ريال "
|
||||
f"واحتمالية نجاح {best['probability']:.0%}. "
|
||||
)
|
||||
if len(ranked) > 1:
|
||||
summary_ar += (
|
||||
f"الأقل جاذبية: {worst['name_ar']} بقيمة متوقعة "
|
||||
f"{worst['expected_value_sar']:,.0f} ريال."
|
||||
)
|
||||
|
||||
logger.info("Compared %d scenarios. Best: %s", len(ranked), best["name"])
|
||||
|
||||
return {
|
||||
"ranked": ranked,
|
||||
"best_scenario_id": best["id"],
|
||||
"summary_ar": summary_ar,
|
||||
}
|
||||
|
||||
# ── Sensitivity Analysis ────────────────────────────────────────────────
|
||||
|
||||
async def sensitivity_analysis(
|
||||
self,
|
||||
scenario_id: str,
|
||||
variable: str,
|
||||
value_range: list[float],
|
||||
db: AsyncSession,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Run sensitivity analysis on a single variable across a range of values.
|
||||
تحليل الحساسية لمتغير واحد عبر نطاق من القيم
|
||||
"""
|
||||
base_scenario = self._scenarios.get(scenario_id)
|
||||
if not base_scenario:
|
||||
raise ValueError(f"Scenario {scenario_id} not found")
|
||||
|
||||
if not value_range:
|
||||
base_val = float(base_scenario.assumptions.get(variable, 1.0))
|
||||
value_range = [
|
||||
round(base_val * 0.5, 4),
|
||||
round(base_val * 0.75, 4),
|
||||
round(base_val, 4),
|
||||
round(base_val * 1.25, 4),
|
||||
round(base_val * 1.5, 4),
|
||||
]
|
||||
|
||||
results: list[dict] = []
|
||||
for val in value_range:
|
||||
modified_assumptions = base_scenario.assumptions.copy()
|
||||
modified_assumptions[variable] = val
|
||||
|
||||
upside, downside, net_value, timeline = self._compute_financials(
|
||||
base_scenario.scenario_type, modified_assumptions, 0,
|
||||
)
|
||||
|
||||
expected = net_value * base_scenario.probability
|
||||
results.append({
|
||||
"variable": variable,
|
||||
"value": val,
|
||||
"net_value_sar": round(net_value, 2),
|
||||
"expected_value_sar": round(expected, 2),
|
||||
"upside_revenue": upside.get("revenue_gain_sar", 0),
|
||||
"downside_cost": downside.get("total_cost_sar", 0),
|
||||
"delta_from_base": round(net_value - base_scenario.net_value_sar, 2),
|
||||
})
|
||||
|
||||
logger.info(
|
||||
"Sensitivity analysis for scenario %s on '%s': %d data points",
|
||||
scenario_id, variable, len(results),
|
||||
)
|
||||
return results
|
||||
|
||||
# ── Generate Recommendation ─────────────────────────────────────────────
|
||||
|
||||
async def generate_recommendation(
|
||||
self,
|
||||
scenario_id: str,
|
||||
db: AsyncSession,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a detailed Arabic strategic recommendation for a scenario.
|
||||
إنشاء توصية استراتيجية تفصيلية بالعربي لسيناريو محدد
|
||||
"""
|
||||
scenario = self._scenarios.get(scenario_id)
|
||||
if not scenario:
|
||||
raise ValueError(f"Scenario {scenario_id} not found")
|
||||
|
||||
recommendation = await self._generate_scenario_recommendation(scenario)
|
||||
scenario.recommendation = recommendation
|
||||
scenario.recommendation_ar = recommendation
|
||||
|
||||
logger.info("Generated recommendation for scenario %s", scenario_id)
|
||||
return recommendation
|
||||
|
||||
# ── Private: Compute Financials ─────────────────────────────────────────
|
||||
|
||||
def _compute_financials(
|
||||
self,
|
||||
scenario_type: str,
|
||||
assumptions: dict,
|
||||
acquirer_revenue: float,
|
||||
) -> tuple[dict, dict, float, int]:
|
||||
"""Compute upside, downside, net value, and timeline from assumptions."""
|
||||
|
||||
if scenario_type == "partnership":
|
||||
rev_share = float(assumptions.get("revenue_share_pct", 0.15))
|
||||
setup = float(assumptions.get("setup_cost_sar", 50_000))
|
||||
ramp = int(assumptions.get("ramp_months", 3))
|
||||
growth = float(assumptions.get("annual_growth_pct", 0.10))
|
||||
base_rev = acquirer_revenue if acquirer_revenue > 0 else 1_000_000
|
||||
|
||||
annual_gain = base_rev * rev_share
|
||||
three_year = annual_gain * (1 + growth) + annual_gain * (1 + growth) ** 2 + annual_gain * (1 + growth) ** 3
|
||||
|
||||
upside = {
|
||||
"revenue_gain_sar": round(annual_gain, 2),
|
||||
"three_year_revenue_sar": round(three_year, 2),
|
||||
"reach_expansion_pct": round(rev_share * 100, 1),
|
||||
"capacity_gain_pct": round(rev_share * 50, 1),
|
||||
}
|
||||
downside = {
|
||||
"setup_cost_sar": setup,
|
||||
"annual_management_sar": round(setup * 0.3, 2),
|
||||
"total_cost_sar": round(setup + setup * 0.3 * 3, 2),
|
||||
"operational_burden": "متوسط",
|
||||
"risk_level": "منخفض",
|
||||
}
|
||||
net_value = three_year - downside["total_cost_sar"]
|
||||
timeline = ramp + 12
|
||||
|
||||
elif scenario_type == "acquisition":
|
||||
premium = float(assumptions.get("premium_pct", 0.25))
|
||||
integration_cost = float(assumptions.get("integration_cost_pct", 0.15))
|
||||
synergy = float(assumptions.get("synergy_savings_pct", 0.10))
|
||||
target_value = float(assumptions.get("target_value_sar", acquirer_revenue * 0.3))
|
||||
ramp = int(assumptions.get("ramp_months", 12))
|
||||
|
||||
acquisition_price = target_value * (1 + premium)
|
||||
integration = target_value * integration_cost
|
||||
annual_synergy = target_value * synergy
|
||||
|
||||
upside = {
|
||||
"revenue_gain_sar": round(target_value, 2),
|
||||
"annual_synergy_sar": round(annual_synergy, 2),
|
||||
"three_year_synergy_sar": round(annual_synergy * 3, 2),
|
||||
"market_share_gain_pct": round(target_value / max(acquirer_revenue, 1) * 100, 1),
|
||||
}
|
||||
downside = {
|
||||
"acquisition_price_sar": round(acquisition_price, 2),
|
||||
"integration_cost_sar": round(integration, 2),
|
||||
"total_cost_sar": round(acquisition_price + integration, 2),
|
||||
"operational_burden": "عالي",
|
||||
"risk_level": "عالي",
|
||||
}
|
||||
net_value = upside["three_year_synergy_sar"] + target_value - downside["total_cost_sar"]
|
||||
timeline = ramp + 24
|
||||
|
||||
elif scenario_type == "channel_expansion":
|
||||
channel_setup = float(assumptions.get("channel_setup_sar", 100_000))
|
||||
per_channel = float(assumptions.get("per_channel_cost_sar", 25_000))
|
||||
channels = int(assumptions.get("channels_count", 3))
|
||||
rev_per_channel = float(assumptions.get("revenue_per_channel_sar", 200_000))
|
||||
ramp = int(assumptions.get("ramp_months", 6))
|
||||
|
||||
total_setup = channel_setup + per_channel * channels
|
||||
annual_rev = rev_per_channel * channels
|
||||
|
||||
upside = {
|
||||
"revenue_gain_sar": round(annual_rev, 2),
|
||||
"reach_expansion_pct": round(channels * 15, 1),
|
||||
"channels_added": channels,
|
||||
}
|
||||
downside = {
|
||||
"setup_cost_sar": round(total_setup, 2),
|
||||
"annual_ops_sar": round(per_channel * channels * 0.5, 2),
|
||||
"total_cost_sar": round(total_setup + per_channel * channels * 0.5, 2),
|
||||
"operational_burden": "متوسط",
|
||||
"risk_level": "منخفض",
|
||||
}
|
||||
net_value = annual_rev * 2 - downside["total_cost_sar"]
|
||||
timeline = ramp + 12
|
||||
|
||||
elif scenario_type == "market_entry":
|
||||
entry_cost = float(assumptions.get("entry_cost_sar", 500_000))
|
||||
first_year = float(assumptions.get("first_year_revenue_sar", 300_000))
|
||||
growth = float(assumptions.get("annual_growth_pct", 0.20))
|
||||
ramp = int(assumptions.get("ramp_months", 12))
|
||||
|
||||
three_year_rev = first_year + first_year * (1 + growth) + first_year * (1 + growth) ** 2
|
||||
|
||||
upside = {
|
||||
"revenue_gain_sar": round(first_year, 2),
|
||||
"three_year_revenue_sar": round(three_year_rev, 2),
|
||||
"market_share_target_pct": float(assumptions.get("market_share_target", 0.05)) * 100,
|
||||
}
|
||||
downside = {
|
||||
"entry_cost_sar": round(entry_cost, 2),
|
||||
"annual_ops_sar": round(entry_cost * 0.2, 2),
|
||||
"total_cost_sar": round(entry_cost + entry_cost * 0.2 * 2, 2),
|
||||
"operational_burden": "عالي",
|
||||
"risk_level": "عالي",
|
||||
}
|
||||
net_value = three_year_rev - downside["total_cost_sar"]
|
||||
timeline = ramp + 24
|
||||
|
||||
elif scenario_type == "joint_venture":
|
||||
equity = float(assumptions.get("equity_split", 0.50))
|
||||
investment = float(assumptions.get("total_investment_sar", 1_000_000))
|
||||
projected = float(assumptions.get("projected_revenue_sar", 2_000_000))
|
||||
ramp = int(assumptions.get("ramp_months", 9))
|
||||
|
||||
our_share = projected * equity
|
||||
our_cost = investment * equity
|
||||
|
||||
upside = {
|
||||
"revenue_gain_sar": round(our_share, 2),
|
||||
"equity_value_sar": round(our_share * 3, 2),
|
||||
"reach_expansion_pct": round(equity * 100, 1),
|
||||
}
|
||||
downside = {
|
||||
"investment_sar": round(our_cost, 2),
|
||||
"annual_ops_sar": round(our_cost * 0.1, 2),
|
||||
"total_cost_sar": round(our_cost + our_cost * 0.1 * 2, 2),
|
||||
"operational_burden": "عالي",
|
||||
"risk_level": "متوسط",
|
||||
}
|
||||
net_value = our_share * 2 - downside["total_cost_sar"]
|
||||
timeline = ramp + 18
|
||||
|
||||
elif scenario_type == "franchise":
|
||||
fee = float(assumptions.get("franchise_fee_sar", 200_000))
|
||||
royalty = float(assumptions.get("royalty_pct", 0.06))
|
||||
unit_rev = float(assumptions.get("unit_revenue_sar", 500_000))
|
||||
units = int(assumptions.get("units_count", 2))
|
||||
ramp = int(assumptions.get("ramp_months", 6))
|
||||
|
||||
annual_royalty = unit_rev * units * royalty
|
||||
total_fees = fee * units
|
||||
|
||||
upside = {
|
||||
"revenue_gain_sar": round(annual_royalty + total_fees, 2),
|
||||
"annual_royalty_sar": round(annual_royalty, 2),
|
||||
"franchise_fees_sar": round(total_fees, 2),
|
||||
"units_count": units,
|
||||
}
|
||||
downside = {
|
||||
"setup_cost_sar": round(fee * 0.3 * units, 2),
|
||||
"support_cost_sar": round(unit_rev * 0.02 * units, 2),
|
||||
"total_cost_sar": round(fee * 0.3 * units + unit_rev * 0.02 * units * 3, 2),
|
||||
"operational_burden": "متوسط",
|
||||
"risk_level": "منخفض",
|
||||
}
|
||||
net_value = (annual_royalty * 3 + total_fees) - downside["total_cost_sar"]
|
||||
timeline = ramp + 12
|
||||
|
||||
elif scenario_type == "divestiture":
|
||||
asset_val = float(assumptions.get("asset_value_sar", 1_000_000))
|
||||
discount = float(assumptions.get("discount_pct", 0.10))
|
||||
tx_cost = float(assumptions.get("transaction_cost_pct", 0.05))
|
||||
ramp = int(assumptions.get("timeline_months", 6))
|
||||
|
||||
proceeds = asset_val * (1 - discount)
|
||||
costs = asset_val * tx_cost
|
||||
|
||||
upside = {
|
||||
"proceeds_sar": round(proceeds, 2),
|
||||
"cash_freed_sar": round(proceeds - costs, 2),
|
||||
"operational_relief": "تخفيف عبء تشغيلي",
|
||||
}
|
||||
downside = {
|
||||
"transaction_cost_sar": round(costs, 2),
|
||||
"discount_loss_sar": round(asset_val * discount, 2),
|
||||
"total_cost_sar": round(costs + asset_val * discount, 2),
|
||||
"operational_burden": "منخفض",
|
||||
"risk_level": "منخفض",
|
||||
}
|
||||
net_value = proceeds - costs
|
||||
timeline = ramp
|
||||
|
||||
else:
|
||||
upside = {"revenue_gain_sar": 0}
|
||||
downside = {"total_cost_sar": 0}
|
||||
net_value = 0
|
||||
timeline = 12
|
||||
|
||||
return upside, downside, round(net_value, 2), timeline
|
||||
|
||||
# ── Private: Generate Recommendation ────────────────────────────────────
|
||||
|
||||
async def _generate_scenario_recommendation(
|
||||
self, scenario: StrategicScenario,
|
||||
) -> str:
|
||||
"""Generate an Arabic recommendation for a scenario using LLM."""
|
||||
type_ar = SCENARIO_TYPES.get(scenario.scenario_type, scenario.scenario_type)
|
||||
|
||||
context = f"""نوع السيناريو: {type_ar}
|
||||
الأطراف: {', '.join(scenario.parties)}
|
||||
الافتراضات: {json.dumps(scenario.assumptions, ensure_ascii=False)}
|
||||
الجانب الإيجابي: {json.dumps(scenario.upside, ensure_ascii=False)}
|
||||
الجانب السلبي: {json.dumps(scenario.downside, ensure_ascii=False)}
|
||||
المدة الزمنية: {scenario.timeline_months} شهر
|
||||
احتمالية النجاح: {scenario.probability:.0%}
|
||||
صافي القيمة: {scenario.net_value_sar:,.0f} ريال سعودي"""
|
||||
|
||||
system_prompt = """أنت مستشار استراتيجي سعودي خبير. اكتب توصية تنفيذية واضحة بالعربي.
|
||||
|
||||
يجب أن تشمل:
|
||||
١. ملخص تنفيذي في سطرين
|
||||
٢. المبرر الاستراتيجي
|
||||
٣. المخاطر الرئيسية وطرق التخفيف
|
||||
٤. التوصية النهائية (تنفيذ / تأجيل / رفض) مع المبررات
|
||||
٥. الخطوات التالية إذا كانت التوصية بالتنفيذ
|
||||
|
||||
اكتب بأسلوب مهني رسمي مناسب لعرضه على الإدارة التنفيذية."""
|
||||
|
||||
try:
|
||||
llm_response = await self.llm.complete(
|
||||
system_prompt=system_prompt,
|
||||
user_message=context,
|
||||
temperature=0.3,
|
||||
)
|
||||
return llm_response.content.strip()
|
||||
except Exception as exc:
|
||||
logger.warning("LLM recommendation generation failed: %s", exc)
|
||||
if scenario.net_value_sar > 0 and scenario.probability >= 0.5:
|
||||
verdict = "يُنصح بالتنفيذ"
|
||||
elif scenario.net_value_sar > 0:
|
||||
verdict = "يُنصح بمزيد من الدراسة قبل التنفيذ"
|
||||
else:
|
||||
verdict = "لا يُنصح بالتنفيذ في الوقت الحالي"
|
||||
|
||||
return (
|
||||
f"توصية — {type_ar}\n"
|
||||
f"صافي القيمة المتوقعة: {scenario.net_value_sar:,.0f} ريال\n"
|
||||
f"احتمالية النجاح: {scenario.probability:.0%}\n"
|
||||
f"المدة الزمنية: {scenario.timeline_months} شهر\n"
|
||||
f"القرار: {verdict}"
|
||||
)
|
||||
342
salesflow-saas/backend/app/services/whatsapp_brain.py
Normal file
342
salesflow-saas/backend/app/services/whatsapp_brain.py
Normal file
@ -0,0 +1,342 @@
|
||||
"""
|
||||
WhatsApp AI Brain — Dealix AI Revenue OS
|
||||
Central intelligence for the Dealix WhatsApp number.
|
||||
Handles: sales, support, marketer support, deals, and general inquiries.
|
||||
Connected to backend data for contextual, intelligent responses.
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConversationMode(str, Enum):
|
||||
SALES = "sales"
|
||||
SUPPORT = "support"
|
||||
MARKETER = "marketer"
|
||||
DEALS = "deals"
|
||||
GENERAL = "general"
|
||||
|
||||
|
||||
class CallerProfile(BaseModel):
|
||||
phone: str
|
||||
name: str = "زائر"
|
||||
caller_type: str = "unknown" # client, marketer, lead, unknown
|
||||
tenant_id: str = ""
|
||||
subscription_plan: str = ""
|
||||
commission_balance: float = 0.0
|
||||
lead_score: int = 0
|
||||
language: str = "ar"
|
||||
|
||||
|
||||
class ConversationEntry(BaseModel):
|
||||
role: str # user, assistant
|
||||
content: str
|
||||
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
ARABIC_MARKERS = ["وش", "كيف", "أبي", "ليش", "هلا", "مرحبا", "السلام", "شكرا", "طيب"]
|
||||
INTENT_KEYWORDS = {
|
||||
"greeting": ["هلا", "مرحبا", "السلام عليكم", "أهلاً", "hi", "hello", "hey"],
|
||||
"pricing": ["سعر", "كم", "باقة", "اشتراك", "price", "cost", "plan", "pricing"],
|
||||
"demo": ["عرض", "demo", "تجربة", "شرح", "وريني"],
|
||||
"support": ["مشكلة", "ما يشتغل", "خطأ", "bug", "help", "مساعدة", "دعم"],
|
||||
"complaint": ["شكوى", "زعلان", "سيء", "complaint", "unhappy"],
|
||||
"partnership": ["شراكة", "partner", "تعاون", "صفقة", "deal"],
|
||||
"commission": ["عمولة", "commission", "أرباح", "دفعة", "payout"],
|
||||
"feature": ["ميزة", "feature", "يقدر", "يدعم", "فيه"],
|
||||
"competitor": ["zoho", "salesforce", "hubspot", "pipedrive", "منافس"],
|
||||
"cancel": ["إلغاء", "cancel", "أوقف", "stop"],
|
||||
}
|
||||
|
||||
|
||||
class WhatsAppBrain:
|
||||
"""Central brain for Dealix WhatsApp — routes and responds intelligently."""
|
||||
|
||||
def __init__(self):
|
||||
self._conversations: dict[str, list[ConversationEntry]] = {}
|
||||
from app.services.whatsapp_knowledge import DealixKnowledge
|
||||
self.knowledge = DealixKnowledge
|
||||
|
||||
async def handle_incoming(
|
||||
self, phone: str, message: str, db: Any = None
|
||||
) -> str:
|
||||
caller = await self.identify_caller(phone, db)
|
||||
language = self._detect_language(message)
|
||||
caller.language = language
|
||||
intent = self._detect_intent(message)
|
||||
history = self._get_history(phone)
|
||||
mode = self._route_conversation(intent, caller)
|
||||
|
||||
self._add_to_history(phone, "user", message)
|
||||
|
||||
handlers = {
|
||||
ConversationMode.SALES: self._handle_sales,
|
||||
ConversationMode.SUPPORT: self._handle_support,
|
||||
ConversationMode.MARKETER: self._handle_marketer,
|
||||
ConversationMode.DEALS: self._handle_deals,
|
||||
ConversationMode.GENERAL: self._handle_general,
|
||||
}
|
||||
handler = handlers.get(mode, self._handle_general)
|
||||
response = await handler(message, caller, intent, history, db)
|
||||
|
||||
self._add_to_history(phone, "assistant", response)
|
||||
logger.info(
|
||||
f"[WhatsAppBrain] {phone} mode={mode.value} intent={intent} "
|
||||
f"caller={caller.caller_type} lang={language}"
|
||||
)
|
||||
return response
|
||||
|
||||
async def identify_caller(self, phone: str, db: Any = None) -> CallerProfile:
|
||||
profile = CallerProfile(phone=phone)
|
||||
if not db:
|
||||
return profile
|
||||
try:
|
||||
from sqlalchemy import select, or_
|
||||
from app.models.lead import Lead
|
||||
from app.models.user import User
|
||||
from app.models.affiliate import AffiliateMarketer
|
||||
|
||||
clean_phone = phone.replace("+", "").replace(" ", "")
|
||||
|
||||
# Check if affiliate marketer
|
||||
result = await db.execute(
|
||||
select(AffiliateMarketer).where(
|
||||
AffiliateMarketer.phone.contains(clean_phone[-9:])
|
||||
).limit(1)
|
||||
)
|
||||
marketer = result.scalar_one_or_none()
|
||||
if marketer:
|
||||
profile.caller_type = "marketer"
|
||||
profile.name = marketer.full_name or "مسوّق"
|
||||
profile.tenant_id = str(marketer.tenant_id) if hasattr(marketer, 'tenant_id') else ""
|
||||
return profile
|
||||
|
||||
# Check if existing user/client
|
||||
result = await db.execute(
|
||||
select(User).where(User.phone.contains(clean_phone[-9:])).limit(1)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
profile.caller_type = "client"
|
||||
profile.name = user.full_name or "عميل"
|
||||
profile.tenant_id = str(user.tenant_id) if hasattr(user, 'tenant_id') else ""
|
||||
return profile
|
||||
|
||||
# Check if known lead
|
||||
result = await db.execute(
|
||||
select(Lead).where(Lead.phone.contains(clean_phone[-9:])).limit(1)
|
||||
)
|
||||
lead = result.scalar_one_or_none()
|
||||
if lead:
|
||||
profile.caller_type = "lead"
|
||||
profile.name = lead.name or "عميل محتمل"
|
||||
profile.lead_score = lead.score or 0
|
||||
profile.tenant_id = str(lead.tenant_id) if hasattr(lead, 'tenant_id') else ""
|
||||
return profile
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error identifying caller {phone}: {e}")
|
||||
|
||||
return profile
|
||||
|
||||
def _detect_language(self, message: str) -> str:
|
||||
arabic_chars = len(re.findall(r'[\u0600-\u06FF]', message))
|
||||
latin_chars = len(re.findall(r'[a-zA-Z]', message))
|
||||
return "ar" if arabic_chars >= latin_chars else "en"
|
||||
|
||||
def _detect_intent(self, message: str) -> str:
|
||||
msg_lower = message.lower()
|
||||
for intent, keywords in INTENT_KEYWORDS.items():
|
||||
if any(kw in msg_lower for kw in keywords):
|
||||
return intent
|
||||
return "general"
|
||||
|
||||
def _route_conversation(self, intent: str, caller: CallerProfile) -> ConversationMode:
|
||||
if caller.caller_type == "marketer" or intent == "commission":
|
||||
return ConversationMode.MARKETER
|
||||
if caller.caller_type == "client" and intent in ("support", "complaint", "cancel"):
|
||||
return ConversationMode.SUPPORT
|
||||
if intent in ("partnership",):
|
||||
return ConversationMode.DEALS
|
||||
if intent in ("pricing", "demo", "feature", "competitor"):
|
||||
return ConversationMode.SALES
|
||||
if caller.caller_type == "client":
|
||||
return ConversationMode.SUPPORT
|
||||
return ConversationMode.SALES if caller.caller_type == "unknown" else ConversationMode.GENERAL
|
||||
|
||||
def _get_history(self, phone: str) -> list[ConversationEntry]:
|
||||
return self._conversations.get(phone, [])[-10:]
|
||||
|
||||
def _add_to_history(self, phone: str, role: str, content: str) -> None:
|
||||
if phone not in self._conversations:
|
||||
self._conversations[phone] = []
|
||||
self._conversations[phone].append(ConversationEntry(role=role, content=content))
|
||||
if len(self._conversations[phone]) > 50:
|
||||
self._conversations[phone] = self._conversations[phone][-50:]
|
||||
|
||||
async def _handle_sales(
|
||||
self, message: str, caller: CallerProfile, intent: str, history: list, db: Any
|
||||
) -> str:
|
||||
lang = caller.language
|
||||
|
||||
if intent == "greeting":
|
||||
name_part = f" {caller.name}" if caller.name != "زائر" else ""
|
||||
if lang == "ar":
|
||||
return (
|
||||
f"أهلاً وسهلاً{name_part}! 👋\n"
|
||||
f"أنا مساعد ديلكس الذكي.\n\n"
|
||||
f"أقدر أساعدك في:\n"
|
||||
f"• معرفة مميزات Dealix\n"
|
||||
f"• الأسعار والباقات\n"
|
||||
f"• حجز عرض توضيحي\n"
|
||||
f"• أي سؤال ثاني\n\n"
|
||||
f"كيف أقدر أساعدك؟"
|
||||
)
|
||||
return (
|
||||
f"Hello{name_part}! 👋\n"
|
||||
f"I'm the Dealix AI assistant.\n\n"
|
||||
f"I can help with:\n"
|
||||
f"• Dealix features\n• Pricing\n• Book a demo\n\nHow can I help?"
|
||||
)
|
||||
|
||||
if intent == "pricing":
|
||||
pricing_text = self.knowledge.get_pricing_text(lang)
|
||||
suffix = "\nكل الباقات فيها تجربة مجانية ١٤ يوم بدون بطاقة.\nتبي تجرب؟" if lang == "ar" else "\nAll plans include a 14-day free trial. Want to try?"
|
||||
return f"{pricing_text}\n{suffix}"
|
||||
|
||||
if intent == "demo":
|
||||
if lang == "ar":
|
||||
return (
|
||||
"ممتاز! يسعدنا نعرض لك Dealix 🎉\n\n"
|
||||
"العرض يستغرق ١٥ دقيقة فقط.\n"
|
||||
"أرسل لي اسمك ورقم جوالك وأرتب لك الموعد."
|
||||
)
|
||||
return "Great! We'd love to show you Dealix 🎉\nThe demo takes just 15 minutes.\nSend your name and phone, and I'll set it up."
|
||||
|
||||
if intent == "competitor":
|
||||
for comp in ["zoho", "salesforce", "hubspot"]:
|
||||
if comp in message.lower():
|
||||
resp = self.knowledge.get_competitor_response(comp)
|
||||
if resp:
|
||||
return resp
|
||||
if lang == "ar":
|
||||
return "Dealix الوحيد المصمم للسوق السعودي: عربي أولاً، واتساب مدمج، AI يفهم سعودي. تبي أوريك المقارنة؟"
|
||||
return "Dealix is the only CRM built for Saudi: Arabic-first, WhatsApp native, Saudi-aware AI. Want to see the comparison?"
|
||||
|
||||
if intent == "feature":
|
||||
for key, feat in self.knowledge.FEATURES.items():
|
||||
if any(word in message for word in feat["name_ar"].split()):
|
||||
points = "\n".join(f"✅ {p}" for p in feat["selling_points_ar"])
|
||||
return f"*{feat['name_ar']}*\n{feat['desc_ar']}\n\n{points}"
|
||||
|
||||
# Check objections
|
||||
for obj_type, obj_data in self.knowledge.OBJECTION_RESPONSES.items():
|
||||
triggers = {"expensive": ["غالي", "مكلف"], "need_to_think": ["أفكر", "بشوف"], "too_complex": ["صعب", "معقد"], "small_team": ["صغير", "وحدي"]}
|
||||
if obj_type in triggers and any(t in message for t in triggers[obj_type]):
|
||||
return obj_data.get(lang, obj_data["ar"])
|
||||
|
||||
# FAQ search
|
||||
faq_answer = self.knowledge.search_faq(message)
|
||||
if faq_answer:
|
||||
return faq_answer
|
||||
|
||||
if lang == "ar":
|
||||
return "شكراً لتواصلك! 🙏\nأقدر أساعدك بأي سؤال عن Dealix — الأسعار، المميزات، أو حجز عرض توضيحي.\nوش تحب تعرف؟"
|
||||
return "Thanks for reaching out! 🙏\nI can help with pricing, features, or booking a demo.\nWhat would you like to know?"
|
||||
|
||||
async def _handle_support(
|
||||
self, message: str, caller: CallerProfile, intent: str, history: list, db: Any
|
||||
) -> str:
|
||||
name = caller.name or "عميل"
|
||||
if intent == "complaint":
|
||||
return (
|
||||
f"أستاذ/ة {name}، نعتذر عن أي إزعاج 🙏\n"
|
||||
f"فريق الدعم المتخصص بيتواصل معك خلال ساعة.\n"
|
||||
f"لو تقدر توصف المشكلة بالتفصيل، بيساعدنا نحلها أسرع."
|
||||
)
|
||||
if intent == "cancel":
|
||||
return (
|
||||
f"أستاذ/ة {name}، نأسف إنك تفكر بالإلغاء 😔\n"
|
||||
f"قبل ما نلغي، ممكن أعرف السبب؟ يمكن نقدر نساعدك.\n"
|
||||
f"لو تبي، أقدر أحولك لمدير حسابك مباشرة."
|
||||
)
|
||||
return (
|
||||
f"أهلاً {name}! 👋\n"
|
||||
f"كيف أقدر أساعدك اليوم؟\n\n"
|
||||
f"لو عندك مشكلة تقنية، وصّف لي المشكلة وبأساعدك فوراً.\n"
|
||||
f"لو تحتاج شي ما أقدر أحله، بأحولك لفريق الدعم المتخصص."
|
||||
)
|
||||
|
||||
async def _handle_marketer(
|
||||
self, message: str, caller: CallerProfile, intent: str, history: list, db: Any
|
||||
) -> str:
|
||||
name = caller.name or "مسوّق"
|
||||
if intent == "commission":
|
||||
return (
|
||||
f"أهلاً {name}! 🌟\n\n"
|
||||
f"للاطلاع على عمولاتك وأدائك، ادخل لوحة التحكم من:\n"
|
||||
f"dealix.sa/dashboard\n\n"
|
||||
f"لو عندك سؤال عن العمولات أو المدفوعات، أنا هنا أساعدك."
|
||||
)
|
||||
|
||||
# Search marketer FAQ
|
||||
for faq in self.knowledge.MARKETER_FAQ:
|
||||
if any(word in message for word in faq["q_ar"].split() if len(word) > 2):
|
||||
return faq["a_ar"]
|
||||
|
||||
return (
|
||||
f"أهلاً {name}! مسوّقنا المميز 🌟\n\n"
|
||||
f"كيف أقدر أساعدك اليوم؟\n"
|
||||
f"• استفسار عن العمولات\n"
|
||||
f"• مساعدة تقنية\n"
|
||||
f"• نصائح للتسويق\n"
|
||||
f"• أي سؤال ثاني"
|
||||
)
|
||||
|
||||
async def _handle_deals(
|
||||
self, message: str, caller: CallerProfile, intent: str, history: list, db: Any
|
||||
) -> str:
|
||||
return (
|
||||
"أهلاً! 🤝\n\n"
|
||||
"Dealix يدعم ١٥ نوع صفقة استراتيجية:\n"
|
||||
"• شراكات وتبادل خدمات\n"
|
||||
"• توزيع وreseller\n"
|
||||
"• مشاريع مشتركة\n"
|
||||
"• فرص استحواذ\n\n"
|
||||
"حدثني أكثر عن شركتك ووش تبحث عنه، وبأساعدك نلقى أفضل فرصة."
|
||||
)
|
||||
|
||||
async def _handle_general(
|
||||
self, message: str, caller: CallerProfile, intent: str, history: list, db: Any
|
||||
) -> str:
|
||||
faq_answer = self.knowledge.search_faq(message)
|
||||
if faq_answer:
|
||||
return faq_answer
|
||||
lang = caller.language
|
||||
if lang == "ar":
|
||||
return (
|
||||
"أهلاً وسهلاً! 👋\n"
|
||||
"أنا مساعد ديلكس — نظام المبيعات الذكي للسعودية.\n\n"
|
||||
"أقدر أساعدك في:\n"
|
||||
"١. معرفة مميزات Dealix\n"
|
||||
"٢. الأسعار والباقات\n"
|
||||
"٣. حجز عرض توضيحي\n"
|
||||
"٤. الدعم الفني\n"
|
||||
"٥. برنامج التسويق بالعمولة\n\n"
|
||||
"أختر رقم أو اكتب سؤالك مباشرة."
|
||||
)
|
||||
return (
|
||||
"Hello! 👋\nI'm the Dealix assistant — the smart sales system for Saudi Arabia.\n\n"
|
||||
"I can help with:\n1. Features\n2. Pricing\n3. Book a demo\n4. Support\n5. Affiliate program\n\n"
|
||||
"Pick a number or type your question."
|
||||
)
|
||||
|
||||
|
||||
# Global singleton
|
||||
whatsapp_brain = WhatsAppBrain()
|
||||
221
salesflow-saas/backend/app/services/whatsapp_knowledge.py
Normal file
221
salesflow-saas/backend/app/services/whatsapp_knowledge.py
Normal file
@ -0,0 +1,221 @@
|
||||
"""
|
||||
WhatsApp Knowledge Base — Dealix AI Revenue OS
|
||||
Complete knowledge the WhatsApp brain uses to respond intelligently.
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DealixKnowledge:
|
||||
"""Everything the WhatsApp brain needs to know."""
|
||||
|
||||
FEATURES = {
|
||||
"whatsapp_crm": {
|
||||
"name_ar": "إدارة العملاء عبر الواتساب",
|
||||
"name_en": "WhatsApp CRM",
|
||||
"desc_ar": "تواصل مع عملاءك مباشرة من الواتساب مع تتبع كامل للمحادثات",
|
||||
"selling_points_ar": [
|
||||
"رد تلقائي ذكي بالعربي",
|
||||
"تتبع كل محادثة",
|
||||
"إشعارات فورية عند رد العميل",
|
||||
],
|
||||
},
|
||||
"ai_scoring": {
|
||||
"name_ar": "تقييم عملاء بالذكاء الاصطناعي",
|
||||
"name_en": "AI Lead Scoring",
|
||||
"desc_ar": "النظام يقيّم كل عميل من ٠ لـ ١٠٠ ويقولك مين الأهم",
|
||||
"selling_points_ar": [
|
||||
"تقييم تلقائي مع كل تفاعل",
|
||||
"يفهم المحادثات العربية",
|
||||
"توصيات متابعة بالعربي",
|
||||
],
|
||||
},
|
||||
"pipeline": {
|
||||
"name_ar": "مسار صفقات بصري",
|
||||
"name_en": "Visual Pipeline",
|
||||
"desc_ar": "شوف كل صفقاتك بنظرة واحدة وحركها بالسحب",
|
||||
"selling_points_ar": ["Kanban بصري", "٥ مراحل", "drag-and-drop"],
|
||||
},
|
||||
"cpq": {
|
||||
"name_ar": "عروض أسعار احترافية",
|
||||
"name_en": "Quotes & Proposals",
|
||||
"desc_ar": "أنشئ عروض أسعار بالعربي مع ضريبة القيمة المضافة تلقائياً",
|
||||
"selling_points_ar": ["ضريبة ١٥٪ تلقائي", "إرسال بالواتساب", "تتبع القبول"],
|
||||
},
|
||||
"pdpl": {
|
||||
"name_ar": "حماية البيانات PDPL",
|
||||
"name_en": "PDPL Compliance",
|
||||
"desc_ar": "متوافق مع نظام حماية البيانات الشخصية السعودي",
|
||||
"selling_points_ar": ["موافقات تلقائية", "حقوق بيانات", "audit trail"],
|
||||
},
|
||||
"deal_exchange": {
|
||||
"name_ar": "صفقات استراتيجية",
|
||||
"name_en": "Strategic Deals",
|
||||
"desc_ar": "اكتشف شركاء وصفقات متبادلة — تبادل خدمات، شراكات، توزيع",
|
||||
"selling_points_ar": ["١٥ نوع صفقة", "مطابقة ذكية", "مفاوض AI"],
|
||||
},
|
||||
}
|
||||
|
||||
PRICING = {
|
||||
"all_in_one": {
|
||||
"name_ar": "Dealix All-in-One",
|
||||
"name_en": "Dealix All-in-One",
|
||||
"price_monthly": 1499,
|
||||
"price_yearly": 14999,
|
||||
"trial_days": 7,
|
||||
"users_included": 20,
|
||||
"extra_user_price": 99,
|
||||
"features_ar": [
|
||||
"كل المميزات مفتوحة — بدون استثناء",
|
||||
"٧ أدمغة AI لكل قناة (واتساب، إيميل، لينكدإن، إنستقرام، تيكتوك، تويتر، سناب)",
|
||||
"صفقات استراتيجية — ١٥ نوع صفقة",
|
||||
"مفاوض AI بالعربي",
|
||||
"Company Twin — نموذج شركتك الرقمي",
|
||||
"رصد استحواذات + محاكي نمو",
|
||||
"تقييم عملاء AI + تنبؤات مبيعات",
|
||||
"مسار صفقات بصري + عروض أسعار CPQ",
|
||||
"تسلسلات متعددة القنوات",
|
||||
"PDPL كامل + حوكمة",
|
||||
"٢٠ مستخدم + ٩٩ ر.س لكل مستخدم إضافي",
|
||||
"دعم عربي + إنجليزي",
|
||||
"٧ أيام تجربة مجانية — بدون بطاقة",
|
||||
],
|
||||
"best_for_ar": "كل الشركات — من الصغيرة للكبيرة",
|
||||
"guarantee_ar": "استرداد كامل خلال ٣٠ يوم إذا لم يعجبك",
|
||||
},
|
||||
}
|
||||
|
||||
OBJECTION_RESPONSES = {
|
||||
"expensive": {
|
||||
"ar": "أفهم — لكن ٥٩ ر.س أقل من فاتورة كابتشينو أسبوعية. وصفقة وحدة ضايعة بسبب عدم المتابعة تكلف أضعاف. جرّبه مجاناً ١٤ يوم وشوف بنفسك.",
|
||||
"en": "I understand — but 59 SAR is less than weekly cappuccinos. One lost deal due to poor follow-up costs much more. Try it free for 14 days.",
|
||||
},
|
||||
"already_have_crm": {
|
||||
"ar": "ممتاز! وش تستخدم حالياً؟ كثير من عملاءنا انتقلوا من أنظمة أجنبية لأن Dealix مصمم للسوق السعودي — عربي أولاً، واتساب مدمج، PDPL جاهز.",
|
||||
"en": "Great! What are you using? Many clients switched because Dealix is built for Saudi — Arabic-first, WhatsApp native, PDPL ready.",
|
||||
},
|
||||
"need_to_think": {
|
||||
"ar": "أكيد، خذ وقتك. بس حبيت أذكرك إن التجربة مجانية ١٤ يوم بدون بطاقة — تقدر تجرب وتقرر بعدها.",
|
||||
"en": "Sure, take your time. Just remember — 14-day free trial, no credit card needed.",
|
||||
},
|
||||
"too_complex": {
|
||||
"ar": "بالعكس! Dealix مصمم ليكون بسيط جداً — أغلب العملاء يبدون يستخدمونه بأقل من ٥ دقائق. وعندنا دعم بالعربي يساعدك.",
|
||||
"en": "Actually the opposite! Most clients start using it in under 5 minutes. And we have Arabic support.",
|
||||
},
|
||||
"small_team": {
|
||||
"ar": "حتى لو شخص واحد! باقة المبتدئ ٥٩ ر.س تكفي. والنظام يساعدك تتابع عملاءك بدون ما تحتاج فريق كبير.",
|
||||
"en": "Even for one person! Starter plan at 59 SAR is enough. The system helps you follow up without needing a big team.",
|
||||
},
|
||||
"no_budget": {
|
||||
"ar": "أفهم. التجربة مجانية ١٤ يوم — جربها وشوف كم صفقة تقدر تكسب. الاستثمار يرجع لك أضعاف.",
|
||||
"en": "I understand. 14-day free trial — try it and see how many deals you can win. The ROI speaks for itself.",
|
||||
},
|
||||
"competitor_better": {
|
||||
"ar": "كل نظام له مميزاته. لكن Dealix الوحيد المصمم للسعودية: عربي أولاً، واتساب مدمج، AI يفهم سعودي. تبي أوريك المقارنة؟",
|
||||
"en": "Every system has its strengths. But Dealix is the only one built for Saudi: Arabic-first, WhatsApp native, Saudi-aware AI. Want to see the comparison?",
|
||||
},
|
||||
"not_now": {
|
||||
"ar": "تمام! أقدر أرسل لك ملخص سريع عن Dealix وتشوفه لما يناسبك. وش إيميلك؟",
|
||||
"en": "No problem! I can send you a quick summary to review when it suits you. What's your email?",
|
||||
},
|
||||
}
|
||||
|
||||
COMPETITOR_CARDS = {
|
||||
"zoho": {
|
||||
"name": "Zoho CRM",
|
||||
"we_win": [
|
||||
"عربي أولاً (مو ترجمة)", "واتساب مدمج (مو إضافة)",
|
||||
"AI يفهم اللهجة السعودية", "PDPL مدمج بالنظام",
|
||||
"صفقات استراتيجية (لا يوجد عندهم)", "دعم سعودي مباشر",
|
||||
],
|
||||
"they_win": ["نظام أكبر وأقدم", "تكاملات أكثر", "سيرفرات سعودية"],
|
||||
"response_ar": "Zoho نظام ممتاز ومعروف. لكن الفرق إن Dealix مبني من الأساس للسوق السعودي — مو ترجمة لنظام أجنبي. واتساب عندنا مدمج، الذكاء الاصطناعي يفهم عربي، وPDPL جاهز. وبسعر مقارب.",
|
||||
},
|
||||
"salesforce": {
|
||||
"name": "Salesforce",
|
||||
"we_win": [
|
||||
"عربي بالكامل", "سعر أقل ١٠ مرات", "واتساب مدمج",
|
||||
"بسيط وسريع (مو ٦ أشهر تطبيق)", "PDPL جاهز",
|
||||
],
|
||||
"they_win": ["أكبر نظام CRM بالعالم", "آلاف التكاملات", "enterprise-grade"],
|
||||
"response_ar": "Salesforce نظام عملاق — لكن يحتاج ٦ أشهر تطبيق ومئات الآلاف. Dealix يشتغل بأقل من ٥ دقائق، عربي بالكامل، وبسعر يبدأ من ٥٩ ر.س. للشركات السعودية الصغيرة والمتوسطة، Dealix الخيار الأذكى.",
|
||||
},
|
||||
"hubspot": {
|
||||
"name": "HubSpot",
|
||||
"we_win": [
|
||||
"عربي أولاً", "واتساب مدمج", "AI عربي",
|
||||
"سعر أقل بكثير", "PDPL مدمج", "صفقات استراتيجية",
|
||||
],
|
||||
"they_win": ["marketing hub قوي", "content management", "brand أكبر"],
|
||||
"response_ar": "HubSpot ممتاز للتسويق الرقمي. لكن للمبيعات في السوق السعودي، Dealix أقوى: واتساب مدمج، AI يفهم عربي، وPDPL جاهز. وبسعر أقل بكثير.",
|
||||
},
|
||||
}
|
||||
|
||||
FAQ = [
|
||||
{"q_ar": "كم سعر Dealix؟", "a_ar": "يبدأ من ٥٩ ر.س/شهر. الاحترافي ١٤٩ ر.س، المؤسسي ٢٢٥ ر.س. وفيه تجربة مجانية ١٤ يوم."},
|
||||
{"q_ar": "هل يدعم الواتساب؟", "a_ar": "نعم! واتساب مدمج بالنظام — ترسل وتستقبل وتتابع كل المحادثات من مكان واحد."},
|
||||
{"q_ar": "هل يدعم العربي؟", "a_ar": "نعم! Dealix مبني عربي أولاً — الواجهة والتقارير والذكاء الاصطناعي كلها بالعربي."},
|
||||
{"q_ar": "هل هو آمن؟", "a_ar": "نعم. متوافق مع نظام حماية البيانات PDPL، تشفير SSL، وسيرفرات سعودية."},
|
||||
{"q_ar": "هل فيه تجربة مجانية؟", "a_ar": "نعم! ١٤ يوم تجربة مجانية كاملة — بدون بطاقة ائتمانية."},
|
||||
{"q_ar": "كيف أبدأ؟", "a_ar": "ادخل dealix.sa واضغط 'ابدأ مجاناً'. التسجيل يأخذ أقل من دقيقة."},
|
||||
{"q_ar": "هل يناسب شركتي الصغيرة؟", "a_ar": "أكيد! باقة المبتدئ ٥٩ ر.س مصممة للشركات الصغيرة. حتى لو شخص واحد."},
|
||||
{"q_ar": "هل يدعم الإنجليزي بعد؟", "a_ar": "نعم! تقدر تبدل بين العربي والإنجليزي بضغطة زر."},
|
||||
{"q_ar": "كيف أتواصل مع الدعم؟", "a_ar": "واتساب أو إيميل support@dealix.sa — نرد خلال ٤ ساعات عمل."},
|
||||
{"q_ar": "هل فيه تطبيق جوال؟", "a_ar": "الموقع متجاوب ويشتغل بشكل ممتاز على الجوال. تطبيق مخصص قريباً إن شاء الله."},
|
||||
]
|
||||
|
||||
MARKETER_FAQ = [
|
||||
{"q_ar": "كيف أسجل كمسوّق؟", "a_ar": "ادخل dealix.sa/marketers واضغط 'سجّل كمسوّق'. التسجيل مجاني ويتفعل فوراً."},
|
||||
{"q_ar": "كم العمولة؟", "a_ar": "٢٠٪ من اشتراك كل عميل (٣٠٠ ر.س/شهر) مستمرة لمدة ٦ أشهر. قادة الفرق يحصلون على override ٧٪ لمدة ١٢ شهر."},
|
||||
{"q_ar": "متى تنزل العمولة؟", "a_ar": "كل يوم أحد تتحول العمولات لحسابك البنكي أو STC Pay."},
|
||||
{"q_ar": "كم مدة العمولة المستمرة؟", "a_ar": "المسوّقين: ٦ أشهر من تاريخ اشتراك العميل. مدراء التسويق: ١٢ شهر override من فريقهم."},
|
||||
{"q_ar": "كيف أتابع أدائي؟", "a_ar": "من لوحة التحكم تشوف كل شي: عملاء، عمولات، مستواك، وروابط التتبع."},
|
||||
{"q_ar": "هل فيه حد أقصى للعمولة؟", "a_ar": "لا! ما فيه حد — كل ما زاد عدد العملاء زادت عمولتك."},
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_pricing_text(cls, language: str = "ar") -> str:
|
||||
plan = cls.PRICING["all_in_one"]
|
||||
if language == "ar":
|
||||
features = "\n".join(f" ✅ {f}" for f in plan["features_ar"][:6])
|
||||
return (
|
||||
f"🔵 *{plan['name_ar']}*\n\n"
|
||||
f"💰 {plan['price_monthly']} ر.س/شهر\n"
|
||||
f"💰 {plan['price_yearly']} ر.س/سنة (وفّر شهرين)\n"
|
||||
f"👥 {plan['users_included']} مستخدم + {plan['extra_user_price']} ر.س/إضافي\n\n"
|
||||
f"كل المميزات مفتوحة:\n{features}\n\n"
|
||||
f"🎁 ٧ أيام تجربة مجانية — بدون بطاقة\n"
|
||||
f"🔒 استرداد كامل خلال ٣٠ يوم"
|
||||
)
|
||||
features = "\n".join(f" ✅ {f}" for f in [
|
||||
"7 AI brains", "15 deal types", "Arabic AI negotiator",
|
||||
"PDPL compliance", "Visual pipeline", "20 users included",
|
||||
])
|
||||
return (
|
||||
f"🔵 *{plan['name_en']}*\n\n"
|
||||
f"💰 {plan['price_monthly']} SAR/mo\n"
|
||||
f"💰 {plan['price_yearly']} SAR/yr (save 2 months)\n\n"
|
||||
f"Everything included:\n{features}\n\n"
|
||||
f"🎁 7-day free trial — no credit card\n"
|
||||
f"🔒 30-day money-back guarantee"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def search_faq(cls, query: str) -> Optional[str]:
|
||||
query_lower = query.lower()
|
||||
for faq in cls.FAQ:
|
||||
if any(word in faq["q_ar"] for word in query_lower.split() if len(word) > 2):
|
||||
return faq["a_ar"]
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_competitor_response(cls, competitor: str) -> Optional[str]:
|
||||
card = cls.COMPETITOR_CARDS.get(competitor.lower())
|
||||
return card["response_ar"] if card else None
|
||||
|
||||
@classmethod
|
||||
def get_objection_response(cls, objection_type: str, language: str = "ar") -> Optional[str]:
|
||||
obj = cls.OBJECTION_RESPONSES.get(objection_type)
|
||||
return obj[language] if obj else None
|
||||
@ -0,0 +1,217 @@
|
||||
# Dealix — Investor Pitch Deck
|
||||
# ديلكس — عرض المستثمرين
|
||||
|
||||
---
|
||||
|
||||
## Slide 1: Cover
|
||||
|
||||
# Dealix | ديلكس
|
||||
### نظام المبيعات الذكي للسعودية
|
||||
### The Smart Sales System for Saudi Arabia
|
||||
|
||||
**AI-Powered Revenue + Partnership + Strategic Deal OS**
|
||||
|
||||
---
|
||||
|
||||
## Slide 2: Problem | المشكلة
|
||||
|
||||
### الشركات السعودية تخسر ملايين بسبب:
|
||||
|
||||
| المشكلة | الأثر |
|
||||
|---------|-------|
|
||||
| **٧٠٪ من العملاء المحتملين يضيعون** | بسبب عدم المتابعة |
|
||||
| **فوضى الواتساب** | رسائل ضايعة، ما تعرف مين رد |
|
||||
| **لا يوجد CRM عربي ذكي** | الأنظمة الأجنبية مو مصممة للسوق السعودي |
|
||||
| **فرص شراكات ضائعة** | لا يوجد نظام يكتشف ويفاوض الصفقات تلقائياً |
|
||||
| **غرامات PDPL** | حتى ٥ مليون ر.س لكل مخالفة |
|
||||
|
||||
**السوق يحتاج نظام مبني للسعودية، عربي أولاً، ذكي فعلاً.**
|
||||
|
||||
---
|
||||
|
||||
## Slide 3: Solution | الحل
|
||||
|
||||
### Dealix = 4 طبقات متكاملة
|
||||
|
||||
```
|
||||
Layer 3: Strategic Growth OS
|
||||
→ رصد استحواذات، خريطة شركاء، محاكاة ROI
|
||||
|
||||
Layer 2: Deal Exchange OS
|
||||
→ مطابقة شركاء، تبادل خدمات، غرف صفقات
|
||||
|
||||
Layer 1: Sales OS
|
||||
→ ليدات، تواصل واتساب ذكي، عروض أسعار، pipeline
|
||||
|
||||
Layer 0: Core Platform
|
||||
→ Company Twin، حوكمة، PDPL، ذاكرة تجارية
|
||||
```
|
||||
|
||||
**أول منصة سعودية تجمع بين المبيعات والشراكات والنمو الاستراتيجي.**
|
||||
|
||||
---
|
||||
|
||||
## Slide 4: Market | السوق
|
||||
|
||||
### سوق CRM السعودي
|
||||
|
||||
| المقياس | الرقم |
|
||||
|---------|-------|
|
||||
| **حجم السوق ٢٠٢٤** | $652M |
|
||||
| **المتوقع ٢٠٣٣** | $1.46B |
|
||||
| **معدل النمو** | 9.4% CAGR |
|
||||
| **سوق AI السعودي ٢٠٢٦** | $680M |
|
||||
| **المتوقع ٢٠٣١** | $2.8B (32.9% CAGR) |
|
||||
| **عدد الشركات المسجلة** | 1.2M+ |
|
||||
| **استخدام WhatsApp** | 85%+ (30M+ مستخدم) |
|
||||
|
||||
### رؤية 2030 تدفع التحول الرقمي
|
||||
- استثمار Salesforce $500M في الرياض
|
||||
- AWS + Humain: 150,000 AI accelerator في الرياض
|
||||
- SDAIA تقود حوكمة الذكاء الاصطناعي
|
||||
|
||||
---
|
||||
|
||||
## Slide 5: Product | المنتج
|
||||
|
||||
### ميزات رئيسية
|
||||
|
||||
| الميزة | الوصف |
|
||||
|--------|-------|
|
||||
| 🤖 **AI عربي** | NLP يفهم اللهجة السعودية + تقييم ذكي للعملاء |
|
||||
| 📱 **واتساب ذكي** | بوت يبيع ويدعم ويتفاوض بالعربي |
|
||||
| 🔄 **صفقات استراتيجية** | مطابقة شركاء + تبادل خدمات + مفاوض AI |
|
||||
| 📊 **Pipeline بصري** | Kanban مع drag-and-drop |
|
||||
| 💰 **عروض أسعار** | CPQ مع ضريبة القيمة المضافة تلقائياً |
|
||||
| 🛡️ **PDPL مدمج** | موافقات + حقوق بيانات + audit trail |
|
||||
| 📈 **تنبؤات مبيعات** | AI forecasting بالعربي |
|
||||
| 🌍 **ثنائي اللغة** | عربي/إنجليزي بتبديل فوري |
|
||||
|
||||
---
|
||||
|
||||
## Slide 6: Technology | التقنية
|
||||
|
||||
### البنية التقنية
|
||||
|
||||
- **Backend**: FastAPI + Python 3.12 (async)
|
||||
- **Frontend**: Next.js 15 + React 19 + TypeScript
|
||||
- **Database**: PostgreSQL 16 + Redis + pgvector
|
||||
- **AI**: Groq + OpenAI + Arabic NLP (camel-tools)
|
||||
- **Orchestration**: Hermes + OpenClaw 2026.4.11
|
||||
- **Channels**: WhatsApp Business API + Email + LinkedIn
|
||||
- **Infrastructure**: Docker + Nginx + Celery
|
||||
|
||||
### AI Stack
|
||||
- Arabic NLP with Saudi dialect detection
|
||||
- AI Lead Scoring (0-100, 4 dimensions)
|
||||
- Conversation Intelligence (Arabic dialogue analysis)
|
||||
- Autonomous Sales Agent (WhatsApp qualification bot)
|
||||
- Strategic Deal Negotiator (multi-round Arabic negotiation)
|
||||
|
||||
---
|
||||
|
||||
## Slide 7: Business Model | نموذج العمل
|
||||
|
||||
### SaaS Subscription
|
||||
|
||||
| الباقة | السعر/شهر | المستهدف |
|
||||
|--------|-----------|----------|
|
||||
| **Starter** | 59 ر.س | شركات صغيرة (1-3 مستخدمين) |
|
||||
| **Professional** | 149 ر.س | شركات متوسطة (1-10 مستخدمين) |
|
||||
| **Enterprise** | 225 ر.س | شركات كبيرة (لا محدود) |
|
||||
|
||||
### مصادر إيراد إضافية
|
||||
- **Success Fees**: 1-3% من قيمة الصفقات المكتملة عبر Deal Exchange
|
||||
- **Marketplace**: عمولة على شراكات ناجحة
|
||||
- **API Access**: للمطورين والتكاملات
|
||||
- **Premium Support**: دعم مخصص للمؤسسات
|
||||
|
||||
---
|
||||
|
||||
## Slide 8: Go-to-Market | خطة الذهاب للسوق
|
||||
|
||||
### Phase A: Real Estate (الآن)
|
||||
- 20,000+ وكالة عقارية في الرياض
|
||||
- متوسط قيمة الصفقة: 500K-5M ر.س
|
||||
- WhatsApp = قناة التواصل الأساسية
|
||||
|
||||
### Phase B: Healthcare (شهر 3-6)
|
||||
- عيادات وأسنان ومراكز طبية
|
||||
- حجوزات + متابعة مرضى
|
||||
|
||||
### Phase C: Contracting (شهر 6-12)
|
||||
- مقاولات وخدمات
|
||||
- عروض أسعار + متابعة مشاريع
|
||||
|
||||
### استراتيجية الاكتساب
|
||||
- Cold outreach (email-first)
|
||||
- Content marketing (Arabic SEO)
|
||||
- WhatsApp viral (referral program)
|
||||
- LinkedIn thought leadership
|
||||
- Saudi Chamber partnerships
|
||||
|
||||
---
|
||||
|
||||
## Slide 9: Competition | المنافسة
|
||||
|
||||
### لماذا Dealix يتفوق
|
||||
|
||||
| الميزة | Dealix | Salesforce | Zoho | HubSpot |
|
||||
|--------|--------|------------|------|---------|
|
||||
| عربي أولاً | ✅ | ❌ | ⚠️ | ❌ |
|
||||
| واتساب مدمج | ✅ | ❌ | ⚠️ | ❌ |
|
||||
| AI عربي | ✅ | ❌ | ❌ | ❌ |
|
||||
| PDPL مدمج | ✅ | ❌ | ❌ | ❌ |
|
||||
| صفقات استراتيجية | ✅ | ❌ | ❌ | ❌ |
|
||||
| السعر/مستخدم/شهر | 59 ر.س | 656 ر.س | 52 ر.س | 562 ر.س |
|
||||
| سيرفر سعودي | ✅ | ❌ | ✅ | ❌ |
|
||||
|
||||
**لا يوجد منافس يجمع: AI عربي + WhatsApp أساسي + PDPL + صفقات استراتيجية**
|
||||
|
||||
---
|
||||
|
||||
## Slide 10: Revenue Projections | التوقعات المالية
|
||||
|
||||
| الفترة | عملاء | MRR | ARR |
|
||||
|--------|-------|-----|-----|
|
||||
| شهر 1 | 5 | 745 ر.س | - |
|
||||
| شهر 3 | 25 | 3,725 ر.س | - |
|
||||
| شهر 6 | 100 | 14,900 ر.س | 178K ر.س |
|
||||
| سنة 1 | 300 | 44,700 ر.س | 536K ر.س |
|
||||
| سنة 2 | 1,000 | 149,000 ر.س | 1.79M ر.س |
|
||||
| سنة 3 | 5,000 | 745,000 ر.س | 8.94M ر.س |
|
||||
|
||||
---
|
||||
|
||||
## Slide 11: Ask | الطلب
|
||||
|
||||
### نبحث عن: جولة Pre-Seed / Seed
|
||||
|
||||
| البند | المبلغ |
|
||||
|-------|--------|
|
||||
| **المطلوب** | 2-5M ر.س |
|
||||
| **التقييم** | 15-25M ر.س (pre-money) |
|
||||
| **الاستخدام** | التطوير (40%) + التسويق (30%) + التشغيل (20%) + احتياطي (10%) |
|
||||
|
||||
### المعالم (12 شهر)
|
||||
- 1,000 عميل مدفوع
|
||||
- 1.79M ر.س ARR
|
||||
- 3 قطاعات مغطاة
|
||||
- Deal Exchange OS مُطلق
|
||||
- فريق 8-12 شخص
|
||||
|
||||
---
|
||||
|
||||
## Slide 12: Contact | تواصل
|
||||
|
||||
### Dealix | ديلكس
|
||||
- **الموقع**: dealix.sa
|
||||
- **الإيميل**: invest@dealix.sa
|
||||
- **الواتساب**: +966 5X XXX XXXX
|
||||
- **LinkedIn**: linkedin.com/company/dealix-sa
|
||||
|
||||
> "نبني أول نظام تجاري ذكي مصمم للسعودية"
|
||||
|
||||
---
|
||||
|
||||
*صنع بحب في السعودية 🇸🇦*
|
||||
10
salesflow-saas/frontend/public/robots.txt
Normal file
10
salesflow-saas/frontend/public/robots.txt
Normal file
@ -0,0 +1,10 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /api/
|
||||
Disallow: /dashboard/
|
||||
Disallow: /settings/
|
||||
|
||||
Sitemap: https://dealix.sa/sitemap.xml
|
||||
|
||||
# Dealix - نظام المبيعات الذكي للسعودية
|
||||
# https://dealix.sa
|
||||
9
salesflow-saas/frontend/public/sitemap.xml
Normal file
9
salesflow-saas/frontend/public/sitemap.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url><loc>https://dealix.sa/</loc><priority>1.0</priority><changefreq>weekly</changefreq></url>
|
||||
<url><loc>https://dealix.sa/login</loc><priority>0.8</priority></url>
|
||||
<url><loc>https://dealix.sa/register</loc><priority>0.8</priority></url>
|
||||
<url><loc>https://dealix.sa/marketers</loc><priority>0.7</priority></url>
|
||||
<url><loc>https://dealix.sa/terms</loc><priority>0.3</priority></url>
|
||||
<url><loc>https://dealix.sa/privacy</loc><priority>0.3</priority></url>
|
||||
</urlset>
|
||||
@ -44,6 +44,9 @@ import { IntelligenceDashboard } from "../../components/dealix/intelligence-dash
|
||||
import { LeadGeneratorView } from "../../components/dealix/lead-generator-view";
|
||||
import { SalesOsView } from "../../components/dealix/sales-os-view";
|
||||
import { FullOpsView } from "../../components/dealix/full-ops-view";
|
||||
import { PipelineKanban } from "../../components/dealix/pipeline-kanban";
|
||||
import { UnifiedInbox } from "../../components/dealix/unified-inbox";
|
||||
import { LeadScoreCard } from "../../components/dealix/lead-score-card";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const auth = useRequireAuth();
|
||||
@ -78,6 +81,9 @@ export default function DashboardPage() {
|
||||
{ id: "scripts", label: "سكربتات المبيعات", icon: Phone },
|
||||
{ id: "agreements", label: "الاتفاقيات واHR", icon: FileSignature },
|
||||
{ id: "guarantee", label: "الضمان الذهبي", icon: ShieldCheck },
|
||||
{ id: "pipeline", label: "مسار الصفقات", icon: Target },
|
||||
{ id: "inbox", label: "صندوق الوارد الموحد", icon: Bell },
|
||||
{ id: "scoring", label: "تقييم العملاء AI", icon: Zap },
|
||||
{ id: "onboarding", label: "تأهيل المسوق", icon: BookOpen },
|
||||
];
|
||||
|
||||
@ -117,6 +123,12 @@ export default function DashboardPage() {
|
||||
return <AgreementsView />;
|
||||
case "guarantee":
|
||||
return <GuaranteesView />;
|
||||
case "pipeline":
|
||||
return <PipelineKanban />;
|
||||
case "inbox":
|
||||
return <UnifiedInbox />;
|
||||
case "scoring":
|
||||
return <LeadScoreCard score={82} breakdown={{ engagement: 24, profile: 20, behavior: 22, intent: 16 }} recommendation="عميل واعد — تابع خلال ٢٤ ساعة" />;
|
||||
case "onboarding":
|
||||
return <OnboardingView />;
|
||||
default:
|
||||
|
||||
81
salesflow-saas/frontend/src/app/error.tsx
Normal file
81
salesflow-saas/frontend/src/app/error.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface ErrorPageProps {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export default function ErrorPage({ error, reset }: ErrorPageProps) {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
useEffect(() => {
|
||||
console.error('[Dealix Error]', error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center px-4 text-center">
|
||||
<div className="absolute top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[400px] h-[400px] bg-destructive/10 rounded-full blur-[120px] pointer-events-none" />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 200, damping: 20 }}
|
||||
className="mb-6"
|
||||
>
|
||||
<div className="w-20 h-20 mx-auto rounded-full bg-destructive/20 border border-destructive/30 flex items-center justify-center mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-10 h-10 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-white mb-2">
|
||||
حدث خطأ غير متوقع
|
||||
</h1>
|
||||
<p className="text-slate-400 text-lg">
|
||||
An unexpected error occurred
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.button
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
onClick={reset}
|
||||
className="px-8 py-3 rounded-xl bg-primary/20 hover:bg-primary/30 text-primary border border-primary/30 hover:border-primary/50 font-semibold transition-all duration-300 backdrop-blur-sm mb-4"
|
||||
>
|
||||
Try Again
|
||||
</motion.button>
|
||||
|
||||
{isDev && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="mt-6 w-full max-w-lg"
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowDetails((v) => !v)}
|
||||
className="text-sm text-slate-500 hover:text-slate-300 transition-colors underline underline-offset-4"
|
||||
>
|
||||
{showDetails ? 'Hide Details' : 'Show Error Details'}
|
||||
</button>
|
||||
{showDetails && (
|
||||
<motion.pre
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
className="mt-3 p-4 rounded-xl bg-white/5 border border-white/10 text-start text-xs text-red-400 overflow-auto max-h-48 backdrop-blur-xl"
|
||||
>
|
||||
{error.message}
|
||||
{error.stack && `\n\n${error.stack}`}
|
||||
{error.digest && `\n\nDigest: ${error.digest}`}
|
||||
</motion.pre>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
salesflow-saas/frontend/src/app/not-found.tsx
Normal file
52
salesflow-saas/frontend/src/app/not-found.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center px-4 text-center">
|
||||
{/* Decorative glow */}
|
||||
<div className="absolute top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-primary/10 rounded-full blur-[120px] pointer-events-none" />
|
||||
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 200, damping: 20 }}
|
||||
className="text-[12rem] sm:text-[16rem] font-bold leading-none bg-gradient-to-b from-white via-white/60 to-white/10 bg-clip-text text-transparent select-none"
|
||||
>
|
||||
404
|
||||
</motion.h1>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.5 }}
|
||||
className="space-y-3 mb-10"
|
||||
>
|
||||
<h2 className="text-2xl sm:text-3xl font-semibold text-white">
|
||||
الصفحة غير موجودة
|
||||
</h2>
|
||||
<p className="text-slate-400 text-lg">
|
||||
Page Not Found
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4, duration: 0.5 }}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 px-8 py-3 rounded-xl bg-primary/20 hover:bg-primary/30 text-primary border border-primary/30 hover:border-primary/50 font-semibold transition-all duration-300 backdrop-blur-sm"
|
||||
>
|
||||
<span>العودة للرئيسية</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 rtl:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { DealixPublicSite } from "../components/dealix/dealix-public-site";
|
||||
import { PremiumLanding } from "../components/dealix/premium-landing";
|
||||
|
||||
export default function HomePage() {
|
||||
return <DealixPublicSite />;
|
||||
return <PremiumLanding />;
|
||||
}
|
||||
|
||||
72
salesflow-saas/frontend/src/app/privacy/page.tsx
Normal file
72
salesflow-saas/frontend/src/app/privacy/page.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const LAST_UPDATED = '2026-03-01';
|
||||
|
||||
const sections = [
|
||||
{ title: 'نظرة عامة', body: 'تلتزم Dealix بحماية خصوصية مستخدميها وفقاً لنظام حماية البيانات الشخصية (PDPL) الصادر بالمرسوم الملكي رقم (م/19) بتاريخ 1443/2/9هـ. توضح هذه السياسة كيفية جمع واستخدام ومعالجة بياناتك الشخصية.', pdpl: false },
|
||||
{ title: 'البيانات التي نجمعها', body: 'نجمع بيانات التسجيل (الاسم، البريد الإلكتروني، رقم الجوال)، بيانات الشركة (الاسم، السجل التجاري، المجال)، بيانات الاستخدام (سجلات الدخول، النشاط)، وبيانات الاتصال (رسائل واتساب، بريد إلكتروني) بموافقة صريحة.', pdpl: false },
|
||||
{ title: 'الأساس القانوني للمعالجة (PDPL)', body: 'نعالج بياناتك بناءً على: (أ) موافقتك الصريحة، (ب) تنفيذ العقد المبرم معك، (ج) الالتزام بمتطلبات نظامية. يحق لك سحب موافقتك في أي وقت دون المساس بمشروعية المعالجة التي تمت قبل السحب.', pdpl: true },
|
||||
{ title: 'حقوق صاحب البيانات (PDPL)', body: 'وفقاً لنظام PDPL، يحق لك: الوصول إلى بياناتك الشخصية، تصحيح البيانات غير الدقيقة، طلب حذف بياناتك، الحصول على نسخة من بياناتك بصيغة قابلة للقراءة، الاعتراض على المعالجة، وتقييد معالجة بياناتك.', pdpl: true },
|
||||
{ title: 'نقل البيانات خارج المملكة (PDPL)', body: 'لا يتم نقل بياناتك الشخصية خارج المملكة العربية السعودية إلا وفقاً لمتطلبات المادة 29 من نظام PDPL وبعد التأكد من توفر مستوى حماية كافٍ في الدولة المستقبلة أو الحصول على موافقتك الصريحة.', pdpl: true },
|
||||
{ title: 'ملفات تعريف الارتباط', body: 'نستخدم ملفات تعريف الارتباط (Cookies) لتحسين تجربتك. تشمل: ملفات ضرورية لتشغيل المنصة، ملفات تحليلية لفهم الاستخدام، وملفات تفضيلات لحفظ إعداداتك. يمكنك التحكم في إعدادات الملفات من خلال المتصفح.', pdpl: false },
|
||||
{ title: 'الاحتفاظ بالبيانات (PDPL)', body: 'نحتفظ ببياناتك طوال مدة اشتراكك وفترة إضافية لا تتجاوز 12 شهراً بعد إلغاء الحساب للأغراض القانونية. يتم حذف البيانات تلقائياً بعد انتهاء فترة الاحتفاظ ما لم يكن هناك التزام نظامي يقتضي خلاف ذلك.', pdpl: true },
|
||||
{ title: 'أمن البيانات', body: 'نتخذ إجراءات أمنية تقنية وتنظيمية لحماية بياناتك تشمل: التشفير أثناء النقل والتخزين (TLS 1.3, AES-256)، التحكم في الوصول، المراقبة المستمرة، والنسخ الاحتياطي المنتظم.', pdpl: false },
|
||||
{ title: 'الإبلاغ عن الانتهاكات (PDPL)', body: 'في حال حدوث أي انتهاك لبياناتك الشخصية، سنقوم بإخطارك والجهة المختصة خلال 72 ساعة وفقاً لمتطلبات نظام PDPL. سنوضح طبيعة الانتهاك والإجراءات المتخذة والتوصيات لتقليل الأثر.', pdpl: true },
|
||||
];
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<div className="min-h-screen py-16 px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="max-w-3xl mx-auto"
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 text-sm text-slate-400 hover:text-white transition-colors mb-8"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4 rtl:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
رجوع
|
||||
</Link>
|
||||
|
||||
<h1 className="text-3xl sm:text-4xl font-bold text-white mb-2">سياسة الخصوصية</h1>
|
||||
<p className="text-sm text-slate-500 mb-10">آخر تحديث: {LAST_UPDATED}</p>
|
||||
|
||||
<div className="space-y-8">
|
||||
{sections.map((s, i) => (
|
||||
<section key={i} className={s.pdpl ? 'p-4 rounded-xl border border-primary/20 bg-primary/5' : ''}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h2 className="text-lg font-semibold text-white">{s.title}</h2>
|
||||
{s.pdpl && (
|
||||
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-primary/20 text-primary border border-primary/30">
|
||||
PDPL
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-slate-300 leading-relaxed text-sm">{s.body}</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* DPO Contact */}
|
||||
<div className="mt-12 p-6 rounded-xl bg-white/5 border border-white/10 backdrop-blur-xl">
|
||||
<h2 className="text-lg font-semibold text-white mb-2">التواصل مع مسؤول حماية البيانات (DPO)</h2>
|
||||
<p className="text-slate-300 text-sm leading-relaxed mb-4">
|
||||
لأي استفسارات تتعلق بخصوصية بياناتك أو لممارسة حقوقك وفقاً لنظام PDPL، يمكنك التواصل مع مسؤول حماية البيانات:
|
||||
</p>
|
||||
<div className="space-y-1 text-sm text-slate-400">
|
||||
<p>البريد الإلكتروني: <span className="text-primary">dpo@dealix.sa</span></p>
|
||||
<p>الهاتف: <span className="text-primary" dir="ltr">+966 11 XXX XXXX</span></p>
|
||||
<p>العنوان: الرياض، المملكة العربية السعودية</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
504
salesflow-saas/frontend/src/app/settings/page.tsx
Normal file
504
salesflow-saas/frontend/src/app/settings/page.tsx
Normal file
@ -0,0 +1,504 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type TabId = 'account' | 'company' | 'team' | 'billing' | 'integrations' | 'notifications';
|
||||
|
||||
interface Tab {
|
||||
id: TabId;
|
||||
labelAr: string;
|
||||
labelEn: string;
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
interface TeamMember {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: 'owner' | 'manager' | 'agent';
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Static data */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{ id: 'account', labelAr: 'الحساب', labelEn: 'Account', icon: <UserIcon /> },
|
||||
{ id: 'company', labelAr: 'الشركة', labelEn: 'Company', icon: <BuildingIcon /> },
|
||||
{ id: 'team', labelAr: 'الفريق', labelEn: 'Team', icon: <UsersIcon /> },
|
||||
{ id: 'billing', labelAr: 'الفوترة', labelEn: 'Billing', icon: <CreditCardIcon /> },
|
||||
{ id: 'integrations', labelAr: 'التكاملات', labelEn: 'Integrations', icon: <PuzzleIcon /> },
|
||||
{ id: 'notifications', labelAr: 'الإشعارات', labelEn: 'Notifications', icon: <BellIcon /> },
|
||||
];
|
||||
|
||||
const mockTeam: TeamMember[] = [
|
||||
{ id: '1', name: 'أحمد الغامدي', email: 'ahmed@dealix.sa', role: 'owner' },
|
||||
{ id: '2', name: 'سارة العتيبي', email: 'sara@dealix.sa', role: 'manager' },
|
||||
{ id: '3', name: 'خالد المالكي', email: 'khaled@dealix.sa', role: 'agent' },
|
||||
];
|
||||
|
||||
const roleLabels: Record<string, { ar: string; en: string; color: string }> = {
|
||||
owner: { ar: 'مالك', en: 'Owner', color: 'text-amber-400 bg-amber-400/10 border-amber-400/30' },
|
||||
manager: { ar: 'مدير', en: 'Manager', color: 'text-primary bg-primary/10 border-primary/30' },
|
||||
agent: { ar: 'وكيل', en: 'Agent', color: 'text-slate-300 bg-white/5 border-white/10' },
|
||||
};
|
||||
|
||||
const notificationEvents = [
|
||||
{ id: 'new_lead', labelAr: 'عميل محتمل جديد', labelEn: 'New Lead' },
|
||||
{ id: 'deal_won', labelAr: 'صفقة مكسوبة', labelEn: 'Deal Won' },
|
||||
{ id: 'deal_lost', labelAr: 'صفقة خاسرة', labelEn: 'Deal Lost' },
|
||||
{ id: 'message', labelAr: 'رسالة جديدة', labelEn: 'New Message' },
|
||||
{ id: 'task_due', labelAr: 'مهمة مستحقة', labelEn: 'Task Due' },
|
||||
{ id: 'approval', labelAr: 'طلب موافقة', labelEn: 'Approval Request' },
|
||||
];
|
||||
|
||||
const channels = ['email', 'whatsapp', 'sms', 'push'] as const;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Page */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { isArabic } = useI18n();
|
||||
const [activeTab, setActiveTab] = useState<TabId>('account');
|
||||
|
||||
const label = (ar: string, en: string) => (isArabic ? ar : en);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-white mb-8">
|
||||
{label('الإعدادات', 'Settings')}
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
{/* Tab nav -- right side in RTL */}
|
||||
<nav className="lg:w-56 shrink-0 flex lg:flex-col gap-1 overflow-x-auto lg:overflow-x-visible pb-2 lg:pb-0">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium whitespace-nowrap transition-all duration-200
|
||||
${activeTab === tab.id
|
||||
? 'bg-primary/15 text-primary border border-primary/30'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<span className="w-5 h-5 shrink-0">{tab.icon}</span>
|
||||
{label(tab.labelAr, tab.labelEn)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{activeTab === 'account' && <AccountTab label={label} />}
|
||||
{activeTab === 'company' && <CompanyTab label={label} />}
|
||||
{activeTab === 'team' && <TeamTab label={label} />}
|
||||
{activeTab === 'billing' && <BillingTab label={label} />}
|
||||
{activeTab === 'integrations' && <IntegrationsTab label={label} />}
|
||||
{activeTab === 'notifications' && <NotificationsTab label={label} />}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shared */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type L = (ar: string, en: string) => string;
|
||||
|
||||
function Section({ title, children, onSave, label }: { title: string; children: ReactNode; onSave?: () => void; label: L }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-white/5 border border-white/10 backdrop-blur-xl p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-5">{title}</h2>
|
||||
<div className="space-y-4">{children}</div>
|
||||
{onSave && (
|
||||
<div className="mt-6 pt-4 border-t border-white/10 flex justify-end">
|
||||
<button
|
||||
onClick={onSave}
|
||||
className="px-6 py-2 rounded-xl bg-primary/20 hover:bg-primary/30 text-primary border border-primary/30 hover:border-primary/50 text-sm font-semibold transition-all duration-200"
|
||||
>
|
||||
{label('حفظ التغييرات', 'Save Changes')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label: fieldLabel, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-400 mb-1.5">{fieldLabel}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextInput({ placeholder, defaultValue, dir }: { placeholder?: string; defaultValue?: string; dir?: string }) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
dir={dir}
|
||||
className="w-full px-4 py-2.5 rounded-xl bg-white/5 border border-white/10 text-white placeholder-slate-500 text-sm focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/40 transition-all"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectInput({ options, defaultValue }: { options: { value: string; label: string }[]; defaultValue?: string }) {
|
||||
return (
|
||||
<select
|
||||
defaultValue={defaultValue}
|
||||
className="w-full px-4 py-2.5 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/40 transition-all appearance-none"
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value} className="bg-slate-900">{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ defaultChecked = false }: { defaultChecked?: boolean }) {
|
||||
const [on, setOn] = useState(defaultChecked);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={on}
|
||||
onClick={() => setOn(!on)}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors duration-200 ${on ? 'bg-primary' : 'bg-white/10'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 start-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform duration-200 ${on ? 'translate-x-5 rtl:-translate-x-5' : ''}`} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tabs */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function AccountTab({ label }: { label: L }) {
|
||||
return (
|
||||
<Section title={label('معلومات الحساب', 'Account Information')} onSave={() => {}} label={label}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Field label={label('الاسم الكامل', 'Full Name')}>
|
||||
<TextInput defaultValue="أحمد الغامدي" />
|
||||
</Field>
|
||||
<Field label={label('البريد الإلكتروني', 'Email')}>
|
||||
<TextInput defaultValue="ahmed@company.sa" dir="ltr" />
|
||||
</Field>
|
||||
<Field label={label('رقم الجوال', 'Phone')}>
|
||||
<TextInput defaultValue="+966 50 123 4567" dir="ltr" />
|
||||
</Field>
|
||||
<Field label={label('اللغة المفضلة', 'Language')}>
|
||||
<SelectInput
|
||||
defaultValue="ar"
|
||||
options={[
|
||||
{ value: 'ar', label: label('العربية', 'Arabic') },
|
||||
{ value: 'en', label: label('الإنجليزية', 'English') },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={label('المنطقة الزمنية', 'Timezone')}>
|
||||
<SelectInput
|
||||
defaultValue="Asia/Riyadh"
|
||||
options={[
|
||||
{ value: 'Asia/Riyadh', label: '(UTC+3) Riyadh' },
|
||||
{ value: 'Asia/Dubai', label: '(UTC+4) Dubai' },
|
||||
{ value: 'Asia/Kuwait', label: '(UTC+3) Kuwait' },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function CompanyTab({ label }: { label: L }) {
|
||||
return (
|
||||
<Section title={label('معلومات الشركة', 'Company Information')} onSave={() => {}} label={label}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Field label={label('اسم الشركة (عربي)', 'Company Name (Arabic)')}>
|
||||
<TextInput defaultValue="شركة البناء المتقدم" />
|
||||
</Field>
|
||||
<Field label={label('اسم الشركة (إنجليزي)', 'Company Name (English)')}>
|
||||
<TextInput defaultValue="Advanced Construction Co." dir="ltr" />
|
||||
</Field>
|
||||
<Field label={label('المجال', 'Industry')}>
|
||||
<SelectInput
|
||||
defaultValue="construction"
|
||||
options={[
|
||||
{ value: 'real_estate', label: label('عقارات', 'Real Estate') },
|
||||
{ value: 'construction', label: label('مقاولات', 'Construction') },
|
||||
{ value: 'automotive', label: label('سيارات', 'Automotive') },
|
||||
{ value: 'healthcare', label: label('رعاية صحية', 'Healthcare') },
|
||||
{ value: 'technology', label: label('تقنية', 'Technology') },
|
||||
{ value: 'services', label: label('خدمات', 'Services') },
|
||||
{ value: 'other', label: label('أخرى', 'Other') },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={label('رقم السجل التجاري', 'CR Number')}>
|
||||
<TextInput defaultValue="1010XXXXXX" dir="ltr" />
|
||||
</Field>
|
||||
</div>
|
||||
{/* Logo upload placeholder */}
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-slate-400 mb-1.5">
|
||||
{label('شعار الشركة', 'Company Logo')}
|
||||
</label>
|
||||
<div className="flex items-center justify-center w-full h-32 rounded-xl border-2 border-dashed border-white/10 hover:border-primary/30 transition-colors cursor-pointer">
|
||||
<div className="text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-8 h-8 mx-auto text-slate-500 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
<span className="text-xs text-slate-500">{label('اسحب الملف أو اضغط للرفع', 'Drag & drop or click to upload')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamTab({ label }: { label: L }) {
|
||||
return (
|
||||
<>
|
||||
<Section title={label('أعضاء الفريق', 'Team Members')} label={label}>
|
||||
<div className="space-y-3">
|
||||
{mockTeam.map((m) => {
|
||||
const rl = roleLabels[m.role];
|
||||
return (
|
||||
<div key={m.id} className="flex items-center justify-between p-3 rounded-xl bg-white/[0.03] border border-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary text-sm font-bold">
|
||||
{m.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{m.name}</p>
|
||||
<p className="text-xs text-slate-500" dir="ltr">{m.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{m.role === 'owner' ? (
|
||||
<span className={`text-xs font-semibold px-3 py-1 rounded-full border ${rl.color}`}>
|
||||
{label(rl.ar, rl.en)}
|
||||
</span>
|
||||
) : (
|
||||
<select
|
||||
defaultValue={m.role}
|
||||
className={`text-xs font-semibold px-3 py-1 rounded-full border bg-transparent appearance-none cursor-pointer focus:outline-none ${rl.color}`}
|
||||
>
|
||||
<option value="manager" className="bg-slate-900">{label('مدير', 'Manager')}</option>
|
||||
<option value="agent" className="bg-slate-900">{label('وكيل', 'Agent')}</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button className="mt-4 w-full py-2.5 rounded-xl border border-dashed border-white/10 hover:border-primary/30 text-sm text-slate-400 hover:text-primary transition-all">
|
||||
+ {label('دعوة عضو جديد', 'Invite Team Member')}
|
||||
</button>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BillingTab({ label }: { label: L }) {
|
||||
return (
|
||||
<>
|
||||
{/* Current Plan */}
|
||||
<Section title={label('الباقة الحالية', 'Current Plan')} label={label}>
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-gradient-to-bl from-primary/10 via-transparent to-transparent border border-primary/20">
|
||||
<div>
|
||||
<p className="text-lg font-bold text-white">{label('الباقة الاحترافية', 'Professional Plan')}</p>
|
||||
<p className="text-sm text-slate-400">{label('١٤٩ ر.س / شهرياً', 'SAR 149 / month')}</p>
|
||||
</div>
|
||||
<button className="px-5 py-2 rounded-xl bg-primary/20 hover:bg-primary/30 text-primary border border-primary/30 text-sm font-semibold transition-all">
|
||||
{label('ترقية', 'Upgrade')}
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Payment method */}
|
||||
<Section title={label('طريقة الدفع', 'Payment Method')} label={label}>
|
||||
<div className="flex items-center gap-4 p-4 rounded-xl bg-white/[0.03] border border-white/5">
|
||||
<div className="w-12 h-8 rounded bg-white/10 flex items-center justify-center text-xs text-slate-400 font-bold">VISA</div>
|
||||
<div>
|
||||
<p className="text-sm text-white" dir="ltr">**** **** **** 4242</p>
|
||||
<p className="text-xs text-slate-500">{label('تنتهي ١٢/٢٧', 'Expires 12/27')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Invoice history */}
|
||||
<Section title={label('سجل الفواتير', 'Invoice History')} label={label}>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ date: '2026-03-01', amount: '149', status: 'paid' },
|
||||
{ date: '2026-02-01', amount: '149', status: 'paid' },
|
||||
{ date: '2026-01-01', amount: '149', status: 'paid' },
|
||||
].map((inv, i) => (
|
||||
<div key={i} className="flex items-center justify-between py-2 border-b border-white/5 last:border-0">
|
||||
<span className="text-sm text-slate-400" dir="ltr">{inv.date}</span>
|
||||
<span className="text-sm text-white">{inv.amount} {label('ر.س', 'SAR')}</span>
|
||||
<span className="text-xs text-emerald-400">{label('مدفوعة', 'Paid')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegrationsTab({ label }: { label: L }) {
|
||||
const integrations = [
|
||||
{ name: 'WhatsApp', icon: '💬', connected: true, descAr: 'متصل — رقم +966 50 XXX XXXX', descEn: 'Connected — +966 50 XXX XXXX' },
|
||||
{ name: label('البريد SMTP', 'Email SMTP'), icon: '📧', connected: false, descAr: 'غير متصل', descEn: 'Not connected' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section title={label('التكاملات', 'Integrations')} label={label}>
|
||||
<div className="space-y-3">
|
||||
{integrations.map((intg, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-4 rounded-xl bg-white/[0.03] border border-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{intg.icon}</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{intg.name}</p>
|
||||
<p className="text-xs text-slate-500">{label(intg.descAr, intg.descEn)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-xs font-semibold px-3 py-1 rounded-full border ${intg.connected ? 'text-emerald-400 bg-emerald-400/10 border-emerald-400/30' : 'text-slate-400 bg-white/5 border-white/10'}`}>
|
||||
{intg.connected ? label('متصل', 'Connected') : label('غير متصل', 'Disconnected')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* API Key */}
|
||||
<Section title={label('مفتاح API', 'API Key')} label={label}>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value="dlx_live_sk_••••••••••••••••••••"
|
||||
dir="ltr"
|
||||
className="flex-1 px-4 py-2.5 rounded-xl bg-white/5 border border-white/10 text-slate-400 text-sm font-mono"
|
||||
/>
|
||||
<button className="px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-sm text-slate-300 transition-all">
|
||||
{label('نسخ', 'Copy')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
{label('لا تشارك مفتاح API مع أي شخص. يمكنك إعادة توليده من هنا.', 'Never share your API key. You can regenerate it here.')}
|
||||
</p>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationsTab({ label }: { label: L }) {
|
||||
return (
|
||||
<Section title={label('تفضيلات الإشعارات', 'Notification Preferences')} onSave={() => {}} label={label}>
|
||||
{/* Channel headers */}
|
||||
<div className="hidden sm:grid grid-cols-[1fr_repeat(4,_60px)] gap-2 mb-2 text-center">
|
||||
<span />
|
||||
{channels.map((ch) => (
|
||||
<span key={ch} className="text-xs text-slate-500 capitalize">{ch}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{notificationEvents.map((evt) => (
|
||||
<div key={evt.id} className="grid grid-cols-1 sm:grid-cols-[1fr_repeat(4,_60px)] gap-2 items-center p-3 rounded-xl bg-white/[0.02]">
|
||||
<span className="text-sm text-white">{label(evt.labelAr, evt.labelEn)}</span>
|
||||
{channels.map((ch) => (
|
||||
<div key={ch} className="flex items-center justify-center sm:justify-center gap-2">
|
||||
<span className="text-xs text-slate-500 sm:hidden capitalize">{ch}</span>
|
||||
<Toggle defaultChecked={ch === 'email' || ch === 'push'} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Icons */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function UserIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function BuildingIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CreditCardIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.421 48.421 0 01-4.185-.069c-.547-.036-1.058.36-1.058.91v0c0 .381.208.716.432.957.227.246.432.574.432.965 0 1.036-1.007 1.875-2.25 1.875S1.5 10.536 1.5 9.5c0-.39.205-.719.432-.965.224-.241.432-.576.432-.957v0c0-.55-.511-.946-1.058-.91C.766 6.7.166 6.723 0 6.723v0c0 1.036 1.007 1.875 2.25 1.875S4.5 7.76 4.5 6.723" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function BellIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
52
salesflow-saas/frontend/src/app/terms/page.tsx
Normal file
52
salesflow-saas/frontend/src/app/terms/page.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const LAST_UPDATED = '2026-03-01';
|
||||
|
||||
const sections = [
|
||||
{ title: 'المقدمة', body: 'مرحباً بكم في منصة Dealix ("المنصة"). باستخدامك للمنصة، فإنك توافق على الالتزام بهذه الشروط والأحكام. يرجى قراءتها بعناية قبل استخدام خدماتنا.' },
|
||||
{ title: 'تعريفات', body: '"المنصة" تعني تطبيق Dealix وجميع خدماته. "المستخدم" يعني أي شخص أو كيان يستخدم المنصة. "الخدمات" تشمل جميع الميزات والأدوات المتاحة عبر المنصة بما في ذلك إدارة العملاء والصفقات والتواصل.' },
|
||||
{ title: 'الأهلية', body: 'يجب أن يكون عمرك 18 عاماً على الأقل لاستخدام المنصة. باستخدامك للمنصة، تؤكد أنك تملك الأهلية القانونية لإبرام هذه الاتفاقية وأنك مفوّض من قبل الشركة التي تمثلها.' },
|
||||
{ title: 'الحساب والأمان', body: 'أنت مسؤول عن الحفاظ على سرية بيانات حسابك وكلمة المرور. يجب إخطارنا فوراً عند اكتشاف أي استخدام غير مصرح به لحسابك. لا تتحمل Dealix مسؤولية أي خسارة ناتجة عن استخدام غير مصرح به.' },
|
||||
{ title: 'الاستخدام المقبول', body: 'تلتزم باستخدام المنصة للأغراض التجارية المشروعة فقط. يُحظر استخدام المنصة في أي نشاط مخالف للأنظمة السعودية أو لإرسال رسائل غير مرغوبة (spam) أو لجمع بيانات بطرق غير مشروعة.' },
|
||||
{ title: 'حماية البيانات', body: 'نلتزم بنظام حماية البيانات الشخصية (PDPL) في المملكة العربية السعودية. تتم معالجة البيانات وفقاً لسياسة الخصوصية الخاصة بنا وبموافقة صريحة من أصحاب البيانات.' },
|
||||
{ title: 'الملكية الفكرية', body: 'جميع حقوق الملكية الفكرية للمنصة وبرامجها وتصاميمها وعلاماتها التجارية مملوكة لشركة Dealix. لا يحق لك نسخ أو تعديل أو توزيع أي جزء من المنصة دون إذن كتابي مسبق.' },
|
||||
{ title: 'الإنهاء', body: 'يحق لنا تعليق أو إنهاء حسابك في حال مخالفة هذه الشروط. يمكنك إلغاء حسابك في أي وقت من خلال إعدادات الحساب. عند الإنهاء، سيتم حذف بياناتك وفقاً لسياسة الاحتفاظ بالبيانات.' },
|
||||
{ title: 'القانون الحاكم', body: 'تخضع هذه الشروط لأنظمة المملكة العربية السعودية. أي نزاع ينشأ عن استخدام المنصة يخضع لاختصاص المحاكم المختصة في المملكة العربية السعودية.' },
|
||||
];
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<div className="min-h-screen py-16 px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="max-w-3xl mx-auto"
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 text-sm text-slate-400 hover:text-white transition-colors mb-8"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4 rtl:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
رجوع
|
||||
</Link>
|
||||
|
||||
<h1 className="text-3xl sm:text-4xl font-bold text-white mb-2">الشروط والأحكام</h1>
|
||||
<p className="text-sm text-slate-500 mb-10">آخر تحديث: {LAST_UPDATED}</p>
|
||||
|
||||
<div className="space-y-8">
|
||||
{sections.map((s, i) => (
|
||||
<section key={i}>
|
||||
<h2 className="text-lg font-semibold text-white mb-2">{s.title}</h2>
|
||||
<p className="text-slate-300 leading-relaxed text-sm">{s.body}</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
MessageCircle, Mail, Linkedin, Instagram, Music2, Twitter,
|
||||
Brain, Shield, Handshake, TrendingUp, Globe, Zap,
|
||||
Building2, Users, BarChart3, Bot, Lock, Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
const fadeUp = {
|
||||
hidden: { opacity: 0, y: 30 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.6 } },
|
||||
};
|
||||
|
||||
const stagger = {
|
||||
visible: { transition: { staggerChildren: 0.1 } },
|
||||
};
|
||||
|
||||
const UNIQUE_CAPABILITIES = [
|
||||
{
|
||||
icon: Brain,
|
||||
title_ar: "٧ أدمغة ذكية لكل قناة",
|
||||
title_en: "7 AI Brains — One Per Channel",
|
||||
desc_ar: "كل قناة عندها عقل خاص: واتساب، إيميل، لينكدإن، إنستقرام، تيكتوك، تويتر، سناب — كلهم مربوطين بالباك إند ويعرفون عميلك",
|
||||
desc_en: "Each channel has its own AI brain: WhatsApp, Email, LinkedIn, Instagram, TikTok, Twitter, Snapchat — all connected to your CRM data",
|
||||
badge_ar: "لا يوجد منافس يقدم هذا",
|
||||
badge_en: "No competitor offers this",
|
||||
color: "from-cyan-500 to-blue-600",
|
||||
},
|
||||
{
|
||||
icon: Handshake,
|
||||
title_ar: "محرك صفقات استراتيجية",
|
||||
title_en: "Strategic Deal Exchange Engine",
|
||||
desc_ar: "النظام يفهم شركتك ويبحث عن شركاء مناسبين — تبادل خدمات، شراكات، توزيع، استحواذ — ١٥ نوع صفقة",
|
||||
desc_en: "The system understands your company and finds matching partners — barter, partnerships, distribution, acquisition — 15 deal types",
|
||||
badge_ar: "أول نظام بالعالم يفعل هذا",
|
||||
badge_en: "World's first system to do this",
|
||||
color: "from-emerald-500 to-teal-600",
|
||||
},
|
||||
{
|
||||
icon: Bot,
|
||||
title_ar: "مفاوض AI بالعربي",
|
||||
title_en: "Arabic AI Negotiator",
|
||||
desc_ar: "يتفاوض بالنيابة عنك بالعربي — يفهم الثقافة السعودية، يحفظ ماء الوجه، ويعرف متى يصعّد للبشر",
|
||||
desc_en: "Negotiates on your behalf in Arabic — understands Saudi culture, saves face, knows when to escalate to humans",
|
||||
badge_ar: "حصري لـ Dealix",
|
||||
badge_en: "Dealix exclusive",
|
||||
color: "from-purple-500 to-indigo-600",
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title_ar: "حماية PDPL مدمجة",
|
||||
title_en: "Built-in PDPL Protection",
|
||||
desc_ar: "النظام يفحص الموافقة قبل كل رسالة — حماية بياناتك وبيانات عملائك حسب نظام حماية البيانات السعودي",
|
||||
desc_en: "System checks consent before every message — protects your data and clients' data per Saudi PDPL law",
|
||||
badge_ar: "غرامة ٥ مليون ر.س — نحميك",
|
||||
badge_en: "SAR 5M fine — we protect you",
|
||||
color: "from-red-500 to-orange-600",
|
||||
},
|
||||
{
|
||||
icon: TrendingUp,
|
||||
title_ar: "محاكي نمو استراتيجي",
|
||||
title_en: "Strategic Growth Simulator",
|
||||
desc_ar: "حاكي أي سيناريو: شراكة، استحواذ، توسع — شوف العائد والمخاطر قبل ما تقرر",
|
||||
desc_en: "Simulate any scenario: partnership, acquisition, expansion — see ROI and risks before deciding",
|
||||
badge_ar: "مستوى enterprise",
|
||||
badge_en: "Enterprise-grade",
|
||||
color: "from-amber-500 to-yellow-600",
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
title_ar: "عربي أولاً — ثنائي اللغة",
|
||||
title_en: "Arabic-First — Bilingual",
|
||||
desc_ar: "مبني عربي من الأساس مع AI يفهم اللهجة السعودية — مو ترجمة لنظام أجنبي",
|
||||
desc_en: "Built Arabic from the ground up with Saudi-dialect AI — not a translation of a foreign system",
|
||||
badge_ar: "الوحيد بالسوق",
|
||||
badge_en: "Only one in market",
|
||||
color: "from-green-500 to-emerald-600",
|
||||
},
|
||||
];
|
||||
|
||||
const CHANNEL_ICONS = [
|
||||
{ icon: MessageCircle, name: "WhatsApp", color: "#25D366" },
|
||||
{ icon: Mail, name: "Email", color: "#EA4335" },
|
||||
{ icon: Linkedin, name: "LinkedIn", color: "#0A66C2" },
|
||||
{ icon: Instagram, name: "Instagram", color: "#E4405F" },
|
||||
{ icon: Music2, name: "TikTok", color: "#000000" },
|
||||
{ icon: Twitter, name: "X/Twitter", color: "#1DA1F2" },
|
||||
];
|
||||
|
||||
const COMPARISON_DATA = [
|
||||
{ feature_ar: "أدمغة AI لكل قناة", dealix: true, salesforce: false, zoho: false, hubspot: false },
|
||||
{ feature_ar: "صفقات استراتيجية تلقائية", dealix: true, salesforce: false, zoho: false, hubspot: false },
|
||||
{ feature_ar: "مفاوض AI بالعربي", dealix: true, salesforce: false, zoho: false, hubspot: false },
|
||||
{ feature_ar: "واتساب مدمج بالنظام", dealix: true, salesforce: false, zoho: false, hubspot: false },
|
||||
{ feature_ar: "PDPL مدمج", dealix: true, salesforce: false, zoho: false, hubspot: false },
|
||||
{ feature_ar: "عربي أولاً (مو ترجمة)", dealix: true, salesforce: false, zoho: true, hubspot: false },
|
||||
{ feature_ar: "محاكي نمو استراتيجي", dealix: true, salesforce: false, zoho: false, hubspot: false },
|
||||
{ feature_ar: "تسلسلات متعددة القنوات", dealix: true, salesforce: true, zoho: true, hubspot: true },
|
||||
];
|
||||
|
||||
export function CapabilitiesShowcase() {
|
||||
return (
|
||||
<section className="py-20 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
variants={fadeUp}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<span className="inline-block px-4 py-1.5 rounded-full bg-cyan-500/10 text-cyan-400 text-sm font-medium mb-4 border border-cyan-500/20">
|
||||
ما يميز Dealix عن كل المنافسين
|
||||
</span>
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-white mb-4">
|
||||
قدرات لا توجد في أي نظام آخر
|
||||
</h2>
|
||||
<p className="text-xl text-slate-400 max-w-3xl mx-auto">
|
||||
Dealix ليس مجرد CRM — هو نظام تجاري ذكي يبيع ويتفاوض ويبني شراكات بالنيابة عنك
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Channel Icons Row */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
variants={stagger}
|
||||
className="flex justify-center gap-6 mb-16"
|
||||
>
|
||||
{CHANNEL_ICONS.map((ch) => (
|
||||
<motion.div
|
||||
key={ch.name}
|
||||
variants={fadeUp}
|
||||
className="flex flex-col items-center gap-2"
|
||||
>
|
||||
<div
|
||||
className="w-14 h-14 rounded-xl flex items-center justify-center"
|
||||
style={{ backgroundColor: `${ch.color}20`, border: `1px solid ${ch.color}40` }}
|
||||
>
|
||||
<ch.icon className="w-7 h-7" style={{ color: ch.color }} />
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">{ch.name}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Unique Capabilities Grid */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
variants={stagger}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-20"
|
||||
>
|
||||
{UNIQUE_CAPABILITIES.map((cap, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
variants={fadeUp}
|
||||
className="relative p-6 rounded-2xl bg-white/5 border border-white/10 backdrop-blur-sm
|
||||
hover:bg-white/[0.08] transition-all duration-300 group"
|
||||
>
|
||||
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${cap.color} flex items-center justify-center mb-4`}>
|
||||
<cap.icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white mb-2">{cap.title_ar}</h3>
|
||||
<p className="text-sm text-slate-400 mb-4 leading-relaxed">{cap.desc_ar}</p>
|
||||
<span className="inline-block px-3 py-1 rounded-full bg-cyan-500/10 text-cyan-400 text-xs border border-cyan-500/20">
|
||||
{cap.badge_ar}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Comparison Table */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
variants={fadeUp}
|
||||
className="mb-16"
|
||||
>
|
||||
<h3 className="text-2xl font-bold text-white text-center mb-8">
|
||||
لماذا Dealix هو الخيار الأذكى؟
|
||||
</h3>
|
||||
<div className="overflow-x-auto rounded-2xl border border-white/10">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-white/5">
|
||||
<th className="px-6 py-4 text-start text-sm font-medium text-slate-400">الميزة</th>
|
||||
<th className="px-6 py-4 text-center text-sm font-bold text-cyan-400">Dealix</th>
|
||||
<th className="px-6 py-4 text-center text-sm font-medium text-slate-500">Salesforce</th>
|
||||
<th className="px-6 py-4 text-center text-sm font-medium text-slate-500">Zoho</th>
|
||||
<th className="px-6 py-4 text-center text-sm font-medium text-slate-500">HubSpot</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{COMPARISON_DATA.map((row, i) => (
|
||||
<tr key={i} className="border-t border-white/5 hover:bg-white/[0.02]">
|
||||
<td className="px-6 py-3 text-sm text-white">{row.feature_ar}</td>
|
||||
<td className="px-6 py-3 text-center">{row.dealix ? "✅" : "❌"}</td>
|
||||
<td className="px-6 py-3 text-center">{row.salesforce ? "✅" : "❌"}</td>
|
||||
<td className="px-6 py-3 text-center">{row.zoho ? "✅" : "❌"}</td>
|
||||
<td className="px-6 py-3 text-center">{row.hubspot ? "✅" : "❌"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-center text-sm text-slate-500 mt-4">
|
||||
Dealix يتفوق في ٧ من ٨ مقارنات — والباقي متساوي
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Bottom CTA */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
variants={fadeUp}
|
||||
className="text-center p-8 rounded-2xl bg-gradient-to-r from-cyan-500/10 to-emerald-500/10 border border-cyan-500/20"
|
||||
>
|
||||
<Sparkles className="w-8 h-8 text-cyan-400 mx-auto mb-4" />
|
||||
<h3 className="text-2xl font-bold text-white mb-2">
|
||||
جاهز تشوف قدرات لا توجد في أي مكان ثاني؟
|
||||
</h3>
|
||||
<p className="text-slate-400 mb-6">
|
||||
١٤ يوم تجربة مجانية — بدون بطاقة — كل المميزات مفتوحة
|
||||
</p>
|
||||
<button className="px-8 py-3 rounded-xl bg-gradient-to-r from-cyan-500 to-emerald-600 text-white font-bold hover:shadow-lg hover:shadow-cyan-500/25 transition-all">
|
||||
ابدأ تجربتك المجانية الآن
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,289 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { clsx } from 'clsx';
|
||||
import {
|
||||
Search, Plus, MessageSquare, BarChart3, Settings,
|
||||
Users, Briefcase, ArrowRight, Clock, Inbox,
|
||||
LayoutDashboard, UserPlus, CheckSquare, Megaphone,
|
||||
} from 'lucide-react';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
type CommandCategory = 'recent' | 'navigation' | 'actions' | 'contacts' | 'deals';
|
||||
|
||||
interface CommandItem {
|
||||
id: string;
|
||||
label: string;
|
||||
labelAr: string;
|
||||
category: CommandCategory;
|
||||
icon: typeof Search;
|
||||
keywords: string[];
|
||||
onSelect?: () => void;
|
||||
}
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect?: (item: CommandItem) => void;
|
||||
}
|
||||
|
||||
const backdropVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1 },
|
||||
};
|
||||
|
||||
const panelVariants = {
|
||||
hidden: { opacity: 0, scale: 0.96, y: -8 },
|
||||
visible: { opacity: 1, scale: 1, y: 0 },
|
||||
exit: { opacity: 0, scale: 0.96, y: -8 },
|
||||
};
|
||||
|
||||
function buildItems(t: (k: string) => string): CommandItem[] {
|
||||
return [
|
||||
{ id: 'nav-dashboard', label: 'Dashboard', labelAr: t('dashboard.tabs.overview'), category: 'navigation', icon: LayoutDashboard, keywords: ['home', 'لوحة', 'loha', 'dashboard'] },
|
||||
{ id: 'nav-pipeline', label: 'Pipeline', labelAr: t('dashboard.tabs.pipeline'), category: 'navigation', icon: Briefcase, keywords: ['deals', 'مسار', 'masar', 'pipeline', 'صفقات'] },
|
||||
{ id: 'nav-inbox', label: 'Inbox', labelAr: t('dashboard.tabs.inbox'), category: 'navigation', icon: Inbox, keywords: ['messages', 'صندوق', 'sandoq', 'inbox', 'رسائل'] },
|
||||
{ id: 'nav-analytics', label: 'Analytics', labelAr: t('dashboard.tabs.analytics'), category: 'navigation', icon: BarChart3, keywords: ['reports', 'تحليلات', 'tahlilat', 'analytics', 'تقارير'] },
|
||||
{ id: 'nav-leads', label: 'Leads', labelAr: t('dashboard.tabs.leads'), category: 'navigation', icon: Users, keywords: ['clients', 'عملاء', '3omala', 'leads'] },
|
||||
{ id: 'nav-settings', label: 'Settings', labelAr: t('dashboard.tabs.settings'), category: 'navigation', icon: Settings, keywords: ['config', 'إعدادات', 'e3dadat', 'settings'] },
|
||||
{ id: 'nav-marketers', label: 'Marketers', labelAr: t('commandPalette.actions.goToMarketers'), category: 'navigation', icon: Megaphone, keywords: ['affiliate', 'مسوقين', 'msawqin', 'marketers'] },
|
||||
{ id: 'act-new-deal', label: 'Create New Deal', labelAr: t('commandPalette.actions.newDeal'), category: 'actions', icon: Plus, keywords: ['new', 'deal', 'صفقة', 'safqa', 'جديد', 'jadid', 'create'] },
|
||||
{ id: 'act-new-contact', label: 'Add Contact', labelAr: t('commandPalette.actions.newContact'), category: 'actions', icon: UserPlus, keywords: ['contact', 'add', 'إضافة', 'edafa', 'جهة', 'jiha'] },
|
||||
{ id: 'act-new-task', label: 'Create Task', labelAr: t('commandPalette.actions.newTask'), category: 'actions', icon: CheckSquare, keywords: ['task', 'مهمة', 'muhimma', 'todo'] },
|
||||
{ id: 'act-send-msg', label: 'Send Message', labelAr: t('commandPalette.actions.sendMessage'), category: 'actions', icon: MessageSquare, keywords: ['message', 'رسالة', 'risala', 'whatsapp', 'واتساب'] },
|
||||
];
|
||||
}
|
||||
|
||||
function fuzzyMatch(query: string, item: CommandItem, isArabic: boolean): boolean {
|
||||
const q = query.toLowerCase();
|
||||
const haystack = [
|
||||
item.label.toLowerCase(),
|
||||
item.labelAr,
|
||||
...item.keywords.map((k) => k.toLowerCase()),
|
||||
].join(' ');
|
||||
return haystack.includes(q);
|
||||
}
|
||||
|
||||
function CommandPalette({ open, onClose, onSelect }: CommandPaletteProps) {
|
||||
const { t, dir, isArabic } = useI18n();
|
||||
const [query, setQuery] = useState('');
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const allItems = useMemo(() => buildItems(t), [t]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return allItems.slice(0, 8);
|
||||
return allItems.filter((item) => fuzzyMatch(query, item, isArabic));
|
||||
}, [query, allItems, isArabic]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<CommandCategory, CommandItem[]>();
|
||||
for (const item of filtered) {
|
||||
const list = map.get(item.category) ?? [];
|
||||
list.push(item);
|
||||
map.set(item.category, list);
|
||||
}
|
||||
return map;
|
||||
}, [filtered]);
|
||||
|
||||
const flatItems = useMemo(() => filtered, [filtered]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery('');
|
||||
setActiveIndex(0);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
}, [query]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(item: CommandItem) => {
|
||||
onSelect?.(item);
|
||||
item.onSelect?.();
|
||||
onClose();
|
||||
},
|
||||
[onSelect, onClose],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => (i + 1) % flatItems.length);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => (i - 1 + flatItems.length) % flatItems.length);
|
||||
} else if (e.key === 'Enter' && flatItems[activeIndex]) {
|
||||
e.preventDefault();
|
||||
handleSelect(flatItems[activeIndex]);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[flatItems, activeIndex, handleSelect, onClose],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
const categoryLabel = (cat: CommandCategory) =>
|
||||
t(`commandPalette.categories.${cat}`);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh] px-4">
|
||||
<motion.div
|
||||
variants={backdropVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
variants={panelVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Command Palette"
|
||||
dir={dir}
|
||||
className={clsx(
|
||||
'relative z-10 w-full max-w-lg',
|
||||
'bg-[#0A0F1C]/95 backdrop-blur-2xl',
|
||||
'border border-white/10 rounded-2xl',
|
||||
'shadow-2xl shadow-black/50',
|
||||
'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-white/10 px-4 py-3">
|
||||
<Search className="h-4.5 w-4.5 text-slate-500 shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('commandPalette.placeholder')}
|
||||
className={clsx(
|
||||
'flex-1 bg-transparent text-sm text-white',
|
||||
'placeholder:text-slate-500',
|
||||
'outline-none',
|
||||
)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<kbd className="hidden sm:inline-flex items-center rounded-md px-1.5 py-0.5 bg-white/[0.06] border border-white/10 text-[11px] text-slate-500 font-mono">
|
||||
ESC
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="max-h-[340px] overflow-y-auto py-2">
|
||||
{flatItems.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-slate-500">
|
||||
{t('commandPalette.noResults')} “{query}”
|
||||
</div>
|
||||
) : (
|
||||
Array.from(grouped.entries()).map(([category, items]) => (
|
||||
<div key={category} className="mb-1 last:mb-0">
|
||||
<p className="px-4 py-1.5 text-[11px] font-medium uppercase tracking-wider text-slate-500">
|
||||
{categoryLabel(category)}
|
||||
</p>
|
||||
{items.map((item) => {
|
||||
const globalIdx = flatItems.indexOf(item);
|
||||
const isActive = globalIdx === activeIndex;
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleSelect(item)}
|
||||
onMouseEnter={() => setActiveIndex(globalIdx)}
|
||||
className={clsx(
|
||||
'flex items-center gap-3 w-full px-4 py-2.5 text-start',
|
||||
'transition-colors duration-100',
|
||||
isActive
|
||||
? 'bg-teal-500/10 text-white'
|
||||
: 'text-slate-300 hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={clsx(
|
||||
'h-4 w-4 shrink-0',
|
||||
isActive ? 'text-teal-400' : 'text-slate-500',
|
||||
)}
|
||||
/>
|
||||
<span className="flex-1 text-sm truncate">
|
||||
{isArabic ? item.labelAr : item.label}
|
||||
</span>
|
||||
{isActive && (
|
||||
<ArrowRight className="h-3.5 w-3.5 text-teal-400 shrink-0 rtl:rotate-180" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 px-4 py-2 flex items-center gap-4 text-[11px] text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="rounded bg-white/[0.06] px-1 py-0.5 font-mono">↑↓</kbd>
|
||||
{isArabic ? 'تنقل' : 'Navigate'}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="rounded bg-white/[0.06] px-1 py-0.5 font-mono">↵</kbd>
|
||||
{isArabic ? 'اختر' : 'Select'}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="rounded bg-white/[0.06] px-1 py-0.5 font-mono">ESC</kbd>
|
||||
{isArabic ? 'إغلاق' : 'Close'}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
function useCommandPalette() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, []);
|
||||
|
||||
return { open, setOpen, onClose: () => setOpen(false) };
|
||||
}
|
||||
|
||||
export { CommandPalette, useCommandPalette };
|
||||
export type { CommandPaletteProps, CommandItem };
|
||||
@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
const STORAGE_KEY = 'dealix-cookie-consent';
|
||||
|
||||
export function CookieConsent() {
|
||||
const { isArabic } = useI18n();
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const label = (ar: string, en: string) => (isArabic ? ar : en);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) {
|
||||
// Small delay so it doesn't appear on first paint
|
||||
const timer = setTimeout(() => setVisible(true), 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
function handleAccept() {
|
||||
localStorage.setItem(STORAGE_KEY, 'accepted');
|
||||
setVisible(false);
|
||||
}
|
||||
|
||||
function handleReject() {
|
||||
localStorage.setItem(STORAGE_KEY, 'rejected');
|
||||
setVisible(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
className="fixed bottom-0 inset-x-0 z-[100] p-4"
|
||||
>
|
||||
<div className="max-w-3xl mx-auto rounded-2xl bg-slate-900/95 backdrop-blur-2xl border border-white/10 shadow-2xl shadow-black/40 p-5 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||
{/* Text */}
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-slate-300 leading-relaxed">
|
||||
{label(
|
||||
'نستخدم ملفات تعريف الارتباط لتحسين تجربتك وتحليل استخدام المنصة وفقاً لنظام حماية البيانات الشخصية (PDPL).',
|
||||
'We use cookies to improve your experience and analyze platform usage in compliance with PDPL.'
|
||||
)}
|
||||
</p>
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="inline-block mt-1.5 text-xs text-primary hover:text-primary/80 transition-colors underline underline-offset-2"
|
||||
>
|
||||
{label('المزيد من المعلومات', 'More Information')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={handleReject}
|
||||
className="px-5 py-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-sm text-slate-300 font-medium transition-all duration-200"
|
||||
>
|
||||
{label('رفض', 'Reject')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAccept}
|
||||
className="px-5 py-2 rounded-xl bg-primary/20 hover:bg-primary/30 border border-primary/30 hover:border-primary/50 text-sm text-primary font-semibold transition-all duration-200"
|
||||
>
|
||||
{label('قبول', 'Accept')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
196
salesflow-saas/frontend/src/components/dealix/dealix-3d-logo.tsx
Normal file
196
salesflow-saas/frontend/src/components/dealix/dealix-3d-logo.tsx
Normal file
@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useRef, useMemo, useState, useEffect } from 'react';
|
||||
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||
import { Float, MeshTransmissionMaterial, Environment } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
function useIsMobile() {
|
||||
const [mobile, setMobile] = useState(false);
|
||||
useEffect(() => {
|
||||
const check = () => setMobile(window.innerWidth < 768);
|
||||
check();
|
||||
window.addEventListener('resize', check);
|
||||
return () => window.removeEventListener('resize', check);
|
||||
}, []);
|
||||
return mobile;
|
||||
}
|
||||
|
||||
function HandShape({ position, rotation, color }: {
|
||||
position: [number, number, number];
|
||||
rotation: [number, number, number];
|
||||
color: string;
|
||||
}) {
|
||||
const group = useRef<THREE.Group>(null);
|
||||
|
||||
return (
|
||||
<group ref={group} position={position} rotation={rotation}>
|
||||
{/* Palm */}
|
||||
<mesh>
|
||||
<boxGeometry args={[0.7, 0.15, 0.5]} />
|
||||
<meshStandardMaterial color={color} metalness={0.7} roughness={0.2} />
|
||||
</mesh>
|
||||
{/* Fingers - four cylinders */}
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<mesh key={i} position={[0.25, 0.05, -0.15 + i * 0.1]} rotation={[0, 0, 0.3]}>
|
||||
<capsuleGeometry args={[0.035, 0.3, 4, 8]} />
|
||||
<meshStandardMaterial color={color} metalness={0.7} roughness={0.2} />
|
||||
</mesh>
|
||||
))}
|
||||
{/* Thumb */}
|
||||
<mesh position={[-0.25, 0.05, -0.2]} rotation={[0.4, 0, -0.5]}>
|
||||
<capsuleGeometry args={[0.04, 0.2, 4, 8]} />
|
||||
<meshStandardMaterial color={color} metalness={0.7} roughness={0.2} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function GlowSphere() {
|
||||
const ref = useRef<THREE.Mesh>(null);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!ref.current) return;
|
||||
const s = 1 + Math.sin(clock.elapsedTime * 2) * 0.15;
|
||||
ref.current.scale.setScalar(s);
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh ref={ref} position={[0, 0, 0]}>
|
||||
<sphereGeometry args={[0.18, 16, 16]} />
|
||||
<meshStandardMaterial
|
||||
color="#14b8a6"
|
||||
emissive="#14b8a6"
|
||||
emissiveIntensity={2}
|
||||
transparent
|
||||
opacity={0.6}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
function Particles({ count }: { count: number }) {
|
||||
const ref = useRef<THREE.Points>(null);
|
||||
|
||||
const positions = useMemo(() => {
|
||||
const arr = new Float32Array(count * 3);
|
||||
for (let i = 0; i < count; i++) {
|
||||
arr[i * 3] = (Math.random() - 0.5) * 3;
|
||||
arr[i * 3 + 1] = (Math.random() - 0.5) * 3;
|
||||
arr[i * 3 + 2] = (Math.random() - 0.5) * 3;
|
||||
}
|
||||
return arr;
|
||||
}, [count]);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!ref.current) return;
|
||||
ref.current.rotation.y = clock.elapsedTime * 0.05;
|
||||
ref.current.rotation.x = Math.sin(clock.elapsedTime * 0.03) * 0.1;
|
||||
});
|
||||
|
||||
return (
|
||||
<points ref={ref}>
|
||||
<bufferGeometry>
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
args={[positions, 3]}
|
||||
/>
|
||||
</bufferGeometry>
|
||||
<pointsMaterial color="#5eead4" size={0.02} transparent opacity={0.6} sizeAttenuation />
|
||||
</points>
|
||||
);
|
||||
}
|
||||
|
||||
function HandshakeScene({ isMobile }: { isMobile: boolean }) {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const mouse = useRef({ x: 0, y: 0 });
|
||||
|
||||
const { viewport } = useThree();
|
||||
|
||||
useEffect(() => {
|
||||
const handle = (e: MouseEvent) => {
|
||||
mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||
mouse.current.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
||||
};
|
||||
window.addEventListener('mousemove', handle);
|
||||
return () => window.removeEventListener('mousemove', handle);
|
||||
}, []);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!groupRef.current) return;
|
||||
groupRef.current.rotation.y = clock.elapsedTime * 0.15 + mouse.current.x * 0.3;
|
||||
groupRef.current.rotation.x = Math.sin(clock.elapsedTime * 0.2) * 0.05 + mouse.current.y * 0.15;
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
{/* Left hand reaching right */}
|
||||
<HandShape
|
||||
position={[-0.35, 0, 0]}
|
||||
rotation={[0, 0, 0.1]}
|
||||
color="#0d9488"
|
||||
/>
|
||||
{/* Right hand reaching left */}
|
||||
<HandShape
|
||||
position={[0.35, 0, 0]}
|
||||
rotation={[0, Math.PI, -0.1]}
|
||||
color="#14b8a6"
|
||||
/>
|
||||
{/* Glow at handshake point */}
|
||||
<GlowSphere />
|
||||
{/* Particles */}
|
||||
<Particles count={isMobile ? 40 : 120} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingShimmer() {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<div className="relative h-16 w-16">
|
||||
<div className="absolute inset-0 rounded-full bg-teal-500/20 animate-ping" />
|
||||
<div className="absolute inset-2 rounded-full bg-teal-500/40 animate-pulse" />
|
||||
<div className="absolute inset-4 rounded-full bg-teal-400/60" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DealixLogo3DProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function DealixLogo3D({ size = 300, className }: DealixLogo3DProps) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx('relative', className)}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<Suspense fallback={<LoadingShimmer />}>
|
||||
<Canvas
|
||||
camera={{ position: [0, 0, 3], fov: 40 }}
|
||||
dpr={isMobile ? 1 : [1, 2]}
|
||||
gl={{ alpha: true, antialias: !isMobile }}
|
||||
style={{ background: 'transparent' }}
|
||||
>
|
||||
<ambientLight intensity={0.4} />
|
||||
<directionalLight position={[5, 5, 5]} intensity={1} color="#ffffff" />
|
||||
<pointLight position={[0, 0, 2]} intensity={0.8} color="#14b8a6" />
|
||||
<Float speed={1.5} rotationIntensity={0.2} floatIntensity={0.3}>
|
||||
<HandshakeScene isMobile={isMobile} />
|
||||
</Float>
|
||||
</Canvas>
|
||||
</Suspense>
|
||||
|
||||
{/* Ambient glow behind the canvas */}
|
||||
<div className="absolute inset-0 -z-10 rounded-full bg-teal-500/10 blur-3xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { DealixLogo3D };
|
||||
export type { DealixLogo3DProps };
|
||||
@ -0,0 +1,286 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion, useMotionValue, useTransform, animate } from "framer-motion";
|
||||
import { TrendingUp, Sparkles, UserCheck, MousePointerClick, Target } from "lucide-react";
|
||||
|
||||
/* ───────────── types ───────────── */
|
||||
interface BreakdownItem {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number; // 0-25
|
||||
icon: typeof TrendingUp;
|
||||
}
|
||||
|
||||
interface LeadScoreData {
|
||||
score: number; // 0-100
|
||||
breakdown: BreakdownItem[];
|
||||
recommendation: string;
|
||||
}
|
||||
|
||||
/* ───────────── helpers ───────────── */
|
||||
function getGrade(score: number): string {
|
||||
if (score >= 90) return "A+";
|
||||
if (score >= 80) return "A";
|
||||
if (score >= 70) return "B";
|
||||
if (score >= 55) return "C";
|
||||
if (score >= 40) return "D";
|
||||
return "F";
|
||||
}
|
||||
|
||||
function getScoreColor(score: number): string {
|
||||
if (score >= 75) return "#10b981"; // green
|
||||
if (score >= 50) return "#eab308"; // yellow
|
||||
return "#ef4444"; // red
|
||||
}
|
||||
|
||||
function getGradientId(score: number): string {
|
||||
return `score-gradient-${score}`;
|
||||
}
|
||||
|
||||
/* ───────────── sample data ───────────── */
|
||||
const sampleData: LeadScoreData = {
|
||||
score: 78,
|
||||
breakdown: [
|
||||
{ key: "engagement", label: "التفاعل", value: 22, icon: MousePointerClick },
|
||||
{ key: "profile", label: "الملف الشخصي", value: 18, icon: UserCheck },
|
||||
{ key: "behavior", label: "السلوك", value: 20, icon: TrendingUp },
|
||||
{ key: "intent", label: "نية الشراء", value: 18, icon: Target },
|
||||
],
|
||||
recommendation: "عميل واعد — تابع خلال ٢٤ ساعة",
|
||||
};
|
||||
|
||||
/* ───────────── circular ring ───────────── */
|
||||
function ScoreRing({
|
||||
score,
|
||||
size = 160,
|
||||
strokeWidth = 10,
|
||||
}: {
|
||||
score: number;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
}) {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const motionProgress = useMotionValue(0);
|
||||
const strokeDashoffset = useTransform(
|
||||
motionProgress,
|
||||
(v) => circumference - (v / 100) * circumference
|
||||
);
|
||||
const displayScore = useMotionValue(0);
|
||||
const [displayed, setDisplayed] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const anim = animate(motionProgress, score, { duration: 1.4, ease: "easeOut" });
|
||||
const anim2 = animate(displayScore, score, {
|
||||
duration: 1.4,
|
||||
ease: "easeOut",
|
||||
onUpdate: (v) => setDisplayed(Math.round(v)),
|
||||
});
|
||||
return () => {
|
||||
anim.stop();
|
||||
anim2.stop();
|
||||
};
|
||||
}, [score, motionProgress, displayScore]);
|
||||
|
||||
const color = getScoreColor(score);
|
||||
const grade = getGrade(score);
|
||||
const gradientId = getGradientId(score);
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="transform -rotate-90">
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#ef4444" />
|
||||
<stop offset="50%" stopColor="#eab308" />
|
||||
<stop offset="100%" stopColor="#10b981" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* background ring */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.06)"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{/* progress ring */}
|
||||
<motion.circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={`url(#${gradientId})`}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
style={{ strokeDashoffset }}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* center content */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-4xl font-black tabular-nums" style={{ color }}>
|
||||
{displayed}
|
||||
</span>
|
||||
<span
|
||||
className="text-sm font-bold mt-0.5 px-2 py-0.5 rounded-md"
|
||||
style={{ backgroundColor: `${color}20`, color }}
|
||||
>
|
||||
{grade}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────── breakdown bar ───────────── */
|
||||
function BreakdownBar({
|
||||
item,
|
||||
delay,
|
||||
}: {
|
||||
item: BreakdownItem;
|
||||
delay: number;
|
||||
}) {
|
||||
const Icon = item.icon;
|
||||
const pct = (item.value / 25) * 100;
|
||||
const color = getScoreColor(item.value * 4); // scale 0-25 -> 0-100
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay, duration: 0.4 }}
|
||||
className="space-y-1.5"
|
||||
>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-bold tabular-nums" style={{ color }}>
|
||||
{item.value}/٢٥
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-white/70">{item.label}</span>
|
||||
<div className="p-1 rounded-md bg-white/5">
|
||||
<Icon className="w-3.5 h-3.5 text-white/40" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-2 rounded-full bg-white/[0.06] overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${pct}%` }}
|
||||
transition={{ delay: delay + 0.2, duration: 0.8, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────── full variant ───────────── */
|
||||
export function LeadScoreCard({
|
||||
data = sampleData,
|
||||
variant = "full",
|
||||
}: {
|
||||
data?: LeadScoreData;
|
||||
variant?: "full" | "compact";
|
||||
}) {
|
||||
if (variant === "compact") {
|
||||
return <LeadScoreCompact data={data} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="rounded-3xl bg-white/[0.04] backdrop-blur-xl border border-white/[0.08] p-6 max-w-sm w-full"
|
||||
dir="rtl"
|
||||
>
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="p-2 rounded-xl bg-teal-500/10">
|
||||
<Sparkles className="w-5 h-5 text-teal-400" />
|
||||
</div>
|
||||
<h3 className="font-black text-base">تقييم العميل الذكي</h3>
|
||||
</div>
|
||||
|
||||
{/* ring */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<ScoreRing score={data.score} />
|
||||
</div>
|
||||
|
||||
{/* breakdown */}
|
||||
<div className="space-y-4 mb-6">
|
||||
{data.breakdown.map((item, i) => (
|
||||
<BreakdownBar key={item.key} item={item} delay={0.3 + i * 0.1} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* recommendation */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 1, duration: 0.4 }}
|
||||
className="rounded-2xl bg-teal-500/10 border border-teal-500/20 p-4 text-center"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 mb-1">
|
||||
<Sparkles className="w-4 h-4 text-teal-400" />
|
||||
<span className="text-xs font-bold text-teal-300">توصية الذكاء الاصطناعي</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-white/80">{data.recommendation}</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────── compact variant ───────────── */
|
||||
function LeadScoreCompact({ data }: { data: LeadScoreData }) {
|
||||
const color = getScoreColor(data.score);
|
||||
const grade = getGrade(data.score);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex items-center gap-3 rounded-2xl bg-white/[0.04] backdrop-blur-xl border border-white/[0.08] p-3"
|
||||
dir="rtl"
|
||||
>
|
||||
{/* mini ring */}
|
||||
<ScoreRing score={data.score} size={56} strokeWidth={5} />
|
||||
|
||||
{/* info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-black text-lg tabular-nums" style={{ color }}>
|
||||
{data.score}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] font-bold px-1.5 py-0.5 rounded"
|
||||
style={{ backgroundColor: `${color}20`, color }}
|
||||
>
|
||||
{grade}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-white/50 truncate mt-0.5">{data.recommendation}</p>
|
||||
</div>
|
||||
|
||||
{/* mini bars */}
|
||||
<div className="flex gap-1 items-end h-8">
|
||||
{data.breakdown.map((item) => (
|
||||
<motion.div
|
||||
key={item.key}
|
||||
className="w-2 rounded-full"
|
||||
style={{ backgroundColor: getScoreColor(item.value * 4) }}
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: `${(item.value / 25) * 100}%` }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
385
salesflow-saas/frontend/src/components/dealix/marketers-page.tsx
Normal file
385
salesflow-saas/frontend/src/components/dealix/marketers-page.tsx
Normal file
@ -0,0 +1,385 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import { clsx } from 'clsx';
|
||||
import {
|
||||
Zap, Wrench, HeadphonesIcon, Eye,
|
||||
UserPlus, Share2, Coins,
|
||||
Award, ChevronDown, ChevronUp,
|
||||
LayoutDashboard, Link2, FileText, BarChart3,
|
||||
Star, Quote, Phone, Mail, User,
|
||||
} from 'lucide-react';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
/* ---------- Animation Helpers ---------- */
|
||||
const fadeUp = {
|
||||
hidden: { opacity: 0, y: 24 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.5 } },
|
||||
};
|
||||
|
||||
const stagger = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.1 } },
|
||||
};
|
||||
|
||||
function Section({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const inView = useInView(ref, { once: true, margin: '-60px' });
|
||||
return (
|
||||
<motion.section
|
||||
ref={ref}
|
||||
initial="hidden"
|
||||
animate={inView ? 'visible' : 'hidden'}
|
||||
variants={stagger}
|
||||
className={clsx('py-16 sm:py-20', className)}
|
||||
>
|
||||
{children}
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
|
||||
function GlassCard({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<motion.div
|
||||
variants={fadeUp}
|
||||
whileHover={{ y: -3 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
className={clsx(
|
||||
'rounded-xl bg-white/5 backdrop-blur-xl border border-white/10 p-6',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- FAQ Accordion ---------- */
|
||||
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="border-b border-white/10 last:border-0">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center justify-between w-full py-4 text-start gap-4"
|
||||
>
|
||||
<span className="text-sm font-medium text-slate-200">{question}</span>
|
||||
{open ? (
|
||||
<ChevronUp className="h-4 w-4 text-slate-500 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-slate-500 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ height: open ? 'auto' : 0, opacity: open ? 1 : 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className="pb-4 text-sm text-slate-400 leading-relaxed">{answer}</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Main ---------- */
|
||||
function MarketersPage() {
|
||||
const { t, dir, isArabic } = useI18n();
|
||||
const [form, setForm] = useState({ name: '', phone: '', email: '' });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
setSubmitting(false);
|
||||
setSubmitted(true);
|
||||
};
|
||||
|
||||
const benefits = [
|
||||
{ icon: Zap, title: 'benefitInstantCommission', desc: 'benefitInstantCommissionDesc' },
|
||||
{ icon: Wrench, title: 'benefitProTools', desc: 'benefitProToolsDesc' },
|
||||
{ icon: HeadphonesIcon, title: 'benefitSupport', desc: 'benefitSupportDesc' },
|
||||
{ icon: Eye, title: 'benefitTransparency', desc: 'benefitTransparencyDesc' },
|
||||
];
|
||||
const steps = [
|
||||
{ icon: UserPlus, title: 'step1Title', desc: 'step1Desc' },
|
||||
{ icon: Share2, title: 'step2Title', desc: 'step2Desc' },
|
||||
{ icon: Coins, title: 'step3Title', desc: 'step3Desc' },
|
||||
];
|
||||
const tiers = [
|
||||
{ key: 'tierBronze', desc: 'tierBronzeDesc', pct: '10%', color: 'from-amber-700 to-amber-900', badge: 'bg-amber-700/40 text-amber-300' },
|
||||
{ key: 'tierSilver', desc: 'tierSilverDesc', pct: '15%', color: 'from-slate-400 to-slate-600', badge: 'bg-slate-500/40 text-slate-200' },
|
||||
{ key: 'tierGold', desc: 'tierGoldDesc', pct: '20%', color: 'from-amber-400 to-yellow-500', badge: 'bg-amber-400/30 text-amber-200' },
|
||||
];
|
||||
const tools = [
|
||||
{ icon: LayoutDashboard, key: 'toolDashboard' }, { icon: Link2, key: 'toolLinks' },
|
||||
{ icon: FileText, key: 'toolTemplates' }, { icon: BarChart3, key: 'toolReports' },
|
||||
];
|
||||
const faqs = Array.from({ length: 5 }, (_, i) => ({ q: t(`marketersPage.faq${i + 1}Q`), a: t(`marketersPage.faq${i + 1}A`) }));
|
||||
|
||||
return (
|
||||
<div dir={dir} className="min-h-screen bg-[#0A0F1C] text-white">
|
||||
{/* ===== HERO ===== */}
|
||||
<Section className="relative overflow-hidden pt-24 sm:pt-32">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-teal-500/10 via-transparent to-transparent pointer-events-none" />
|
||||
<div className="absolute top-1/4 start-1/2 -translate-x-1/2 w-[600px] h-[600px] bg-teal-500/[0.07] rounded-full blur-[120px] pointer-events-none" />
|
||||
<div className="relative max-w-3xl mx-auto text-center px-4">
|
||||
<motion.h1
|
||||
variants={fadeUp}
|
||||
className="text-3xl sm:text-5xl font-bold leading-tight mb-5"
|
||||
>
|
||||
{t('marketersPage.heroTitle')}
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
variants={fadeUp}
|
||||
className="text-lg text-slate-400 max-w-xl mx-auto leading-relaxed"
|
||||
>
|
||||
{t('marketersPage.heroSubtitle')}
|
||||
</motion.p>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ===== STATS BAR ===== */}
|
||||
<Section className="py-0 -mt-6">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<motion.div variants={fadeUp} className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ label: t('marketersPage.statsAvgCommission'), value: isArabic ? '٤,٢٠٠ ر.س' : 'SAR 4,200' },
|
||||
{ label: t('marketersPage.statsActiveMarketers'), value: isArabic ? '+١٢٠' : '120+' },
|
||||
{ label: t('marketersPage.statsTotalPaid'), value: isArabic ? '+٢.٥ مليون ر.س' : 'SAR 2.5M+' },
|
||||
].map((stat) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="rounded-xl bg-white/5 backdrop-blur-xl border border-white/10 p-5 text-center"
|
||||
>
|
||||
<p className="text-2xl font-bold text-teal-400 mb-1">{stat.value}</p>
|
||||
<p className="text-xs text-slate-400">{stat.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ===== BENEFITS ===== */}
|
||||
<Section>
|
||||
<div className="max-w-5xl mx-auto px-4">
|
||||
<motion.h2 variants={fadeUp} className="text-2xl font-bold text-center mb-10">
|
||||
{t('marketersPage.benefitsTitle')}
|
||||
</motion.h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{benefits.map((b) => (
|
||||
<GlassCard key={b.title}>
|
||||
<div className="rounded-lg bg-teal-500/10 p-2.5 w-fit mb-4">
|
||||
<b.icon className="h-5 w-5 text-teal-400" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-white mb-1.5">{t(`marketersPage.${b.title}`)}</h3>
|
||||
<p className="text-xs text-slate-400 leading-relaxed">{t(`marketersPage.${b.desc}`)}</p>
|
||||
</GlassCard>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ===== HOW IT WORKS ===== */}
|
||||
<Section>
|
||||
<div className="max-w-3xl mx-auto px-4">
|
||||
<motion.h2 variants={fadeUp} className="text-2xl font-bold text-center mb-10">
|
||||
{t('marketersPage.howItWorksTitle')}
|
||||
</motion.h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||
{steps.map((s, i) => (
|
||||
<motion.div key={s.title} variants={fadeUp} className="text-center">
|
||||
<div className="mx-auto w-14 h-14 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center mb-4">
|
||||
<s.icon className="h-6 w-6 text-teal-400" />
|
||||
</div>
|
||||
<span className="inline-block text-xs text-teal-400 font-medium mb-2 tabular-nums">{i + 1}</span>
|
||||
<h3 className="text-sm font-semibold text-white mb-1">{t(`marketersPage.${s.title}`)}</h3>
|
||||
<p className="text-xs text-slate-400 leading-relaxed">{t(`marketersPage.${s.desc}`)}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ===== COMMISSION TIERS ===== */}
|
||||
<Section>
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<motion.h2 variants={fadeUp} className="text-2xl font-bold text-center mb-10">{t('marketersPage.tiersTitle')}</motion.h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
|
||||
{tiers.map((tier) => (
|
||||
<GlassCard key={tier.key} className="text-center relative overflow-hidden">
|
||||
<div className={clsx('absolute inset-0 opacity-[0.06] bg-gradient-to-b', tier.color)} />
|
||||
<div className="relative">
|
||||
<span className={clsx('inline-block px-3 py-1 rounded-full text-xs font-medium mb-4', tier.badge)}>{t(`marketersPage.${tier.key}`)}</span>
|
||||
<p className="text-4xl font-bold text-white mb-1">{tier.pct}</p>
|
||||
<p className="text-xs text-slate-400 mb-3">{t('marketersPage.tierCommission')}</p>
|
||||
<p className="text-xs text-slate-500">{t(`marketersPage.${tier.desc}`)}</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ===== TESTIMONIALS ===== */}
|
||||
<Section>
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<motion.h2 variants={fadeUp} className="text-2xl font-bold text-center mb-10">
|
||||
{t('marketersPage.testimonialsTitle')}
|
||||
</motion.h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||
{[1, 2].map((n) => (
|
||||
<GlassCard key={n}>
|
||||
<Quote className="h-5 w-5 text-teal-500/40 mb-3" />
|
||||
<p className="text-sm text-slate-300 leading-relaxed mb-4">{t(`marketersPage.testimonial${n}Text`)}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-full bg-gradient-to-br from-teal-500 to-emerald-600 flex items-center justify-center text-sm font-bold text-white">
|
||||
{t(`marketersPage.testimonial${n}Name`).charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{t(`marketersPage.testimonial${n}Name`)}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Award className="h-3 w-3 text-amber-400" />
|
||||
<span className="text-xs text-slate-400">{t(`marketersPage.testimonial${n}Role`)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ===== TOOLS PREVIEW ===== */}
|
||||
<Section>
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<motion.h2 variants={fadeUp} className="text-2xl font-bold text-center mb-10">
|
||||
{t('marketersPage.toolsTitle')}
|
||||
</motion.h2>
|
||||
<motion.div variants={fadeUp} className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{tools.map((tl) => (
|
||||
<div key={tl.key} className="rounded-xl bg-white/5 border border-white/10 p-5 text-center hover:bg-white/[0.08] transition-colors">
|
||||
<tl.icon className="h-6 w-6 text-teal-400 mx-auto mb-3" />
|
||||
<p className="text-xs text-slate-300 font-medium">{t(`marketersPage.${tl.key}`)}</p>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ===== FAQ ===== */}
|
||||
<Section>
|
||||
<div className="max-w-2xl mx-auto px-4">
|
||||
<motion.h2 variants={fadeUp} className="text-2xl font-bold text-center mb-10">
|
||||
{t('marketersPage.faqTitle')}
|
||||
</motion.h2>
|
||||
<motion.div
|
||||
variants={fadeUp}
|
||||
className="rounded-xl bg-white/5 backdrop-blur-xl border border-white/10 px-6"
|
||||
>
|
||||
{faqs.map((faq, i) => (
|
||||
<FaqItem key={i} question={faq.q} answer={faq.a} />
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ===== CTA + FORM ===== */}
|
||||
<Section className="pb-24">
|
||||
<div className="max-w-lg mx-auto px-4 text-center">
|
||||
<motion.h2 variants={fadeUp} className="text-2xl font-bold mb-3">
|
||||
{t('marketersPage.ctaTitle')}
|
||||
</motion.h2>
|
||||
|
||||
{submitted ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="mt-8 rounded-xl bg-teal-500/10 border border-teal-500/25 p-8 text-center"
|
||||
>
|
||||
<Star className="h-8 w-8 text-teal-400 mx-auto mb-3" />
|
||||
<p className="text-sm text-teal-300">{t('marketersPage.formSuccess')}</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.form
|
||||
variants={fadeUp}
|
||||
onSubmit={handleSubmit}
|
||||
className="mt-8 space-y-3"
|
||||
>
|
||||
<div className="relative">
|
||||
<User className="absolute top-1/2 -translate-y-1/2 start-3.5 h-4 w-4 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
placeholder={t('marketersPage.formNamePlaceholder')}
|
||||
className={clsx(
|
||||
'w-full rounded-xl bg-white/5 border border-white/10 ps-10 pe-4 py-3',
|
||||
'text-sm text-white placeholder:text-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-teal-400/50 focus:border-transparent',
|
||||
'transition-all',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Phone className="absolute top-1/2 -translate-y-1/2 start-3.5 h-4 w-4 text-slate-500" />
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
dir="ltr"
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
|
||||
placeholder={t('marketersPage.formPhonePlaceholder')}
|
||||
className={clsx(
|
||||
'w-full rounded-xl bg-white/5 border border-white/10 ps-10 pe-4 py-3',
|
||||
'text-sm text-white placeholder:text-slate-500 text-start',
|
||||
'focus:outline-none focus:ring-2 focus:ring-teal-400/50 focus:border-transparent',
|
||||
'transition-all',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Mail className="absolute top-1/2 -translate-y-1/2 start-3.5 h-4 w-4 text-slate-500" />
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
dir="ltr"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
|
||||
placeholder={t('marketersPage.formEmailPlaceholder')}
|
||||
className={clsx(
|
||||
'w-full rounded-xl bg-white/5 border border-white/10 ps-10 pe-4 py-3',
|
||||
'text-sm text-white placeholder:text-slate-500 text-start',
|
||||
'focus:outline-none focus:ring-2 focus:ring-teal-400/50 focus:border-transparent',
|
||||
'transition-all',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<motion.button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
whileHover={submitting ? undefined : { scale: 1.03 }}
|
||||
whileTap={submitting ? undefined : { scale: 0.97 }}
|
||||
className={clsx(
|
||||
'w-full rounded-xl py-3.5 text-sm font-semibold',
|
||||
'bg-gradient-to-l from-teal-500 to-emerald-600 text-white',
|
||||
'hover:shadow-[0_0_24px_rgba(20,184,166,0.4)]',
|
||||
'disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
'transition-shadow duration-200',
|
||||
)}
|
||||
>
|
||||
{submitting ? t('marketersPage.formSubmitting') : t('marketersPage.ctaButton')}
|
||||
</motion.button>
|
||||
</motion.form>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { MarketersPage };
|
||||
@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type NotificationType = 'new_lead' | 'deal_won' | 'deal_lost' | 'message' | 'task_due' | 'approval_needed';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
titleAr: string;
|
||||
titleEn: string;
|
||||
timeAgo: string;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mock data */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const typeConfig: Record<NotificationType, { icon: string; color: string }> = {
|
||||
new_lead: { icon: '👤', color: 'bg-blue-500/20 text-blue-400' },
|
||||
deal_won: { icon: '🎉', color: 'bg-emerald-500/20 text-emerald-400' },
|
||||
deal_lost: { icon: '📉', color: 'bg-red-500/20 text-red-400' },
|
||||
message: { icon: '💬', color: 'bg-primary/20 text-primary' },
|
||||
task_due: { icon: '⏰', color: 'bg-amber-500/20 text-amber-400' },
|
||||
approval_needed: { icon: '✅', color: 'bg-purple-500/20 text-purple-400' },
|
||||
};
|
||||
|
||||
const initialNotifications: Notification[] = [
|
||||
{ id: '1', type: 'new_lead', titleAr: 'عميل محتمل جديد: محمد السالم', titleEn: 'New lead: Mohammed Al-Salem', timeAgo: '2m', read: false },
|
||||
{ id: '2', type: 'deal_won', titleAr: 'تم كسب صفقة عقار الرياض — ٥٠٠,٠٠٠ ر.س', titleEn: 'Deal won: Riyadh Property — SAR 500,000', timeAgo: '15m', read: false },
|
||||
{ id: '3', type: 'message', titleAr: 'رسالة جديدة من أحمد الغامدي', titleEn: 'New message from Ahmed Al-Ghamdi', timeAgo: '1h', read: false },
|
||||
{ id: '4', type: 'task_due', titleAr: 'مهمة مستحقة: متابعة عميل شركة النور', titleEn: 'Task due: Follow up with Al-Nour Co.', timeAgo: '2h', read: true },
|
||||
{ id: '5', type: 'approval_needed', titleAr: 'طلب موافقة على خصم ١٥٪', titleEn: 'Discount approval request: 15%', timeAgo: '3h', read: true },
|
||||
{ id: '6', type: 'deal_lost', titleAr: 'صفقة خاسرة: مشروع جدة', titleEn: 'Deal lost: Jeddah Project', timeAgo: '5h', read: true },
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function NotificationBell() {
|
||||
const { isArabic } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [notifications, setNotifications] = useState(initialNotifications);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const unreadCount = notifications.filter((n) => !n.read).length;
|
||||
const label = (ar: string, en: string) => (isArabic ? ar : en);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, []);
|
||||
|
||||
function markAllRead() {
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||
}
|
||||
|
||||
function markRead(id: string) {
|
||||
setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n)));
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
{/* Bell button */}
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="relative p-2 rounded-xl hover:bg-white/10 transition-colors"
|
||||
aria-label={label('الإشعارات', 'Notifications')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 text-slate-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
|
||||
</svg>
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-0.5 -end-0.5 min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-red-500 text-white text-[10px] font-bold px-1 leading-none">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.96 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute top-full mt-2 end-0 w-80 sm:w-96 max-h-[420px] rounded-xl bg-slate-900/95 backdrop-blur-2xl border border-white/10 shadow-2xl shadow-black/40 z-50 overflow-hidden flex flex-col"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10">
|
||||
<h3 className="text-sm font-semibold text-white">{label('الإشعارات', 'Notifications')}</h3>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllRead}
|
||||
className="text-xs text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
{label('تعيين الكل كمقروء', 'Mark all as read')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-sm text-slate-500">{label('لا توجد إشعارات جديدة', 'No new notifications')}</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((n) => {
|
||||
const cfg = typeConfig[n.type];
|
||||
return (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => markRead(n.id)}
|
||||
className={`w-full flex items-start gap-3 px-4 py-3 text-start hover:bg-white/5 transition-colors ${!n.read ? 'bg-white/[0.03]' : ''}`}
|
||||
>
|
||||
<span className={`shrink-0 w-8 h-8 rounded-lg flex items-center justify-center text-sm ${cfg.color}`}>
|
||||
{cfg.icon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm leading-snug ${n.read ? 'text-slate-400' : 'text-white'}`}>
|
||||
{label(n.titleAr, n.titleEn)}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">{n.timeAgo}</p>
|
||||
</div>
|
||||
{!n.read && (
|
||||
<span className="shrink-0 w-2 h-2 rounded-full bg-primary mt-2" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-white/10 px-4 py-2.5">
|
||||
<button className="w-full text-center text-xs text-primary hover:text-primary/80 transition-colors py-1">
|
||||
{label('عرض كل الإشعارات', 'View All Notifications')}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,377 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { clsx } from 'clsx';
|
||||
import {
|
||||
UserCircle, Building2, CheckCircle2, PartyPopper,
|
||||
Import, MessageCircle, GitBranch, Users,
|
||||
ChevronLeft, ChevronRight, Briefcase, Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
type Phase = 'welcome' | 'firstValue' | 'checklist';
|
||||
type Role = 'salesManager' | 'salesRep' | 'executive' | 'other';
|
||||
type Industry = 'realEstate' | 'automotive' | 'healthcare' | 'services' | 'other';
|
||||
|
||||
interface OnboardingFlowProps {
|
||||
onComplete?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/* ---------- Animation ---------- */
|
||||
const slideVariants = {
|
||||
enter: (dir: number) => ({ x: dir > 0 ? 80 : -80, opacity: 0 }),
|
||||
center: { x: 0, opacity: 1 },
|
||||
exit: (dir: number) => ({ x: dir > 0 ? -80 : 80, opacity: 0 }),
|
||||
};
|
||||
|
||||
/* ---------- Phase 1: Welcome ---------- */
|
||||
function WelcomePhase({
|
||||
role,
|
||||
setRole,
|
||||
industry,
|
||||
setIndustry,
|
||||
onNext,
|
||||
}: {
|
||||
role: Role | null;
|
||||
setRole: (r: Role) => void;
|
||||
industry: Industry | null;
|
||||
setIndustry: (i: Industry) => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
const { t, isArabic } = useI18n();
|
||||
const [step, setStep] = useState<'role' | 'industry'>('role');
|
||||
|
||||
const roles: { key: Role; icon: typeof UserCircle }[] = [
|
||||
{ key: 'salesManager', icon: UserCircle }, { key: 'salesRep', icon: Briefcase },
|
||||
{ key: 'executive', icon: Building2 }, { key: 'other', icon: Users },
|
||||
];
|
||||
const industries: { key: Industry; label: string }[] = [
|
||||
{ key: 'realEstate', label: t('onboarding.industryRealEstate') }, { key: 'automotive', label: t('onboarding.industryAutomotive') },
|
||||
{ key: 'healthcare', label: t('onboarding.industryHealthcare') }, { key: 'services', label: t('onboarding.industryServices') },
|
||||
{ key: 'other', label: t('onboarding.industryOther') },
|
||||
];
|
||||
const roleLabels: Record<Role, string> = {
|
||||
salesManager: t('onboarding.roleSalesManager'), salesRep: t('onboarding.roleSalesRep'),
|
||||
executive: t('onboarding.roleExecutive'), other: t('onboarding.roleOther'),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="text-center max-w-md mx-auto">
|
||||
<Sparkles className="h-8 w-8 text-teal-400 mx-auto mb-4" />
|
||||
<h1 className="text-2xl font-bold text-white mb-2">{t('onboarding.welcomeTitle')}</h1>
|
||||
<p className="text-sm text-slate-400 mb-8">{t('onboarding.welcomeSubtitle')}</p>
|
||||
|
||||
<AnimatePresence mode="wait" custom={1}>
|
||||
{step === 'role' ? (
|
||||
<motion.div
|
||||
key="role"
|
||||
custom={1}
|
||||
variants={slideVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<p className="text-sm font-medium text-slate-300 mb-4">{t('onboarding.roleQuestion')}</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{roles.map((r) => {
|
||||
const selected = role === r.key;
|
||||
return (
|
||||
<button key={r.key} onClick={() => { setRole(r.key); setTimeout(() => setStep('industry'), 300); }}
|
||||
className={clsx('flex flex-col items-center gap-2 p-4 rounded-xl border transition-all duration-200',
|
||||
selected ? 'bg-teal-500/15 border-teal-500/40 text-teal-300' : 'bg-white/5 border-white/10 text-slate-400 hover:bg-white/[0.08]')}>
|
||||
<r.icon className="h-5 w-5" />
|
||||
<span className="text-xs font-medium">{roleLabels[r.key]}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="industry"
|
||||
custom={1}
|
||||
variants={slideVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<p className="text-sm font-medium text-slate-300 mb-4">{t('onboarding.industryQuestion')}</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{industries.map((ind) => (
|
||||
<button key={ind.key} onClick={() => { setIndustry(ind.key); setTimeout(onNext, 400); }}
|
||||
className={clsx('flex items-center justify-center p-3.5 rounded-xl border text-xs font-medium transition-all duration-200',
|
||||
industry === ind.key ? 'bg-teal-500/15 border-teal-500/40 text-teal-300' : 'bg-white/5 border-white/10 text-slate-400 hover:bg-white/[0.08]')}>
|
||||
{ind.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setStep('role')}
|
||||
className="mt-4 text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
{isArabic ? (
|
||||
<span className="flex items-center gap-1 justify-center"><ChevronRight className="h-3 w-3" />{t('common.back')}</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 justify-center"><ChevronLeft className="h-3 w-3" />{t('common.back')}</span>
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Phase 2: First Value ---------- */
|
||||
function FirstValuePhase({ onNext }: { onNext: () => void }) {
|
||||
const { t, isArabic } = useI18n();
|
||||
const [created, setCreated] = useState(false);
|
||||
|
||||
const handleCreate = () => {
|
||||
setCreated(true);
|
||||
setTimeout(onNext, 1800);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="text-center max-w-md mx-auto">
|
||||
<h2 className="text-xl font-bold text-white mb-2">{t('onboarding.firstValueTitle')}</h2>
|
||||
<p className="text-sm text-slate-400 mb-8">{t('onboarding.firstValueSubtitle')}</p>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{!created ? (
|
||||
<motion.div
|
||||
key="form"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="rounded-xl bg-white/5 border border-white/10 p-6 text-start"
|
||||
>
|
||||
<div className="space-y-3 mb-6">
|
||||
<div>
|
||||
<label className="text-[11px] text-slate-500 mb-1 block">
|
||||
{isArabic ? 'اسم الصفقة' : 'Deal Name'}
|
||||
</label>
|
||||
<div className="rounded-lg bg-white/5 border border-white/10 px-3 py-2 text-sm text-slate-300">
|
||||
{t('onboarding.sampleDealName')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] text-slate-500 mb-1 block">
|
||||
{isArabic ? 'القيمة' : 'Value'}
|
||||
</label>
|
||||
<div className="rounded-lg bg-white/5 border border-white/10 px-3 py-2 text-sm text-slate-300">
|
||||
{isArabic ? 'ر.س' : 'SAR'} {t('onboarding.sampleDealValue')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] text-slate-500 mb-1 block">
|
||||
{isArabic ? 'جهة الاتصال' : 'Contact'}
|
||||
</label>
|
||||
<div className="rounded-lg bg-white/5 border border-white/10 px-3 py-2 text-sm text-slate-300">
|
||||
{t('onboarding.sampleContactName')} — {t('onboarding.sampleCompany')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.03 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
onClick={handleCreate}
|
||||
className={clsx(
|
||||
'w-full rounded-xl py-3 text-sm font-semibold',
|
||||
'bg-gradient-to-l from-teal-500 to-emerald-600 text-white',
|
||||
'hover:shadow-[0_0_24px_rgba(20,184,166,0.4)]',
|
||||
'transition-shadow',
|
||||
)}
|
||||
>
|
||||
{t('onboarding.createDeal')}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="celebration"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 15 }}
|
||||
className="py-10"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: [0, -10, 10, -5, 5, 0] }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<PartyPopper className="h-14 w-14 text-amber-400 mx-auto mb-4" />
|
||||
</motion.div>
|
||||
<p className="text-lg font-bold text-white">{t('onboarding.celebration')}</p>
|
||||
<p className="text-sm text-teal-400 mt-1">{t('onboarding.dealCreated')}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Phase 3: Checklist ---------- */
|
||||
interface ChecklistItem {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: typeof Import;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
function ChecklistPhase({ onComplete }: { onComplete?: () => void }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const [items, setItems] = useState<ChecklistItem[]>([
|
||||
{ key: 'import', label: t('onboarding.checkImportContacts'), icon: Import, done: false }, { key: 'whatsapp', label: t('onboarding.checkConnectWhatsApp'), icon: MessageCircle, done: false },
|
||||
{ key: 'pipeline', label: t('onboarding.checkSetupPipeline'), icon: GitBranch, done: false }, { key: 'team', label: t('onboarding.checkInviteTeam'), icon: Users, done: false },
|
||||
]);
|
||||
|
||||
const doneCount = items.filter((i) => i.done).length;
|
||||
const progress = Math.round((doneCount / items.length) * 100);
|
||||
|
||||
const toggleItem = useCallback((key: string) => {
|
||||
setItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.key === key ? { ...item, done: !item.done } : item,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="max-w-sm mx-auto">
|
||||
<h2 className="text-lg font-bold text-white mb-1">{t('onboarding.checklistTitle')}</h2>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="flex items-center gap-3 mb-6 mt-3">
|
||||
<div className="flex-1 h-2 rounded-full bg-white/10 overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-gradient-to-l from-teal-400 to-emerald-500"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 tabular-nums">
|
||||
{progress}% {t('onboarding.checklistProgress')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<ul className="space-y-2">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<motion.li key={item.key} whileHover={{ x: 2 }} onClick={() => toggleItem(item.key)}
|
||||
className={clsx('flex items-center gap-3 px-4 py-3 rounded-xl border transition-all duration-200 cursor-pointer',
|
||||
item.done ? 'bg-teal-500/10 border-teal-500/25' : 'bg-white/5 border-white/10 hover:bg-white/[0.08]')}>
|
||||
{item.done ? <CheckCircle2 className="h-5 w-5 text-teal-400 shrink-0" /> : <div className="h-5 w-5 rounded-full border-2 border-slate-600 shrink-0" />}
|
||||
<Icon className={clsx('h-4 w-4 shrink-0', item.done ? 'text-teal-400' : 'text-slate-500')} />
|
||||
<span className={clsx('text-sm flex-1', item.done ? 'text-teal-300 line-through' : 'text-slate-300')}>{item.label}</span>
|
||||
</motion.li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{progress === 100 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-6 text-center"
|
||||
>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.03 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
onClick={onComplete}
|
||||
className={clsx(
|
||||
'px-8 py-3 rounded-xl text-sm font-semibold',
|
||||
'bg-gradient-to-l from-teal-500 to-emerald-600 text-white',
|
||||
'hover:shadow-[0_0_24px_rgba(20,184,166,0.4)]',
|
||||
'transition-shadow',
|
||||
)}
|
||||
>
|
||||
{t('common.getStarted')}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Main Onboarding Flow ---------- */
|
||||
function OnboardingFlow({ onComplete, className }: OnboardingFlowProps) {
|
||||
const { dir } = useI18n();
|
||||
const [phase, setPhase] = useState<Phase>('welcome');
|
||||
const [direction, setDirection] = useState(1);
|
||||
const [role, setRole] = useState<Role | null>(null);
|
||||
const [industry, setIndustry] = useState<Industry | null>(null);
|
||||
|
||||
const goTo = useCallback((next: Phase) => {
|
||||
const order: Phase[] = ['welcome', 'firstValue', 'checklist'];
|
||||
setDirection(order.indexOf(next) > order.indexOf(phase) ? 1 : -1);
|
||||
setPhase(next);
|
||||
}, [phase]);
|
||||
|
||||
const phases: Phase[] = ['welcome', 'firstValue', 'checklist'];
|
||||
const currentIdx = phases.indexOf(phase);
|
||||
|
||||
return (
|
||||
<div
|
||||
dir={dir}
|
||||
className={clsx(
|
||||
'min-h-[480px] flex flex-col items-center justify-center px-4 py-12',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Phase indicators */}
|
||||
<div className="flex items-center gap-2 mb-10">
|
||||
{phases.map((p, i) => (
|
||||
<div
|
||||
key={p}
|
||||
className={clsx(
|
||||
'h-1.5 rounded-full transition-all duration-300',
|
||||
i === currentIdx ? 'w-8 bg-teal-400' : i < currentIdx ? 'w-4 bg-teal-600' : 'w-4 bg-slate-700',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<AnimatePresence mode="wait" custom={direction}>
|
||||
<motion.div
|
||||
key={phase}
|
||||
custom={direction}
|
||||
variants={slideVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 28 }}
|
||||
className="w-full max-w-lg"
|
||||
>
|
||||
{phase === 'welcome' && (
|
||||
<WelcomePhase
|
||||
role={role}
|
||||
setRole={setRole}
|
||||
industry={industry}
|
||||
setIndustry={setIndustry}
|
||||
onNext={() => goTo('firstValue')}
|
||||
/>
|
||||
)}
|
||||
{phase === 'firstValue' && (
|
||||
<FirstValuePhase onNext={() => goTo('checklist')} />
|
||||
)}
|
||||
{phase === 'checklist' && (
|
||||
<ChecklistPhase onComplete={onComplete} />
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { OnboardingFlow };
|
||||
export type { OnboardingFlowProps };
|
||||
@ -0,0 +1,301 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence, Reorder } from "framer-motion";
|
||||
import {
|
||||
GripVertical,
|
||||
Building2,
|
||||
User,
|
||||
Clock,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
TrendingUp,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
/* ───────────── types ───────────── */
|
||||
interface Deal {
|
||||
id: string;
|
||||
company: string;
|
||||
value: number;
|
||||
rep: string;
|
||||
daysInStage: number;
|
||||
note?: string;
|
||||
probability: number;
|
||||
}
|
||||
|
||||
interface Stage {
|
||||
id: string;
|
||||
label: string;
|
||||
color: string; // tailwind ring/border colour
|
||||
headerBg: string; // gradient header
|
||||
dotColor: string;
|
||||
deals: Deal[];
|
||||
}
|
||||
|
||||
/* ───────────── sample data ───────────── */
|
||||
const initialStages: Stage[] = [
|
||||
{
|
||||
id: "new",
|
||||
label: "جديد",
|
||||
color: "border-blue-500",
|
||||
headerBg: "from-blue-600 to-blue-400",
|
||||
dotColor: "bg-blue-500",
|
||||
deals: [
|
||||
{ id: "d1", company: "شركة الأفق التقنية", value: 45_000, rep: "سالم", daysInStage: 2, probability: 10, note: "تواصل أولي عبر واتساب" },
|
||||
{ id: "d2", company: "مؤسسة الوفاء", value: 22_000, rep: "نورة", daysInStage: 5, probability: 15 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "negotiation",
|
||||
label: "تفاوض",
|
||||
color: "border-yellow-500",
|
||||
headerBg: "from-yellow-500 to-amber-400",
|
||||
dotColor: "bg-yellow-500",
|
||||
deals: [
|
||||
{ id: "d3", company: "مجموعة الرواد", value: 125_000, rep: "فهد", daysInStage: 8, probability: 45, note: "اجتماع مع المدير التنفيذي يوم الأحد" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "proposal",
|
||||
label: "عرض سعر",
|
||||
color: "border-orange-500",
|
||||
headerBg: "from-orange-500 to-orange-400",
|
||||
dotColor: "bg-orange-500",
|
||||
deals: [
|
||||
{ id: "d4", company: "مصنع الشرق", value: 310_000, rep: "سالم", daysInStage: 3, probability: 60 },
|
||||
{ id: "d5", company: "حلول البيانات", value: 88_000, rep: "نورة", daysInStage: 12, probability: 55, note: "بانتظار موافقة المشتريات" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "won",
|
||||
label: "فوز",
|
||||
color: "border-emerald-500",
|
||||
headerBg: "from-emerald-500 to-green-400",
|
||||
dotColor: "bg-emerald-500",
|
||||
deals: [
|
||||
{ id: "d6", company: "شركة النخبة", value: 200_000, rep: "فهد", daysInStage: 0, probability: 100 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "lost",
|
||||
label: "خسارة",
|
||||
color: "border-red-500",
|
||||
headerBg: "from-red-500 to-rose-400",
|
||||
dotColor: "bg-red-500",
|
||||
deals: [
|
||||
{ id: "d7", company: "مؤسسة السلام", value: 60_000, rep: "نورة", daysInStage: 0, probability: 0, note: "اختاروا منافس أرخص" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const fmt = (n: number) =>
|
||||
new Intl.NumberFormat("ar-SA", { maximumFractionDigits: 0 }).format(n);
|
||||
|
||||
/* ───────────── progress dots ───────────── */
|
||||
const stageOrder = ["new", "negotiation", "proposal", "won", "lost"];
|
||||
function ProgressDots({ stageId }: { stageId: string }) {
|
||||
const idx = stageOrder.indexOf(stageId);
|
||||
const isLost = stageId === "lost";
|
||||
return (
|
||||
<div className="flex gap-1 mt-2">
|
||||
{stageOrder.slice(0, 4).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`h-1.5 rounded-full transition-all ${
|
||||
isLost
|
||||
? "w-3 bg-red-500/40"
|
||||
: i <= idx
|
||||
? "w-5 bg-emerald-400"
|
||||
: "w-3 bg-white/10"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────── deal card ───────────── */
|
||||
function DealCard({ deal, stageId }: { deal: Deal; stageId: string }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
drag="y"
|
||||
dragConstraints={{ top: 0, bottom: 0 }}
|
||||
dragElastic={0.12}
|
||||
className="group relative cursor-grab active:cursor-grabbing rounded-2xl bg-white/[0.04] backdrop-blur-xl border border-white/[0.08] p-4 shadow-lg hover:border-white/20 transition-colors"
|
||||
>
|
||||
{/* drag handle */}
|
||||
<GripVertical className="absolute top-4 left-2 w-4 h-4 text-white/20 group-hover:text-white/40 transition-colors" />
|
||||
|
||||
{/* header */}
|
||||
<div className="flex items-start justify-between gap-2 pr-0 pl-5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-white/40 shrink-0" />
|
||||
<h4 className="font-bold text-sm truncate">{deal.company}</h4>
|
||||
</div>
|
||||
<p className="text-lg font-black mt-1 tracking-tight text-teal-400">
|
||||
{fmt(deal.value)} <span className="text-xs font-medium text-white/40">ر.س</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="p-1 rounded-lg hover:bg-white/10 transition-colors"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-white/50" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-white/50" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* meta row */}
|
||||
<div className="flex items-center gap-3 mt-3 text-[11px] text-white/50">
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
{deal.rep}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{deal.daysInStage} يوم
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
{deal.probability}٪
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ProgressDots stageId={stageId} />
|
||||
|
||||
{/* expanded details */}
|
||||
<AnimatePresence>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-3 pt-3 border-t border-white/10 text-xs text-white/60 space-y-2">
|
||||
{deal.note && <p>{deal.note}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button className="flex-1 py-1.5 rounded-lg bg-teal-500/20 text-teal-300 hover:bg-teal-500/30 transition-colors font-medium">
|
||||
فتح الصفقة
|
||||
</button>
|
||||
<button className="flex-1 py-1.5 rounded-lg bg-white/5 hover:bg-white/10 transition-colors font-medium">
|
||||
تعديل
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────── empty state ───────────── */
|
||||
function EmptyColumn() {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center rounded-2xl border-2 border-dashed border-white/10 p-6 text-center">
|
||||
<p className="text-sm text-white/30 font-medium">لا توجد صفقات</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────── column ───────────── */
|
||||
function StageColumn({ stage }: { stage: Stage }) {
|
||||
const total = stage.deals.reduce((s, d) => s + d.value, 0);
|
||||
const [deals, setDeals] = useState(stage.deals);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-w-[280px] w-[280px] shrink-0 lg:min-w-0 lg:w-auto lg:flex-1">
|
||||
{/* header */}
|
||||
<div
|
||||
className={`rounded-2xl bg-gradient-to-l ${stage.headerBg} p-4 mb-3 shadow-lg`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-black/60 bg-black/10 px-2 py-0.5 rounded-full">
|
||||
{deals.length}
|
||||
</span>
|
||||
<h3 className="font-black text-black text-base">{stage.label}</h3>
|
||||
</div>
|
||||
<p className="text-left text-sm font-bold text-black/70 mt-1">
|
||||
{fmt(total)} <span className="text-[10px]">ر.س</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* cards */}
|
||||
<div className="flex-1 space-y-3 overflow-y-auto max-h-[calc(100vh-260px)] pe-1 scrollbar-thin">
|
||||
{deals.length === 0 ? (
|
||||
<EmptyColumn />
|
||||
) : (
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={deals}
|
||||
onReorder={setDeals}
|
||||
className="space-y-3"
|
||||
>
|
||||
{deals.map((deal) => (
|
||||
<Reorder.Item key={deal.id} value={deal}>
|
||||
<DealCard deal={deal} stageId={stage.id} />
|
||||
</Reorder.Item>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────── main component ───────────── */
|
||||
export function PipelineKanban() {
|
||||
const [stages] = useState<Stage[]>(initialStages);
|
||||
|
||||
return (
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
className="w-full"
|
||||
>
|
||||
{/* title bar */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-black">خط الصفقات</h2>
|
||||
<p className="text-sm text-white/40 mt-0.5">
|
||||
إجمالي: {fmt(stages.flatMap((s) => s.deals).reduce((a, d) => a + d.value, 0))} ر.س
|
||||
| {stages.flatMap((s) => s.deals).length} صفقة
|
||||
</p>
|
||||
</div>
|
||||
<button className="px-4 py-2 rounded-xl bg-teal-500/20 text-teal-300 text-sm font-bold hover:bg-teal-500/30 transition-colors">
|
||||
+ صفقة جديدة
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* kanban board */}
|
||||
<div className="flex gap-4 overflow-x-auto pb-4 -mx-2 px-2 snap-x snap-mandatory lg:snap-none">
|
||||
{stages.map((stage, i) => (
|
||||
<motion.div
|
||||
key={stage.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.08, duration: 0.4 }}
|
||||
className="snap-start flex flex-col min-w-[280px] w-[280px] shrink-0 lg:min-w-0 lg:w-auto lg:flex-1"
|
||||
>
|
||||
<StageColumn stage={stage} />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,474 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { motion, useInView } from "framer-motion";
|
||||
import {
|
||||
Zap,
|
||||
MessageSquare,
|
||||
BarChart3,
|
||||
FileText,
|
||||
ShieldCheck,
|
||||
BrainCircuit,
|
||||
ChevronLeft,
|
||||
Play,
|
||||
CheckCircle2,
|
||||
ArrowLeft,
|
||||
Star,
|
||||
Users,
|
||||
Trophy,
|
||||
Rocket,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
XCircle,
|
||||
Building2,
|
||||
} from "lucide-react";
|
||||
|
||||
/* ───────────── animation helpers ───────────── */
|
||||
const fadeUp = {
|
||||
hidden: { opacity: 0, y: 28 },
|
||||
visible: (i: number = 0) => ({
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { delay: i * 0.1, duration: 0.55, ease: "easeOut" },
|
||||
}),
|
||||
};
|
||||
|
||||
const stagger = {
|
||||
visible: { transition: { staggerChildren: 0.1 } },
|
||||
};
|
||||
|
||||
function Section({
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const ref = useRef(null);
|
||||
const inView = useInView(ref, { once: true, margin: "-60px" });
|
||||
return (
|
||||
<motion.section
|
||||
ref={ref}
|
||||
initial="hidden"
|
||||
animate={inView ? "visible" : "hidden"}
|
||||
variants={stagger}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────── data ───────────── */
|
||||
const painPoints = [
|
||||
{ icon: AlertTriangle, title: "بيانات مبعثرة", desc: "معلومات العملاء موزعة بين إكسل وواتساب وأوراق" },
|
||||
{ icon: Clock, title: "وقت ضائع", desc: "فريق المبيعات يقضي ٦٠٪ من وقته في مهام يدوية" },
|
||||
{ icon: XCircle, title: "صفقات تضيع", desc: "عدم متابعة العملاء المحتملين في الوقت المناسب" },
|
||||
{ icon: BarChart3, title: "لا تقارير واضحة", desc: "صعوبة قياس أداء الفريق واتخاذ قرارات مبنية على بيانات" },
|
||||
];
|
||||
|
||||
const features = [
|
||||
{ icon: MessageSquare, title: "واتساب ذكي", desc: "تواصل تلقائي مع العملاء عبر واتساب مع ردود الذكاء الاصطناعي", color: "text-green-400 bg-green-400/10" },
|
||||
{ icon: BrainCircuit, title: "تقييم عملاء AI", desc: "تقييم تلقائي لكل عميل محتمل بناءً على سلوكه واهتمامه", color: "text-teal-400 bg-teal-400/10" },
|
||||
{ icon: BarChart3, title: "Pipeline بصري", desc: "تتبع جميع الصفقات بلوحة كانبان تفاعلية مع drag & drop", color: "text-blue-400 bg-blue-400/10" },
|
||||
{ icon: FileText, title: "عروض أسعار", desc: "أنشئ عروض أسعار احترافية بضغطة زر مع حسابات تلقائية", color: "text-orange-400 bg-orange-400/10" },
|
||||
{ icon: ShieldCheck, title: "متوافق مع PDPL", desc: "حماية بيانات العملاء وفق نظام حماية البيانات الشخصية السعودي", color: "text-purple-400 bg-purple-400/10" },
|
||||
{ icon: Zap, title: "تقارير ذكية", desc: "تحليلات فورية ولوحات بيانات تفاعلية لاتخاذ قرارات أسرع", color: "text-amber-400 bg-amber-400/10" },
|
||||
];
|
||||
|
||||
const steps = [
|
||||
{ num: "١", title: "سجّل", desc: "أنشئ حسابك في أقل من دقيقتين وابدأ فوراً" },
|
||||
{ num: "٢", title: "أضف عملاءك", desc: "استورد بياناتك من إكسل أو أضفها يدوياً بسهولة" },
|
||||
{ num: "٣", title: "ابدأ البيع", desc: "دع الذكاء الاصطناعي يساعدك في إتمام المزيد من الصفقات" },
|
||||
];
|
||||
|
||||
const pricingPlans = [
|
||||
{
|
||||
name: "Starter",
|
||||
nameAr: "الأساسية",
|
||||
price: "٥٩",
|
||||
period: "شهرياً",
|
||||
features: ["٣ مستخدمين", "٥٠٠ عميل محتمل", "واتساب أساسي", "تقارير أساسية", "دعم بالإيميل"],
|
||||
cta: "ابدأ مجاناً",
|
||||
highlighted: false,
|
||||
},
|
||||
{
|
||||
name: "Professional",
|
||||
nameAr: "الاحترافية",
|
||||
price: "١٤٩",
|
||||
period: "شهرياً",
|
||||
features: ["١٠ مستخدمين", "عملاء غير محدودين", "واتساب + إيميل + SMS", "تقييم AI للعملاء", "Pipeline بصري", "عروض أسعار", "تقارير متقدمة", "دعم أولوية"],
|
||||
cta: "ابدأ التجربة المجانية",
|
||||
highlighted: true,
|
||||
badge: "الأكثر شعبية",
|
||||
},
|
||||
{
|
||||
name: "Enterprise",
|
||||
nameAr: "المؤسسية",
|
||||
price: "٢٢٥",
|
||||
period: "شهرياً",
|
||||
features: ["مستخدمين غير محدودين", "كل مميزات الاحترافية", "PDPL كامل", "API مفتوح", "مدير حساب مخصص", "تدريب الفريق", "SLA ٩٩.٩٪"],
|
||||
cta: "تواصل معنا",
|
||||
highlighted: false,
|
||||
},
|
||||
];
|
||||
|
||||
/* ───────────── 3D Logo placeholder ───────────── */
|
||||
function DealixLogo3D() {
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ y: [0, -12, 0] }}
|
||||
transition={{ duration: 5, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="relative w-48 h-48 md:w-64 md:h-64"
|
||||
>
|
||||
<div className="absolute inset-0 rounded-3xl bg-gradient-to-br from-teal-500 via-emerald-500 to-cyan-400 opacity-80 blur-2xl animate-pulse" />
|
||||
<div className="relative w-full h-full rounded-3xl bg-gradient-to-br from-teal-500 via-emerald-500 to-cyan-400 flex items-center justify-center shadow-2xl shadow-teal-500/30 border border-white/20">
|
||||
<div className="text-center">
|
||||
<Zap className="w-12 h-12 md:w-16 md:h-16 text-black mx-auto" />
|
||||
<span className="text-xl md:text-2xl font-black text-black tracking-tighter mt-2 block">DEALIX</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────── main component ───────────── */
|
||||
export function PremiumLanding() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground overflow-hidden font-sans" dir="rtl">
|
||||
{/* ── mesh background ── */}
|
||||
<div className="fixed inset-0 -z-10">
|
||||
<div className="absolute top-0 right-0 w-[700px] h-[700px] bg-teal-500/10 rounded-full blur-[160px] -translate-y-1/3 translate-x-1/4" />
|
||||
<div className="absolute bottom-0 left-0 w-[500px] h-[500px] bg-cyan-500/8 rounded-full blur-[120px] translate-y-1/3 -translate-x-1/4" />
|
||||
<div className="absolute top-1/2 left-1/2 w-[400px] h-[400px] bg-emerald-500/5 rounded-full blur-[100px] -translate-x-1/2 -translate-y-1/2" />
|
||||
{/* mesh dots */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.03]"
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(circle, rgba(255,255,255,0.8) 1px, transparent 1px)",
|
||||
backgroundSize: "32px 32px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ═══════════ NAV ═══════════ */}
|
||||
<nav className="relative z-10 flex items-center justify-between px-6 md:px-12 py-5 max-w-7xl mx-auto">
|
||||
<button className="px-5 py-2 rounded-xl bg-teal-500 text-black font-bold text-sm hover:bg-teal-400 transition-colors shadow-lg shadow-teal-500/20">
|
||||
ابدأ مجاناً
|
||||
</button>
|
||||
<div className="hidden md:flex items-center gap-8 text-sm font-medium text-white/60">
|
||||
<a href="#pricing" className="hover:text-white transition-colors">الأسعار</a>
|
||||
<a href="#features" className="hover:text-white transition-colors">المميزات</a>
|
||||
<a href="#how" className="hover:text-white transition-colors">كيف يعمل</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-tr from-teal-500 to-emerald-400 flex items-center justify-center">
|
||||
<Zap className="w-4 h-4 text-black" />
|
||||
</div>
|
||||
<span className="text-lg font-black tracking-tighter">DEALIX</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ═══════════ HERO ═══════════ */}
|
||||
<Section className="relative z-10 max-w-7xl mx-auto px-6 md:px-12 pt-16 md:pt-24 pb-20">
|
||||
<div className="flex flex-col-reverse md:flex-row items-center gap-12 md:gap-8">
|
||||
{/* left: 3D logo */}
|
||||
<motion.div variants={fadeUp} custom={2} className="shrink-0">
|
||||
<DealixLogo3D />
|
||||
</motion.div>
|
||||
|
||||
{/* right: text */}
|
||||
<div className="flex-1 text-right">
|
||||
<motion.h1
|
||||
variants={fadeUp}
|
||||
custom={0}
|
||||
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black leading-tight mb-6"
|
||||
>
|
||||
نظام المبيعات الذكي
|
||||
<br />
|
||||
<span className="text-teal-400">للسعودية</span>
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
variants={fadeUp}
|
||||
custom={1}
|
||||
className="text-lg md:text-xl text-white/60 mb-8 max-w-xl leading-relaxed"
|
||||
>
|
||||
وحّد فريق مبيعاتك مع واتساب، أتمت المتابعة بالذكاء الاصطناعي، وتابع كل صفقة من البداية للإغلاق
|
||||
</motion.p>
|
||||
<motion.div variants={fadeUp} custom={2} className="flex flex-wrap gap-4">
|
||||
<button className="px-8 py-4 rounded-2xl bg-teal-500 text-black font-black text-base hover:bg-teal-400 transition-all shadow-xl shadow-teal-500/25 flex items-center gap-2">
|
||||
ابدأ مجاناً
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<button className="px-8 py-4 rounded-2xl bg-white/5 border border-white/10 font-bold text-base hover:bg-white/10 transition-all flex items-center gap-2">
|
||||
<Play className="w-4 h-4" />
|
||||
شاهد العرض
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* stats bar */}
|
||||
<motion.div
|
||||
variants={fadeUp}
|
||||
custom={3}
|
||||
className="mt-20 grid grid-cols-3 gap-4 max-w-2xl mx-auto"
|
||||
>
|
||||
{[
|
||||
{ label: "شركة سعودية", value: "+٥٠٠" },
|
||||
{ label: "رضا العملاء", value: "٩٥٪" },
|
||||
{ label: "صفقة مغلقة", value: "+١٠٠٠" },
|
||||
].map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-center py-4 px-3 rounded-2xl bg-white/[0.03] border border-white/[0.06]"
|
||||
>
|
||||
<p className="text-2xl md:text-3xl font-black text-teal-400">{s.value}</p>
|
||||
<p className="text-xs text-white/40 font-medium mt-1">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</Section>
|
||||
|
||||
{/* ═══════════ PAIN POINTS ═══════════ */}
|
||||
<Section className="max-w-7xl mx-auto px-6 md:px-12 py-20">
|
||||
<motion.h2 variants={fadeUp} className="text-3xl md:text-4xl font-black text-center mb-4">
|
||||
مشاكل يعاني منها كل مدير مبيعات
|
||||
</motion.h2>
|
||||
<motion.p variants={fadeUp} custom={1} className="text-center text-white/50 mb-12 max-w-lg mx-auto">
|
||||
هل تواجه هذه التحديات في فريقك؟ Dealix صُمم لحلها
|
||||
</motion.p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
{painPoints.map((p, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
variants={fadeUp}
|
||||
custom={i}
|
||||
className="rounded-2xl bg-white/[0.03] backdrop-blur-xl border border-white/[0.08] p-6 text-right hover:border-red-500/30 hover:bg-red-500/[0.03] transition-all group"
|
||||
>
|
||||
<div className="w-11 h-11 rounded-xl bg-red-500/10 flex items-center justify-center mb-4 mr-auto group-hover:bg-red-500/20 transition-colors">
|
||||
<p.icon className="w-5 h-5 text-red-400" />
|
||||
</div>
|
||||
<h3 className="font-bold text-base mb-2">{p.title}</h3>
|
||||
<p className="text-sm text-white/50 leading-relaxed">{p.desc}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ═══════════ FEATURES ═══════════ */}
|
||||
<Section id="features" className="max-w-7xl mx-auto px-6 md:px-12 py-20">
|
||||
<motion.h2 variants={fadeUp} className="text-3xl md:text-4xl font-black text-center mb-4">
|
||||
كل ما يحتاجه فريق المبيعات
|
||||
</motion.h2>
|
||||
<motion.p variants={fadeUp} custom={1} className="text-center text-white/50 mb-12 max-w-lg mx-auto">
|
||||
أدوات متكاملة مصممة خصيصاً للسوق السعودي
|
||||
</motion.p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{features.map((f, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
variants={fadeUp}
|
||||
custom={i}
|
||||
whileHover={{ y: -4 }}
|
||||
className="rounded-2xl bg-white/[0.03] backdrop-blur-xl border border-white/[0.08] p-6 text-right hover:border-teal-500/30 hover:shadow-lg hover:shadow-teal-500/5 transition-all group cursor-default"
|
||||
>
|
||||
<div className={`w-11 h-11 rounded-xl ${f.color.split(" ")[1]} flex items-center justify-center mb-4 mr-auto`}>
|
||||
<f.icon className={`w-5 h-5 ${f.color.split(" ")[0]}`} />
|
||||
</div>
|
||||
<h3 className="font-bold text-base mb-2">{f.title}</h3>
|
||||
<p className="text-sm text-white/50 leading-relaxed">{f.desc}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ═══════════ HOW IT WORKS ═══════════ */}
|
||||
<Section id="how" className="max-w-4xl mx-auto px-6 md:px-12 py-20">
|
||||
<motion.h2 variants={fadeUp} className="text-3xl md:text-4xl font-black text-center mb-14">
|
||||
ابدأ في ٣ خطوات بسيطة
|
||||
</motion.h2>
|
||||
<div className="relative">
|
||||
{/* connecting line */}
|
||||
<div className="hidden md:block absolute top-10 right-10 left-10 h-0.5 bg-gradient-to-l from-teal-500/50 via-teal-500/20 to-teal-500/50" />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{steps.map((s, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
variants={fadeUp}
|
||||
custom={i}
|
||||
className="flex flex-col items-center text-center"
|
||||
>
|
||||
<div className="relative z-10 w-20 h-20 rounded-full bg-teal-500/10 border-2 border-teal-500/30 flex items-center justify-center mb-5">
|
||||
<span className="text-3xl font-black text-teal-400">{s.num}</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-lg mb-2">{s.title}</h3>
|
||||
<p className="text-sm text-white/50 leading-relaxed max-w-[240px]">{s.desc}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ═══════════ SOCIAL PROOF ═══════════ */}
|
||||
<Section className="max-w-7xl mx-auto px-6 md:px-12 py-20">
|
||||
<motion.h2 variants={fadeUp} className="text-3xl md:text-4xl font-black text-center mb-12">
|
||||
شركات سعودية تثق بـ Dealix
|
||||
</motion.h2>
|
||||
|
||||
{/* logos row */}
|
||||
<motion.div variants={fadeUp} custom={1} className="flex items-center justify-center gap-8 md:gap-14 mb-14 flex-wrap">
|
||||
{["الأفق التقنية", "مجموعة الرواد", "حلول البيانات", "شركة النخبة", "مصنع الشرق"].map((name, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-white/20 hover:text-white/40 transition-colors">
|
||||
<Building2 className="w-5 h-5" />
|
||||
<span className="font-bold text-sm">{name}</span>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* testimonial */}
|
||||
<motion.div
|
||||
variants={fadeUp}
|
||||
custom={2}
|
||||
className="max-w-2xl mx-auto rounded-3xl bg-white/[0.03] backdrop-blur-xl border border-white/[0.08] p-8 text-center"
|
||||
>
|
||||
<div className="flex justify-center gap-1 mb-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star key={i} className="w-5 h-5 text-amber-400 fill-amber-400" />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-lg text-white/80 leading-relaxed mb-6">
|
||||
“Dealix غيّر طريقة عمل فريق المبيعات عندنا بالكامل. من أول شهر زادت مبيعاتنا ٤٠٪ وصار عندنا رؤية واضحة لكل صفقة.”
|
||||
</p>
|
||||
<div>
|
||||
<p className="font-bold">عبدالله الشمري</p>
|
||||
<p className="text-sm text-white/40">مدير المبيعات — شركة الأفق التقنية</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Section>
|
||||
|
||||
{/* ═══════════ PRICING ═══════════ */}
|
||||
<Section id="pricing" className="max-w-7xl mx-auto px-6 md:px-12 py-20">
|
||||
<motion.h2 variants={fadeUp} className="text-3xl md:text-4xl font-black text-center mb-4">
|
||||
أسعار بسيطة وشفافة
|
||||
</motion.h2>
|
||||
<motion.p variants={fadeUp} custom={1} className="text-center text-white/50 mb-12 max-w-lg mx-auto">
|
||||
ابدأ مجاناً لمدة ١٤ يوم — بدون بطاقة ائتمانية
|
||||
</motion.p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 items-start">
|
||||
{pricingPlans.map((plan, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
variants={fadeUp}
|
||||
custom={i}
|
||||
whileHover={{ y: -4 }}
|
||||
className={`relative rounded-3xl p-7 text-right transition-all ${
|
||||
plan.highlighted
|
||||
? "bg-teal-500/10 border-2 border-teal-500/40 shadow-xl shadow-teal-500/10"
|
||||
: "bg-white/[0.03] border border-white/[0.08]"
|
||||
}`}
|
||||
>
|
||||
{plan.badge && (
|
||||
<span className="absolute -top-3 left-1/2 -translate-x-1/2 px-4 py-1 rounded-full bg-teal-500 text-black text-xs font-black">
|
||||
{plan.badge}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-white/50 font-medium">{plan.nameAr}</p>
|
||||
<h3 className="text-sm font-bold text-white/70 mt-1">{plan.name}</h3>
|
||||
|
||||
<div className="flex items-baseline gap-1 mt-4 mb-6">
|
||||
<span className="text-4xl font-black text-white">{plan.price}</span>
|
||||
<span className="text-sm text-white/40 font-medium">ر.س / {plan.period}</span>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3 mb-8">
|
||||
{plan.features.map((f, j) => (
|
||||
<li key={j} className="flex items-center gap-2 text-sm text-white/70">
|
||||
<CheckCircle2 className={`w-4 h-4 shrink-0 ${plan.highlighted ? "text-teal-400" : "text-white/30"}`} />
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
className={`w-full py-3 rounded-xl font-bold text-sm transition-all ${
|
||||
plan.highlighted
|
||||
? "bg-teal-500 text-black hover:bg-teal-400 shadow-lg shadow-teal-500/20"
|
||||
: "bg-white/5 border border-white/10 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
{plan.cta}
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ═══════════ FINAL CTA ═══════════ */}
|
||||
<Section className="max-w-4xl mx-auto px-6 md:px-12 py-24 text-center">
|
||||
<motion.div
|
||||
variants={fadeUp}
|
||||
className="rounded-3xl bg-gradient-to-br from-teal-500/20 via-teal-500/5 to-transparent border border-teal-500/20 p-10 md:p-16"
|
||||
>
|
||||
<Rocket className="w-12 h-12 text-teal-400 mx-auto mb-6" />
|
||||
<h2 className="text-3xl md:text-4xl font-black mb-4">
|
||||
جاهز تنقل مبيعاتك للمستوى التالي؟
|
||||
</h2>
|
||||
<p className="text-white/50 mb-8 max-w-md mx-auto">
|
||||
انضم لأكثر من ٥٠٠ شركة سعودية حققت نمو في المبيعات مع Dealix
|
||||
</p>
|
||||
<button className="px-10 py-4 rounded-2xl bg-teal-500 text-black font-black text-lg hover:bg-teal-400 transition-all shadow-xl shadow-teal-500/25 mb-4">
|
||||
ابدأ مجاناً الآن
|
||||
</button>
|
||||
<p className="text-sm text-white/40">١٤ يوم تجربة مجانية — بدون بطاقة</p>
|
||||
</motion.div>
|
||||
</Section>
|
||||
|
||||
{/* ═══════════ FOOTER ═══════════ */}
|
||||
<footer className="border-t border-white/[0.06] bg-white/[0.01]">
|
||||
<div className="max-w-7xl mx-auto px-6 md:px-12 py-12">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-8">
|
||||
{/* logo */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-tr from-teal-500 to-emerald-400 flex items-center justify-center">
|
||||
<Zap className="w-4 h-4 text-black" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-black tracking-tighter">DEALIX</span>
|
||||
<p className="text-[11px] text-white/30">نظام المبيعات الذكي للسعودية</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* links */}
|
||||
<div className="flex items-center gap-6 text-sm text-white/40">
|
||||
<a href="#features" className="hover:text-white/70 transition-colors">المنتج</a>
|
||||
<a href="#pricing" className="hover:text-white/70 transition-colors">الأسعار</a>
|
||||
<a href="#" className="hover:text-white/70 transition-colors">عن Dealix</a>
|
||||
<a href="#" className="hover:text-white/70 transition-colors">تواصل</a>
|
||||
</div>
|
||||
|
||||
{/* social placeholders */}
|
||||
<div className="flex items-center gap-3">
|
||||
{["X", "in", "yt"].map((s, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className="w-9 h-9 rounded-lg bg-white/5 border border-white/10 flex items-center justify-center text-xs font-bold text-white/30 hover:text-white/60 hover:bg-white/10 transition-all"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-white/[0.06] flex flex-col md:flex-row items-center justify-between gap-4 text-xs text-white/30">
|
||||
<p>جميع الحقوق محفوظة Dealix {new Date().getFullYear()}</p>
|
||||
<p>صنع بـ ❤️ في السعودية</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,364 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { clsx } from 'clsx';
|
||||
import {
|
||||
Users, CalendarPlus, Briefcase, TrendingUp, Clock, Zap,
|
||||
CheckCircle2, Circle, AlertTriangle, MessageSquare, Phone,
|
||||
ArrowUpRight, Sparkles, ChevronLeft, ChevronRight,
|
||||
FileText, InboxIcon,
|
||||
} from 'lucide-react';
|
||||
import { useI18n } from '@/i18n';
|
||||
import { KpiCard } from '@/components/ui/kpi-card';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
dueStatus: 'overdue' | 'today' | 'upcoming';
|
||||
time?: string;
|
||||
}
|
||||
|
||||
interface Deal {
|
||||
id: string;
|
||||
name: string;
|
||||
value: number;
|
||||
stage: string;
|
||||
stageColor: string;
|
||||
}
|
||||
|
||||
interface Activity {
|
||||
id: string;
|
||||
type: 'message' | 'call' | 'dealUpdate' | 'noteAdded';
|
||||
text: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
interface AiInsight {
|
||||
id: string;
|
||||
type: 'followUp' | 'closing' | 'risk';
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface SalesWorkspaceProps {
|
||||
userName?: string;
|
||||
kpis?: {
|
||||
totalLeads: number;
|
||||
newToday: number;
|
||||
openDeals: number;
|
||||
wonValue: number;
|
||||
conversionRate: number;
|
||||
responseTime: number;
|
||||
};
|
||||
tasks?: Task[];
|
||||
deals?: Deal[];
|
||||
activities?: Activity[];
|
||||
insights?: AiInsight[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/* ---------- Demo data ---------- */
|
||||
const demoKpis = {
|
||||
totalLeads: 1247,
|
||||
newToday: 18,
|
||||
openDeals: 43,
|
||||
wonValue: 892500,
|
||||
conversionRate: 34,
|
||||
responseTime: 12,
|
||||
};
|
||||
|
||||
const demoTasks: Task[] = [
|
||||
{ id: '1', title: 'متابعة أحمد الشمري — عرض عقار', dueStatus: 'overdue', time: 'أمس' },
|
||||
{ id: '2', title: 'اتصال مع نورة — عرض سعر', dueStatus: 'today', time: '2:00 م' },
|
||||
{ id: '3', title: 'إرسال عقد لشركة المستقبل', dueStatus: 'today', time: '4:30 م' },
|
||||
{ id: '4', title: 'جدولة عرض تقديمي', dueStatus: 'upcoming', time: 'غداً' },
|
||||
];
|
||||
|
||||
const demoDeals: Deal[] = [
|
||||
{ id: '1', name: 'صفقة أبراج الرياض', value: 2500000, stage: 'تفاوض', stageColor: 'bg-amber-500' },
|
||||
{ id: '2', name: 'مشروع المجمع التجاري', value: 1800000, stage: 'عرض سعر', stageColor: 'bg-teal-500' },
|
||||
{ id: '3', name: 'فيلا حي النرجس', value: 950000, stage: 'مؤهّل', stageColor: 'bg-blue-500' },
|
||||
{ id: '4', name: 'مكاتب طريق الملك', value: 780000, stage: 'تفاوض', stageColor: 'bg-amber-500' },
|
||||
{ id: '5', name: 'شقق حي الملقا', value: 650000, stage: 'عرض سعر', stageColor: 'bg-teal-500' },
|
||||
];
|
||||
|
||||
const demoActivities: Activity[] = [
|
||||
{ id: '1', type: 'message', text: 'رسالة من أحمد: "ابي تفاصيل العرض"', time: 'منذ 5 دقائق' },
|
||||
{ id: '2', type: 'call', text: 'مكالمة مع نورة — 8 دقائق', time: 'منذ 30 دقيقة' },
|
||||
{ id: '3', type: 'dealUpdate', text: 'صفقة أبراج الرياض انتقلت لمرحلة التفاوض', time: 'منذ ساعة' },
|
||||
{ id: '4', type: 'noteAdded', text: 'ملاحظة على فيلا النرجس: العميل يبي جراج إضافي', time: 'منذ 2 ساعة' },
|
||||
];
|
||||
|
||||
const demoInsights: AiInsight[] = [
|
||||
{ id: '1', type: 'followUp', count: 3 },
|
||||
{ id: '2', type: 'closing', count: 2 },
|
||||
{ id: '3', type: 'risk', count: 1 },
|
||||
];
|
||||
|
||||
/* ---------- Sub-components ---------- */
|
||||
const stagger = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.06 } },
|
||||
};
|
||||
|
||||
const fadeUp = {
|
||||
hidden: { opacity: 0, y: 12 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.35 } },
|
||||
};
|
||||
|
||||
function GlassCard({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<motion.div
|
||||
variants={fadeUp}
|
||||
className={clsx(
|
||||
'rounded-xl bg-white/5 backdrop-blur-xl border border-white/10 p-5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ icon: Icon, title }: { icon: typeof Users; title: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Icon className="h-4 w-4 text-teal-400" />
|
||||
<h2 className="text-sm font-semibold text-slate-300">{title}</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const activityIcons: Record<Activity['type'], typeof MessageSquare> = {
|
||||
message: MessageSquare,
|
||||
call: Phone,
|
||||
dealUpdate: ArrowUpRight,
|
||||
noteAdded: FileText,
|
||||
};
|
||||
|
||||
const taskStatusStyles: Record<Task['dueStatus'], { dot: string; text: string }> = {
|
||||
overdue: { dot: 'bg-rose-500', text: 'text-rose-400' },
|
||||
today: { dot: 'bg-amber-500', text: 'text-amber-400' },
|
||||
upcoming: { dot: 'bg-slate-500', text: 'text-slate-400' },
|
||||
};
|
||||
|
||||
const insightIcons: Record<AiInsight['type'], { icon: typeof Sparkles; color: string }> = {
|
||||
followUp: { icon: Clock, color: 'text-amber-400' },
|
||||
closing: { icon: TrendingUp, color: 'text-emerald-400' },
|
||||
risk: { icon: AlertTriangle, color: 'text-rose-400' },
|
||||
};
|
||||
|
||||
/* ---------- Main ---------- */
|
||||
function SalesWorkspace({
|
||||
userName,
|
||||
kpis: kpisProp,
|
||||
tasks: tasksProp,
|
||||
deals: dealsProp,
|
||||
activities: activitiesProp,
|
||||
insights: insightsProp,
|
||||
className,
|
||||
}: SalesWorkspaceProps) {
|
||||
const { t, dir, locale, isArabic } = useI18n();
|
||||
|
||||
const kpis = kpisProp ?? demoKpis;
|
||||
const tasks = tasksProp ?? demoTasks;
|
||||
const deals = dealsProp ?? demoDeals;
|
||||
const activities = activitiesProp ?? demoActivities;
|
||||
const insights = insightsProp ?? demoInsights;
|
||||
|
||||
const greeting = useMemo(() => {
|
||||
const hour = new Date().getHours();
|
||||
const base = hour < 17 ? t('workspace.greeting') : t('workspace.greetingEvening');
|
||||
return userName ? `${base}، ${userName}` : base;
|
||||
}, [t, userName]);
|
||||
|
||||
const formatCurrency = (val: number) =>
|
||||
new Intl.NumberFormat(locale === 'ar' ? 'ar-SA' : 'en-US', {
|
||||
style: 'currency',
|
||||
currency: 'SAR',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(val);
|
||||
|
||||
const kpiDefs = [
|
||||
{ key: 'totalLeads', value: kpis.totalLeads, label: t('dashboard.kpis.totalLeads'), icon: Users, trend: { direction: 'up' as const, percentage: 12 }, sparkline: [30, 42, 38, 55, 52, 68, 62] },
|
||||
{ key: 'newToday', value: kpis.newToday, label: t('dashboard.kpis.newToday'), icon: CalendarPlus, trend: { direction: 'up' as const, percentage: 8 }, sparkline: [5, 8, 12, 9, 15, 11, 18] },
|
||||
{ key: 'openDeals', value: kpis.openDeals, label: t('dashboard.kpis.openDeals'), icon: Briefcase, trend: { direction: 'up' as const, percentage: 5 }, sparkline: [28, 35, 31, 40, 38, 42, 43] },
|
||||
{ key: 'wonValue', value: kpis.wonValue, label: t('dashboard.kpis.wonValue'), prefix: isArabic ? 'ر.س' : 'SAR', trend: { direction: 'up' as const, percentage: 22 }, sparkline: [400, 520, 480, 650, 720, 810, 892] },
|
||||
{ key: 'conversionRate', value: kpis.conversionRate, label: t('dashboard.kpis.conversionRate'), suffix: '%', trend: { direction: 'down' as const, percentage: 3 }, sparkline: [38, 36, 35, 37, 34, 33, 34] },
|
||||
{ key: 'responseTime', value: kpis.responseTime, label: t('dashboard.kpis.responseTime'), suffix: t('workspace.kpiResponseUnit'), trend: { direction: 'up' as const, percentage: 15 }, sparkline: [20, 18, 15, 14, 13, 12, 12] },
|
||||
];
|
||||
|
||||
const insightLabel = (i: AiInsight) => {
|
||||
const labels: Record<AiInsight['type'], string> = {
|
||||
followUp: t('workspace.aiInsightFollowUp'),
|
||||
closing: t('workspace.aiInsightClosing'),
|
||||
risk: t('workspace.aiInsightRisk'),
|
||||
};
|
||||
return `${i.count} ${labels[i.type]}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={stagger}
|
||||
dir={dir}
|
||||
className={clsx('space-y-6', className)}
|
||||
>
|
||||
{/* Greeting */}
|
||||
<motion.h1
|
||||
variants={fadeUp}
|
||||
className="text-2xl font-bold text-white"
|
||||
>
|
||||
{greeting}
|
||||
</motion.h1>
|
||||
|
||||
{/* KPI Bar */}
|
||||
<motion.div variants={fadeUp} className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{kpiDefs.map((k) => (
|
||||
<KpiCard
|
||||
key={k.key}
|
||||
value={k.value}
|
||||
label={k.label}
|
||||
prefix={k.prefix}
|
||||
suffix={k.suffix}
|
||||
trend={k.trend}
|
||||
sparklineData={k.sparkline}
|
||||
variant="compact"
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* 3-column body */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4">
|
||||
{/* LEFT: Tasks */}
|
||||
<GlassCard className="lg:col-span-3">
|
||||
<SectionHeader icon={CheckCircle2} title={t('workspace.todaysTasks')} />
|
||||
{tasks.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={CheckCircle2}
|
||||
title={t('workspace.noTasks')}
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{tasks.map((task) => {
|
||||
const style = taskStatusStyles[task.dueStatus];
|
||||
return (
|
||||
<li
|
||||
key={task.id}
|
||||
className="flex items-start gap-2.5 py-2 border-b border-white/5 last:border-0"
|
||||
>
|
||||
<span className={clsx('mt-1.5 h-2 w-2 rounded-full shrink-0', style.dot)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-slate-200 truncate">{task.title}</p>
|
||||
<p className={clsx('text-xs mt-0.5', style.text)}>{task.time}</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</GlassCard>
|
||||
|
||||
{/* CENTER: Hot Deals */}
|
||||
<GlassCard className="lg:col-span-5">
|
||||
<SectionHeader icon={Briefcase} title={t('workspace.hotDeals')} />
|
||||
{deals.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Briefcase}
|
||||
title={t('workspace.noDeals')}
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{deals.map((deal, idx) => (
|
||||
<div
|
||||
key={deal.id}
|
||||
className="flex items-center gap-3 py-2.5 border-b border-white/5 last:border-0"
|
||||
>
|
||||
<span className="text-xs text-slate-500 w-5 text-center tabular-nums">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-white truncate">{deal.name}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={clsx('h-1.5 w-1.5 rounded-full', deal.stageColor)} />
|
||||
<span className="text-xs text-slate-400">{deal.stage}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-teal-400 tabular-nums whitespace-nowrap">
|
||||
{formatCurrency(deal.value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</GlassCard>
|
||||
|
||||
{/* RIGHT: Activity */}
|
||||
<GlassCard className="lg:col-span-4">
|
||||
<SectionHeader icon={Clock} title={t('workspace.recentActivity')} />
|
||||
{activities.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={InboxIcon}
|
||||
title={t('workspace.noActivity')}
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{activities.map((act) => {
|
||||
const Icon = activityIcons[act.type];
|
||||
return (
|
||||
<li key={act.id} className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-lg bg-white/5 p-1.5">
|
||||
<Icon className="h-3.5 w-3.5 text-slate-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-slate-200 leading-snug">{act.text}</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">{act.time}</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* AI Insights */}
|
||||
<GlassCard className="border-teal-500/20 bg-gradient-to-l from-teal-500/5 to-transparent">
|
||||
<SectionHeader icon={Sparkles} title={t('workspace.aiInsights')} />
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{insights.map((insight) => {
|
||||
const { icon: Icon, color } = insightIcons[insight.type];
|
||||
return (
|
||||
<motion.div
|
||||
key={insight.id}
|
||||
whileHover={{ scale: 1.03 }}
|
||||
className={clsx(
|
||||
'flex items-center gap-2.5 px-4 py-2.5 rounded-lg',
|
||||
'bg-white/5 border border-white/10',
|
||||
'cursor-pointer hover:bg-white/[0.08] transition-colors',
|
||||
)}
|
||||
>
|
||||
<Icon className={clsx('h-4 w-4', color)} />
|
||||
<span className="text-sm text-slate-200">{insightLabel(insight)}</span>
|
||||
{isArabic ? (
|
||||
<ChevronLeft className="h-3.5 w-3.5 text-slate-500" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 text-slate-500" />
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export { SalesWorkspace };
|
||||
export type { SalesWorkspaceProps, Task, Deal, Activity, AiInsight };
|
||||
264
salesflow-saas/frontend/src/components/dealix/search-panel.tsx
Normal file
264
salesflow-saas/frontend/src/components/dealix/search-panel.tsx
Normal file
@ -0,0 +1,264 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, type KeyboardEvent } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type ResultCategory = 'leads' | 'deals' | 'contacts' | 'companies';
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
category: ResultCategory;
|
||||
name: string;
|
||||
nameEn: string;
|
||||
lastActivity: string;
|
||||
lastActivityEn: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mock data */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const categoryConfig: Record<ResultCategory, { labelAr: string; labelEn: string; icon: string; color: string }> = {
|
||||
leads: { labelAr: 'عملاء محتملين', labelEn: 'Leads', icon: '👤', color: 'text-blue-400 bg-blue-400/10 border-blue-400/30' },
|
||||
deals: { labelAr: 'صفقات', labelEn: 'Deals', icon: '💼', color: 'text-emerald-400 bg-emerald-400/10 border-emerald-400/30' },
|
||||
contacts: { labelAr: 'جهات اتصال', labelEn: 'Contacts', icon: '📇', color: 'text-purple-400 bg-purple-400/10 border-purple-400/30' },
|
||||
companies: { labelAr: 'شركات', labelEn: 'Companies', icon: '🏢', color: 'text-amber-400 bg-amber-400/10 border-amber-400/30' },
|
||||
};
|
||||
|
||||
const allResults: SearchResult[] = [
|
||||
{ id: '1', category: 'leads', name: 'محمد السالم', nameEn: 'Mohammed Al-Salem', lastActivity: 'رسالة منذ ساعتين', lastActivityEn: 'Message 2h ago' },
|
||||
{ id: '2', category: 'leads', name: 'فهد العتيبي', nameEn: 'Fahd Al-Otaibi', lastActivity: 'مكالمة منذ يوم', lastActivityEn: 'Call 1d ago' },
|
||||
{ id: '3', category: 'deals', name: 'صفقة عقار الرياض', nameEn: 'Riyadh Property Deal', lastActivity: 'تحديث المرحلة منذ ٣ ساعات', lastActivityEn: 'Stage update 3h ago' },
|
||||
{ id: '4', category: 'deals', name: 'مشروع جدة التجاري', nameEn: 'Jeddah Commercial Project', lastActivity: 'عرض سعر منذ يومين', lastActivityEn: 'Quote sent 2d ago' },
|
||||
{ id: '5', category: 'contacts', name: 'أحمد الغامدي', nameEn: 'Ahmed Al-Ghamdi', lastActivity: 'آخر تواصل منذ أسبوع', lastActivityEn: 'Last contact 1w ago' },
|
||||
{ id: '6', category: 'contacts', name: 'نورة الحربي', nameEn: 'Noura Al-Harbi', lastActivity: 'اجتماع أمس', lastActivityEn: 'Meeting yesterday' },
|
||||
{ id: '7', category: 'companies', name: 'شركة البناء المتقدم', nameEn: 'Advanced Construction Co.', lastActivity: '٣ صفقات نشطة', lastActivityEn: '3 active deals' },
|
||||
{ id: '8', category: 'companies', name: 'مجموعة النور القابضة', nameEn: 'Al-Nour Holding Group', lastActivity: 'عميل منذ ٦ أشهر', lastActivityEn: 'Client for 6 months' },
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function SearchPanel({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { isArabic } = useI18n();
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('dealix-recent-searches');
|
||||
return saved ? JSON.parse(saved) : [];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const label = (ar: string, en: string) => (isArabic ? ar : en);
|
||||
|
||||
// Filter results
|
||||
const filtered = query.trim().length > 0
|
||||
? allResults.filter((r) =>
|
||||
r.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
r.nameEn.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
: [];
|
||||
|
||||
// Group by category
|
||||
const grouped = filtered.reduce<Record<ResultCategory, SearchResult[]>>((acc, r) => {
|
||||
if (!acc[r.category]) acc[r.category] = [];
|
||||
acc[r.category].push(r);
|
||||
return acc;
|
||||
}, {} as Record<ResultCategory, SearchResult[]>);
|
||||
|
||||
const flatResults = Object.values(grouped).flat();
|
||||
|
||||
// Focus input when opened
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
setQuery('');
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Save recent search
|
||||
const saveRecent = useCallback((term: string) => {
|
||||
if (!term.trim()) return;
|
||||
const updated = [term, ...recentSearches.filter((s) => s !== term)].slice(0, 5);
|
||||
setRecentSearches(updated);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('dealix-recent-searches', JSON.stringify(updated));
|
||||
}
|
||||
}, [recentSearches]);
|
||||
|
||||
// Keyboard nav
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => Math.min(i + 1, flatResults.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === 'Enter' && flatResults[selectedIndex]) {
|
||||
saveRecent(query);
|
||||
// Would navigate to result in real app
|
||||
onClose();
|
||||
} else if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll selected into view
|
||||
useEffect(() => {
|
||||
const el = listRef.current?.querySelector(`[data-index="${selectedIndex}"]`);
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
}, [selectedIndex]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50"
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -20, scale: 0.97 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="fixed top-[10%] start-[50%] -translate-x-1/2 rtl:translate-x-1/2 w-full max-w-2xl z-50"
|
||||
>
|
||||
<div className="mx-4 rounded-2xl bg-slate-900/95 backdrop-blur-2xl border border-white/10 shadow-2xl shadow-black/50 overflow-hidden">
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-white/10">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 text-slate-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setSelectedIndex(0); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={label('ابحث في العملاء، الصفقات، الشركات...', 'Search leads, deals, companies...')}
|
||||
className="flex-1 bg-transparent text-white placeholder-slate-500 text-sm focus:outline-none"
|
||||
/>
|
||||
<kbd className="hidden sm:inline-flex items-center px-2 py-0.5 rounded bg-white/5 border border-white/10 text-[10px] text-slate-500 font-mono">
|
||||
ESC
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results area */}
|
||||
<div ref={listRef} className="max-h-80 overflow-y-auto">
|
||||
{query.trim().length === 0 ? (
|
||||
/* Recent searches */
|
||||
<div className="p-4">
|
||||
{recentSearches.length > 0 ? (
|
||||
<>
|
||||
<p className="text-xs text-slate-500 font-medium mb-2">
|
||||
{label('عمليات بحث سابقة', 'Recent Searches')}
|
||||
</p>
|
||||
{recentSearches.map((s, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setQuery(s)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-center text-sm text-slate-500 py-6">
|
||||
{label('اكتب للبحث...', 'Type to search...')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : flatResults.length === 0 ? (
|
||||
/* Empty state */
|
||||
<div className="py-12 text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-10 h-10 mx-auto text-slate-600 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<p className="text-sm text-slate-500">{label('لا توجد نتائج', 'No results found')}</p>
|
||||
<p className="text-xs text-slate-600 mt-1">{label('جرب كلمات بحث مختلفة', 'Try different search terms')}</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Grouped results */
|
||||
<div className="py-2">
|
||||
{(Object.keys(grouped) as ResultCategory[]).map((cat) => {
|
||||
const cfg = categoryConfig[cat];
|
||||
return (
|
||||
<div key={cat}>
|
||||
<p className="px-5 py-1.5 text-xs font-semibold text-slate-500">
|
||||
{label(cfg.labelAr, cfg.labelEn)}
|
||||
</p>
|
||||
{grouped[cat].map((r) => {
|
||||
const globalIdx = flatResults.indexOf(r);
|
||||
const isSelected = globalIdx === selectedIndex;
|
||||
return (
|
||||
<button
|
||||
key={r.id}
|
||||
data-index={globalIdx}
|
||||
onClick={() => { saveRecent(query); onClose(); }}
|
||||
onMouseEnter={() => setSelectedIndex(globalIdx)}
|
||||
className={`w-full flex items-center gap-3 px-5 py-2.5 text-start transition-colors ${isSelected ? 'bg-white/5' : 'hover:bg-white/[0.03]'}`}
|
||||
>
|
||||
<span className={`shrink-0 w-8 h-8 rounded-lg flex items-center justify-center text-sm ${cfg.color.split(' ').slice(0, 2).join(' ')}`}>
|
||||
{cfg.icon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white truncate">{label(r.name, r.nameEn)}</p>
|
||||
<p className="text-xs text-slate-500 truncate">{label(r.lastActivity, r.lastActivityEn)}</p>
|
||||
</div>
|
||||
<span className={`shrink-0 text-[10px] font-semibold px-2 py-0.5 rounded-full border ${cfg.color}`}>
|
||||
{label(cfg.labelAr, cfg.labelEn)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div className="px-5 py-2.5 border-t border-white/10 flex items-center gap-4 text-[10px] text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-white/5 border border-white/10 font-mono">↑↓</kbd>
|
||||
{label('تنقل', 'Navigate')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-white/5 border border-white/10 font-mono">↵</kbd>
|
||||
{label('فتح', 'Open')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-white/5 border border-white/10 font-mono">ESC</kbd>
|
||||
{label('إغلاق', 'Close')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
125
salesflow-saas/frontend/src/components/dealix/stats-counter.tsx
Normal file
125
salesflow-saas/frontend/src/components/dealix/stats-counter.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { motion, useSpring, useTransform, useInView } from 'framer-motion';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
type NumberLocale = 'ar' | 'en';
|
||||
|
||||
interface StatsCounterProps {
|
||||
target: number;
|
||||
label: string;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
currency?: boolean;
|
||||
locale?: NumberLocale;
|
||||
duration?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function formatNumber(value: number, locale: NumberLocale, currency: boolean): string {
|
||||
const opts: Intl.NumberFormatOptions = currency
|
||||
? { style: 'currency', currency: 'SAR', maximumFractionDigits: 0 }
|
||||
: { maximumFractionDigits: 0 };
|
||||
|
||||
const loc = locale === 'ar' ? 'ar-SA' : 'en-SA';
|
||||
return new Intl.NumberFormat(loc, opts).format(value);
|
||||
}
|
||||
|
||||
function AnimatedNumber({
|
||||
target,
|
||||
locale,
|
||||
currency,
|
||||
duration,
|
||||
}: {
|
||||
target: number;
|
||||
locale: NumberLocale;
|
||||
currency: boolean;
|
||||
duration: number;
|
||||
}) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const isInView = useInView(ref, { once: true, margin: '-50px' });
|
||||
|
||||
const springValue = useSpring(0, {
|
||||
stiffness: 50,
|
||||
damping: 20,
|
||||
duration: duration * 1000,
|
||||
});
|
||||
|
||||
const display = useTransform(springValue, (v) => formatNumber(Math.round(v), locale, currency));
|
||||
|
||||
useEffect(() => {
|
||||
if (isInView) {
|
||||
springValue.set(target);
|
||||
}
|
||||
}, [isInView, target, springValue]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = display.on('change', (v) => {
|
||||
if (ref.current) {
|
||||
ref.current.textContent = v;
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [display]);
|
||||
|
||||
return <span ref={ref}>0</span>;
|
||||
}
|
||||
|
||||
function StatsCounter({
|
||||
target,
|
||||
label,
|
||||
prefix,
|
||||
suffix,
|
||||
currency = false,
|
||||
locale = 'ar',
|
||||
duration = 2,
|
||||
className,
|
||||
}: StatsCounterProps) {
|
||||
return (
|
||||
<div className={clsx('text-center', className)}>
|
||||
<div className="text-3xl font-bold text-white md:text-4xl">
|
||||
{prefix && <span className="text-teal-400">{prefix}</span>}
|
||||
<AnimatedNumber
|
||||
target={target}
|
||||
locale={locale}
|
||||
currency={currency}
|
||||
duration={duration}
|
||||
/>
|
||||
{suffix && <span className="text-teal-400 ms-1">{suffix}</span>}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-400 md:text-base">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatsGridProps {
|
||||
stats: StatsCounterProps[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function StatsGrid({ stats, className }: StatsGridProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'grid grid-cols-2 gap-8 md:grid-cols-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{stats.map((stat) => (
|
||||
<motion.div
|
||||
key={stat.label}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-30px' }}
|
||||
transition={{ type: 'spring', stiffness: 100, damping: 15 }}
|
||||
>
|
||||
<StatsCounter {...stat} />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { StatsCounter, StatsGrid };
|
||||
export type { StatsCounterProps, StatsGridProps, NumberLocale };
|
||||
450
salesflow-saas/frontend/src/components/dealix/unified-inbox.tsx
Normal file
450
salesflow-saas/frontend/src/components/dealix/unified-inbox.tsx
Normal file
@ -0,0 +1,450 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Search,
|
||||
Send,
|
||||
Paperclip,
|
||||
ArrowRight,
|
||||
Phone,
|
||||
MoreVertical,
|
||||
Sparkles,
|
||||
Check,
|
||||
CheckCheck,
|
||||
MessageSquare,
|
||||
Mail,
|
||||
Smartphone,
|
||||
} from "lucide-react";
|
||||
|
||||
/* ───────────── types ───────────── */
|
||||
type Channel = "whatsapp" | "email" | "sms";
|
||||
type FilterTab = "all" | "whatsapp" | "email" | "sms";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
text: string;
|
||||
sent: boolean; // true = we sent, false = received
|
||||
time: string;
|
||||
read?: boolean;
|
||||
}
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string; // initials
|
||||
avatarColor: string;
|
||||
channel: Channel;
|
||||
lastMessage: string;
|
||||
time: string;
|
||||
unread: number;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
/* ───────────── channel config ───────────── */
|
||||
const channelConfig: Record<Channel, { icon: typeof MessageSquare; color: string; label: string }> = {
|
||||
whatsapp: { icon: MessageSquare, color: "text-green-400 bg-green-400/20", label: "واتساب" },
|
||||
email: { icon: Mail, color: "text-blue-400 bg-blue-400/20", label: "إيميل" },
|
||||
sms: { icon: Smartphone, color: "text-purple-400 bg-purple-400/20", label: "رسائل" },
|
||||
};
|
||||
|
||||
const filterTabs: { key: FilterTab; label: string }[] = [
|
||||
{ key: "all", label: "الكل" },
|
||||
{ key: "whatsapp", label: "واتساب" },
|
||||
{ key: "email", label: "إيميل" },
|
||||
{ key: "sms", label: "رسائل" },
|
||||
];
|
||||
|
||||
/* ───────────── sample data ───────────── */
|
||||
const sampleConversations: Conversation[] = [
|
||||
{
|
||||
id: "c1",
|
||||
name: "أحمد الغامدي",
|
||||
avatar: "أغ",
|
||||
avatarColor: "bg-green-600",
|
||||
channel: "whatsapp",
|
||||
lastMessage: "تمام، أرسل لي العرض على الإيميل",
|
||||
time: "١٠:٣٢",
|
||||
unread: 2,
|
||||
messages: [
|
||||
{ id: "m1", text: "السلام عليكم، عندكم حل CRM يدعم العربي؟", sent: false, time: "١٠:١٥" },
|
||||
{ id: "m2", text: "وعليكم السلام أحمد! أكيد، Dealix مصمم بالكامل للسوق السعودي", sent: true, time: "١٠:١٨", read: true },
|
||||
{ id: "m3", text: "كم السعر للباقة الاحترافية؟", sent: false, time: "١٠:٢٠" },
|
||||
{ id: "m4", text: "١٤٩ ر.س شهرياً مع تجربة مجانية ١٤ يوم", sent: true, time: "١٠:٢٥", read: true },
|
||||
{ id: "m5", text: "تمام، أرسل لي العرض على الإيميل", sent: false, time: "١٠:٣٢" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "c2",
|
||||
name: "سارة المطيري",
|
||||
avatar: "سم",
|
||||
avatarColor: "bg-blue-600",
|
||||
channel: "email",
|
||||
lastMessage: "شكراً على العرض التقديمي، سأرجع لكم بعد الاجتماع",
|
||||
time: "أمس",
|
||||
unread: 0,
|
||||
messages: [
|
||||
{ id: "m6", text: "مرحباً، أرغب بمعرفة المزيد عن خدمات تقييم العملاء بالذكاء الاصطناعي", sent: false, time: "أمس ٠٩:٠٠" },
|
||||
{ id: "m7", text: "أهلاً سارة! نظام تقييم العملاء يعتمد على ٤ محاور: التفاعل، الملف الشخصي، السلوك، ونية الشراء", sent: true, time: "أمس ٠٩:٤٥", read: true },
|
||||
{ id: "m8", text: "ممتاز، هل يمكنكم تقديم عرض لفريق من ١٥ شخص؟", sent: false, time: "أمس ١١:٣٠" },
|
||||
{ id: "m9", text: "بالتأكيد! أرفقت عرض الأسعار للباقة المؤسسية", sent: true, time: "أمس ١٤:٠٠", read: true },
|
||||
{ id: "m10", text: "شكراً على العرض التقديمي، سأرجع لكم بعد الاجتماع", sent: false, time: "أمس ١٦:٢٠" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "c3",
|
||||
name: "خالد العتيبي",
|
||||
avatar: "خع",
|
||||
avatarColor: "bg-purple-600",
|
||||
channel: "sms",
|
||||
lastMessage: "موعدنا يوم الأحد الساعة ١١ صباحاً",
|
||||
time: "١٢:٠٠",
|
||||
unread: 1,
|
||||
messages: [
|
||||
{ id: "m11", text: "خالد، تذكير بموعد العرض التقديمي", sent: true, time: "١١:٣٠", read: true },
|
||||
{ id: "m12", text: "موعدنا يوم الأحد الساعة ١١ صباحاً", sent: false, time: "١٢:٠٠" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "c4",
|
||||
name: "منيرة القحطاني",
|
||||
avatar: "مق",
|
||||
avatarColor: "bg-amber-600",
|
||||
channel: "whatsapp",
|
||||
lastMessage: "ودي أجرب النظام قبل ما نقرر",
|
||||
time: "٠٩:١٥",
|
||||
unread: 3,
|
||||
messages: [
|
||||
{ id: "m13", text: "مرحباً، محتاجين نظام CRM لشركة عقارية", sent: false, time: "٠٨:٤٥" },
|
||||
{ id: "m14", text: "أهلاً منيرة! Dealix يخدم أكثر من ٥٠ شركة عقارية في المملكة", sent: true, time: "٠٩:٠٠", read: true },
|
||||
{ id: "m15", text: "ودي أجرب النظام قبل ما نقرر", sent: false, time: "٠٩:١٥" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/* ───────────── typing indicator ───────────── */
|
||||
function TypingIndicator() {
|
||||
return (
|
||||
<div className="flex gap-1 items-center px-4 py-3 rounded-2xl rounded-br-sm bg-slate-700/60 w-fit">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
className="w-2 h-2 rounded-full bg-white/40"
|
||||
animate={{ y: [0, -5, 0] }}
|
||||
transition={{ duration: 0.6, repeat: Infinity, delay: i * 0.15 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────── conversation list item ───────────── */
|
||||
function ConversationItem({
|
||||
convo,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
convo: Conversation;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const ch = channelConfig[convo.channel];
|
||||
const Icon = ch.icon;
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
onClick={onClick}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-xl text-right transition-all ${
|
||||
isActive
|
||||
? "bg-white/10 border border-white/10"
|
||||
: "hover:bg-white/[0.04] border border-transparent"
|
||||
}`}
|
||||
>
|
||||
{/* avatar */}
|
||||
<div className={`relative shrink-0 w-11 h-11 rounded-full ${convo.avatarColor} flex items-center justify-center text-xs font-bold text-white`}>
|
||||
{convo.avatar}
|
||||
<span className={`absolute -bottom-0.5 -left-0.5 p-0.5 rounded-full ${ch.color.split(" ")[1]}`}>
|
||||
<Icon className={`w-2.5 h-2.5 ${ch.color.split(" ")[0]}`} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* text */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-white/40">{convo.time}</span>
|
||||
<h4 className="font-bold text-sm truncate">{convo.name}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-white/50 truncate mt-0.5">{convo.lastMessage}</p>
|
||||
</div>
|
||||
|
||||
{/* unread badge */}
|
||||
{convo.unread > 0 && (
|
||||
<span className="shrink-0 w-5 h-5 rounded-full bg-teal-500 text-[10px] font-bold text-black flex items-center justify-center">
|
||||
{convo.unread}
|
||||
</span>
|
||||
)}
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────── chat panel ───────────── */
|
||||
function ChatPanel({
|
||||
convo,
|
||||
onBack,
|
||||
}: {
|
||||
convo: Conversation;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const [messages, setMessages] = useState(convo.messages);
|
||||
const [input, setInput] = useState("");
|
||||
const [showTyping, setShowTyping] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const ch = channelConfig[convo.channel];
|
||||
|
||||
useEffect(() => {
|
||||
setMessages(convo.messages);
|
||||
}, [convo.id, convo.messages]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
|
||||
}, [messages, showTyping]);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim()) return;
|
||||
const newMsg: Message = {
|
||||
id: `m-${Date.now()}`,
|
||||
text: input,
|
||||
sent: true,
|
||||
time: new Date().toLocaleTimeString("ar-SA", { hour: "2-digit", minute: "2-digit" }),
|
||||
read: false,
|
||||
};
|
||||
setMessages((prev) => [...prev, newMsg]);
|
||||
setInput("");
|
||||
setShowTyping(true);
|
||||
setTimeout(() => {
|
||||
setShowTyping(false);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `m-${Date.now()}-r`,
|
||||
text: "شكراً لتواصلك! سأرد عليك في أقرب وقت",
|
||||
sent: false,
|
||||
time: new Date().toLocaleTimeString("ar-SA", { hour: "2-digit", minute: "2-digit" }),
|
||||
},
|
||||
]);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* chat header */}
|
||||
<div className="shrink-0 flex items-center justify-between gap-3 p-4 border-b border-white/10 bg-white/[0.02] backdrop-blur-xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="p-1.5 rounded-lg hover:bg-white/10 transition-colors">
|
||||
<Phone className="w-4 h-4 text-white/50" />
|
||||
</button>
|
||||
<button className="p-1.5 rounded-lg hover:bg-white/10 transition-colors">
|
||||
<MoreVertical className="w-4 h-4 text-white/50" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-1 justify-end">
|
||||
<div className="text-right">
|
||||
<h3 className="font-bold text-sm">{convo.name}</h3>
|
||||
<span className={`text-[10px] font-medium ${ch.color.split(" ")[0]}`}>
|
||||
{ch.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`w-10 h-10 rounded-full ${convo.avatarColor} flex items-center justify-center text-xs font-bold text-white`}>
|
||||
{convo.avatar}
|
||||
</div>
|
||||
{/* back button mobile */}
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="lg:hidden p-1.5 rounded-lg hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* messages */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{messages.map((msg) => (
|
||||
<motion.div
|
||||
key={msg.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`flex ${msg.sent ? "justify-start" : "justify-end"}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[75%] px-4 py-2.5 rounded-2xl text-sm leading-relaxed ${
|
||||
msg.sent
|
||||
? "bg-teal-600/80 text-white rounded-bl-sm"
|
||||
: "bg-slate-700/60 text-white/90 rounded-br-sm"
|
||||
}`}
|
||||
>
|
||||
<p>{msg.text}</p>
|
||||
<div className={`flex items-center gap-1 mt-1 ${msg.sent ? "justify-start" : "justify-end"}`}>
|
||||
<span className="text-[10px] text-white/40">{msg.time}</span>
|
||||
{msg.sent &&
|
||||
(msg.read ? (
|
||||
<CheckCheck className="w-3 h-3 text-teal-300" />
|
||||
) : (
|
||||
<Check className="w-3 h-3 text-white/30" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
<AnimatePresence>
|
||||
{showTyping && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex justify-end"
|
||||
>
|
||||
<TypingIndicator />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* AI suggestion chip */}
|
||||
<div className="shrink-0 px-4 pb-2">
|
||||
<button className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-teal-500/10 border border-teal-500/20 text-teal-300 text-xs font-medium hover:bg-teal-500/20 transition-colors">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
اقتراح الرد الذكي
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* input bar */}
|
||||
<div className="shrink-0 p-3 border-t border-white/10 bg-white/[0.02] backdrop-blur-xl flex items-center gap-2">
|
||||
<button className="p-2 rounded-xl hover:bg-white/10 transition-colors text-white/40 hover:text-white/60">
|
||||
<Paperclip className="w-5 h-5" />
|
||||
</button>
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSend()}
|
||||
placeholder="اكتب رسالتك..."
|
||||
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-sm placeholder:text-white/30 focus:outline-none focus:border-teal-500/50 transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
className="p-2.5 rounded-xl bg-teal-500 text-black hover:bg-teal-400 transition-colors"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────── main component ───────────── */
|
||||
export function UnifiedInbox() {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<FilterTab>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = sampleConversations.filter((c) => {
|
||||
if (filter !== "all" && c.channel !== filter) return false;
|
||||
if (search && !c.name.includes(search) && !c.lastMessage.includes(search)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const activeConvo = sampleConversations.find((c) => c.id === activeId) ?? null;
|
||||
|
||||
return (
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="w-full h-[calc(100vh-120px)] min-h-[500px] rounded-3xl overflow-hidden border border-white/10 bg-white/[0.02] backdrop-blur-xl flex"
|
||||
dir="rtl"
|
||||
>
|
||||
{/* ─── right panel: conversation list ─── */}
|
||||
<div
|
||||
className={`w-full lg:w-[340px] shrink-0 border-l border-white/10 flex flex-col ${
|
||||
activeConvo ? "hidden lg:flex" : "flex"
|
||||
}`}
|
||||
>
|
||||
{/* search */}
|
||||
<div className="p-3 border-b border-white/10">
|
||||
<div className="relative">
|
||||
<Search className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/30" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="بحث..."
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl pr-10 pl-4 py-2 text-sm placeholder:text-white/30 focus:outline-none focus:border-teal-500/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* filter tabs */}
|
||||
<div className="flex gap-1 p-2 border-b border-white/10">
|
||||
{filterTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setFilter(tab.key)}
|
||||
className={`flex-1 py-1.5 rounded-lg text-xs font-bold transition-colors ${
|
||||
filter === tab.key
|
||||
? "bg-teal-500/20 text-teal-300"
|
||||
: "text-white/40 hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* list */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
<AnimatePresence>
|
||||
{filtered.map((convo, i) => (
|
||||
<motion.div
|
||||
key={convo.id}
|
||||
initial={{ opacity: 0, x: 12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
>
|
||||
<ConversationItem
|
||||
convo={convo}
|
||||
isActive={activeId === convo.id}
|
||||
onClick={() => setActiveId(convo.id)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="flex items-center justify-center h-32 text-sm text-white/30">
|
||||
لا توجد محادثات
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── left panel: chat thread ─── */}
|
||||
<div
|
||||
className={`flex-1 flex flex-col ${
|
||||
!activeConvo ? "hidden lg:flex" : "flex"
|
||||
}`}
|
||||
>
|
||||
{activeConvo ? (
|
||||
<ChatPanel convo={activeConvo} onBack={() => setActiveId(null)} />
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center gap-3">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center">
|
||||
<MessageSquare className="w-8 h-8 text-white/20" />
|
||||
</div>
|
||||
<p className="text-white/30 text-sm font-medium">اختر محادثة للبدء</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
69
salesflow-saas/frontend/src/components/ui/badge.tsx
Normal file
69
salesflow-saas/frontend/src/components/ui/badge.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { type ReactNode } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
type BadgeVariant = 'success' | 'warning' | 'danger' | 'info' | 'neutral' | 'live';
|
||||
|
||||
interface BadgeProps {
|
||||
variant?: BadgeVariant;
|
||||
dot?: boolean;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variantStyles: Record<BadgeVariant, string> = {
|
||||
success: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30',
|
||||
warning: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
|
||||
danger: 'bg-red-500/15 text-red-400 border-red-500/30',
|
||||
info: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
|
||||
neutral: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
|
||||
live: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30',
|
||||
};
|
||||
|
||||
const dotColors: Record<BadgeVariant, string> = {
|
||||
success: 'bg-emerald-400',
|
||||
warning: 'bg-amber-400',
|
||||
danger: 'bg-red-400',
|
||||
info: 'bg-blue-400',
|
||||
neutral: 'bg-slate-400',
|
||||
live: 'bg-emerald-400',
|
||||
};
|
||||
|
||||
function Badge({ variant = 'neutral', dot = false, children, className }: BadgeProps) {
|
||||
return (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 25 }}
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1.5',
|
||||
'rounded-full border px-2.5 py-0.5',
|
||||
'text-xs font-medium',
|
||||
variantStyles[variant],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{dot && (
|
||||
<span className="relative flex h-2 w-2">
|
||||
{variant === 'live' && (
|
||||
<span
|
||||
className={clsx(
|
||||
'absolute inline-flex h-full w-full rounded-full opacity-75 animate-ping',
|
||||
dotColors[variant],
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={clsx('relative inline-flex h-2 w-2 rounded-full', dotColors[variant])}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge };
|
||||
export type { BadgeProps, BadgeVariant };
|
||||
118
salesflow-saas/frontend/src/components/ui/button.tsx
Normal file
118
salesflow-saas/frontend/src/components/ui/button.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
|
||||
import { motion, type HTMLMotionProps } from 'framer-motion';
|
||||
import { clsx } from 'clsx';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'gold';
|
||||
type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface ButtonProps
|
||||
extends Omit<HTMLMotionProps<'button'>, 'children' | 'disabled'>,
|
||||
Pick<ButtonHTMLAttributes<HTMLButtonElement>, 'disabled' | 'type' | 'form'> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
loading?: boolean;
|
||||
icon?: ReactNode;
|
||||
iconPosition?: 'start' | 'end';
|
||||
fullWidth?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const variantStyles: Record<ButtonVariant, string> = {
|
||||
primary: clsx(
|
||||
'bg-gradient-to-l from-teal-500 to-emerald-600 text-white',
|
||||
'hover:shadow-[0_0_20px_rgba(20,184,166,0.4)]',
|
||||
'active:from-teal-600 active:to-emerald-700',
|
||||
'disabled:from-slate-600 disabled:to-slate-700 disabled:text-slate-400',
|
||||
),
|
||||
secondary: clsx(
|
||||
'border border-teal-500/50 text-teal-400 bg-transparent',
|
||||
'hover:bg-teal-500/10 hover:border-teal-400',
|
||||
'hover:shadow-[0_0_15px_rgba(20,184,166,0.2)]',
|
||||
'active:bg-teal-500/20',
|
||||
'disabled:border-slate-600 disabled:text-slate-500',
|
||||
),
|
||||
ghost: clsx(
|
||||
'text-slate-300 bg-transparent',
|
||||
'hover:bg-white/5 hover:text-white',
|
||||
'active:bg-white/10',
|
||||
'disabled:text-slate-600',
|
||||
),
|
||||
danger: clsx(
|
||||
'bg-gradient-to-l from-red-500 to-rose-600 text-white',
|
||||
'hover:shadow-[0_0_20px_rgba(239,68,68,0.4)]',
|
||||
'active:from-red-600 active:to-rose-700',
|
||||
'disabled:from-slate-600 disabled:to-slate-700 disabled:text-slate-400',
|
||||
),
|
||||
gold: clsx(
|
||||
'bg-gradient-to-l from-amber-400 to-yellow-500 text-slate-900 font-semibold',
|
||||
'hover:shadow-[0_0_20px_rgba(251,191,36,0.4)]',
|
||||
'active:from-amber-500 active:to-yellow-600',
|
||||
'disabled:from-slate-600 disabled:to-slate-700 disabled:text-slate-400',
|
||||
),
|
||||
};
|
||||
|
||||
const sizeStyles: Record<ButtonSize, string> = {
|
||||
sm: 'h-8 text-sm ps-3 pe-3 gap-1.5 rounded-md',
|
||||
md: 'h-10 text-base ps-5 pe-5 gap-2 rounded-lg',
|
||||
lg: 'h-12 text-lg ps-7 pe-7 gap-2.5 rounded-xl',
|
||||
};
|
||||
|
||||
const DealixButton = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
icon,
|
||||
iconPosition = 'start',
|
||||
fullWidth = false,
|
||||
disabled,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
ref={ref}
|
||||
disabled={isDisabled}
|
||||
whileHover={isDisabled ? undefined : { scale: 1.03 }}
|
||||
whileTap={isDisabled ? undefined : { scale: 0.97 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
|
||||
className={clsx(
|
||||
'inline-flex items-center justify-center font-medium',
|
||||
'text-center select-none cursor-pointer',
|
||||
'transition-shadow duration-200',
|
||||
'disabled:cursor-not-allowed disabled:opacity-70',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900',
|
||||
fullWidth && 'w-full',
|
||||
variantStyles[variant],
|
||||
sizeStyles[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
icon && iconPosition === 'start' && <span className="shrink-0">{icon}</span>
|
||||
)}
|
||||
<span>{children}</span>
|
||||
{!loading && icon && iconPosition === 'end' && (
|
||||
<span className="shrink-0">{icon}</span>
|
||||
)}
|
||||
</motion.button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
DealixButton.displayName = 'DealixButton';
|
||||
|
||||
export { DealixButton as Button };
|
||||
export type { ButtonProps, ButtonVariant, ButtonSize };
|
||||
130
salesflow-saas/frontend/src/components/ui/card.tsx
Normal file
130
salesflow-saas/frontend/src/components/ui/card.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
import { forwardRef, type ReactNode, type HTMLAttributes } from 'react';
|
||||
import { motion, type HTMLMotionProps } from 'framer-motion';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
type CardVariant = 'default' | 'gradient' | 'elevated' | 'feature';
|
||||
|
||||
interface CardProps extends Omit<HTMLMotionProps<'div'>, 'children'> {
|
||||
variant?: CardVariant;
|
||||
header?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
badge?: ReactNode;
|
||||
noPadding?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const variantStyles: Record<CardVariant, string> = {
|
||||
default: clsx(
|
||||
'bg-white/5 backdrop-blur-xl',
|
||||
'border border-white/10',
|
||||
),
|
||||
gradient: clsx(
|
||||
'bg-gradient-to-bl from-teal-500/10 via-slate-900/80 to-slate-900/90',
|
||||
'backdrop-blur-xl border border-teal-500/20',
|
||||
),
|
||||
elevated: clsx(
|
||||
'bg-slate-800/80 backdrop-blur-xl',
|
||||
'border border-white/10',
|
||||
'shadow-xl shadow-black/20',
|
||||
),
|
||||
feature: clsx(
|
||||
'bg-gradient-to-bl from-teal-500/15 via-emerald-500/5 to-transparent',
|
||||
'backdrop-blur-xl border border-teal-400/20',
|
||||
),
|
||||
};
|
||||
|
||||
const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||
(
|
||||
{
|
||||
variant = 'default',
|
||||
header,
|
||||
footer,
|
||||
badge,
|
||||
noPadding = false,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
whileHover={{
|
||||
y: -2,
|
||||
boxShadow: '0 20px 40px -12px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
className={clsx(
|
||||
'relative rounded-xl overflow-hidden',
|
||||
'text-white',
|
||||
'transition-colors duration-200',
|
||||
variantStyles[variant],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{badge && (
|
||||
<div className="absolute top-3 end-3 z-10">{badge}</div>
|
||||
)}
|
||||
|
||||
{header && (
|
||||
<div
|
||||
className={clsx(
|
||||
'border-b border-white/10',
|
||||
!noPadding && 'px-6 py-4',
|
||||
)}
|
||||
>
|
||||
{header}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={clsx(!noPadding && 'p-6')}>{children}</div>
|
||||
|
||||
{footer && (
|
||||
<div
|
||||
className={clsx(
|
||||
'border-t border-white/10',
|
||||
!noPadding && 'px-6 py-4',
|
||||
)}
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Card.displayName = 'Card';
|
||||
|
||||
interface CardTitleProps extends HTMLAttributes<HTMLHeadingElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function CardTitle({ children, className, ...props }: CardTitleProps) {
|
||||
return (
|
||||
<h3
|
||||
className={clsx('text-lg font-semibold text-white', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ children, className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p
|
||||
className={clsx('text-sm text-slate-400 mt-1', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export { Card, CardTitle, CardDescription };
|
||||
export type { CardProps, CardVariant };
|
||||
60
salesflow-saas/frontend/src/components/ui/command-input.tsx
Normal file
60
salesflow-saas/frontend/src/components/ui/command-input.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { forwardRef, type InputHTMLAttributes } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
interface CommandInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||
onCommandClick?: () => void;
|
||||
}
|
||||
|
||||
const CommandInput = forwardRef<HTMLInputElement, CommandInputProps>(
|
||||
({ onCommandClick, className, placeholder, ...props }, ref) => {
|
||||
const { t, dir } = useI18n();
|
||||
|
||||
const resolvedPlaceholder = placeholder ?? t('commandPalette.placeholder');
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCommandClick}
|
||||
className={clsx(
|
||||
'group flex items-center w-full gap-3',
|
||||
'rounded-xl px-4 py-2.5',
|
||||
'bg-white/5 backdrop-blur-sm',
|
||||
'border border-white/10',
|
||||
'hover:bg-white/[0.08] hover:border-white/15',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-400/50',
|
||||
'transition-all duration-200 cursor-pointer',
|
||||
className,
|
||||
)}
|
||||
dir={dir}
|
||||
>
|
||||
<Search className="h-4 w-4 text-slate-500 shrink-0" />
|
||||
|
||||
<span className="flex-1 text-start text-sm text-slate-500 truncate">
|
||||
{resolvedPlaceholder}
|
||||
</span>
|
||||
|
||||
<kbd
|
||||
className={clsx(
|
||||
'hidden sm:inline-flex items-center gap-0.5',
|
||||
'rounded-md px-1.5 py-0.5',
|
||||
'bg-white/[0.06] border border-white/10',
|
||||
'text-[11px] text-slate-500 font-mono',
|
||||
'group-hover:bg-white/10 group-hover:text-slate-400',
|
||||
'transition-colors duration-200',
|
||||
)}
|
||||
>
|
||||
<span className="text-xs">⌘</span>K
|
||||
</kbd>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CommandInput.displayName = 'CommandInput';
|
||||
|
||||
export { CommandInput };
|
||||
export type { CommandInputProps };
|
||||
75
salesflow-saas/frontend/src/components/ui/empty-state.tsx
Normal file
75
salesflow-saas/frontend/src/components/ui/empty-state.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import { type ReactNode } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { clsx } from 'clsx';
|
||||
import { useI18n } from '@/i18n';
|
||||
import { type LucideIcon } from 'lucide-react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
onAction,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
const { dir } = useI18n();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||
dir={dir}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center text-center',
|
||||
'py-16 px-8',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mb-5 rounded-2xl bg-white/5 p-4 border border-white/10">
|
||||
<Icon className="h-8 w-8 text-slate-500" strokeWidth={1.5} />
|
||||
</div>
|
||||
|
||||
<h3 className="text-base font-semibold text-slate-300 mb-1.5">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{description && (
|
||||
<p className="text-sm text-slate-500 max-w-xs mb-6 leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{actionLabel && onAction && (
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.04 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
onClick={onAction}
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-2 px-5 py-2.5 rounded-lg',
|
||||
'bg-teal-500/15 text-teal-400 text-sm font-medium',
|
||||
'border border-teal-500/25',
|
||||
'hover:bg-teal-500/25 transition-colors duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-400',
|
||||
)}
|
||||
>
|
||||
{actionLabel}
|
||||
</motion.button>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export { EmptyState };
|
||||
export type { EmptyStateProps };
|
||||
29
salesflow-saas/frontend/src/components/ui/index.ts
Normal file
29
salesflow-saas/frontend/src/components/ui/index.ts
Normal file
@ -0,0 +1,29 @@
|
||||
export { Button } from './button';
|
||||
export type { ButtonProps, ButtonVariant, ButtonSize } from './button';
|
||||
|
||||
export { Card, CardTitle, CardDescription } from './card';
|
||||
export type { CardProps, CardVariant } from './card';
|
||||
|
||||
export { Input } from './input';
|
||||
export type { InputProps, InputType } from './input';
|
||||
|
||||
export { Modal } from './modal';
|
||||
export type { ModalProps, ModalSize } from './modal';
|
||||
|
||||
export { Badge } from './badge';
|
||||
export type { BadgeProps, BadgeVariant } from './badge';
|
||||
|
||||
export { Sidebar, useSidebar } from './sidebar';
|
||||
export type { SidebarProps, NavItem, NavSection } from './sidebar';
|
||||
|
||||
export { KpiCard } from './kpi-card';
|
||||
export type { KpiCardProps } from './kpi-card';
|
||||
|
||||
export { EmptyState } from './empty-state';
|
||||
export type { EmptyStateProps } from './empty-state';
|
||||
|
||||
export { CommandInput } from './command-input';
|
||||
export type { CommandInputProps } from './command-input';
|
||||
|
||||
export { ToastProvider, useToast } from './toast';
|
||||
export type { ToastType, Toast, ToastContextType } from './toast';
|
||||
212
salesflow-saas/frontend/src/components/ui/input.tsx
Normal file
212
salesflow-saas/frontend/src/components/ui/input.tsx
Normal file
@ -0,0 +1,212 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useState,
|
||||
useId,
|
||||
type InputHTMLAttributes,
|
||||
type TextareaHTMLAttributes,
|
||||
} from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { clsx } from 'clsx';
|
||||
import { Search, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
type InputType = 'text' | 'email' | 'phone' | 'password' | 'search' | 'textarea';
|
||||
|
||||
interface InputProps
|
||||
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'size'> {
|
||||
inputType?: InputType;
|
||||
label?: string;
|
||||
error?: string;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
const baseStyles = clsx(
|
||||
'w-full bg-white/5 backdrop-blur-sm text-white placeholder-transparent',
|
||||
'border border-white/10 rounded-lg',
|
||||
'transition-all duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-teal-400/50 focus:border-teal-400',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'text-base ps-4 pe-4 pt-5 pb-2',
|
||||
'peer',
|
||||
);
|
||||
|
||||
const labelStyles = clsx(
|
||||
'absolute text-sm text-slate-400 duration-200 transform',
|
||||
'top-4 start-4 z-10 origin-[right]',
|
||||
'peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0',
|
||||
'peer-focus:scale-75 peer-focus:-translate-y-2.5',
|
||||
'peer-[:not(:placeholder-shown)]:scale-75 peer-[:not(:placeholder-shown)]:-translate-y-2.5',
|
||||
'pointer-events-none',
|
||||
);
|
||||
|
||||
const errorLabelStyles = 'text-red-400';
|
||||
|
||||
const DealixInput = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ inputType = 'text', label, error, className, rows = 4, id, ...props }, ref) => {
|
||||
const generatedId = useId();
|
||||
const inputId = id ?? generatedId;
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const errorId = error ? `${inputId}-error` : undefined;
|
||||
|
||||
const wrapperClass = 'relative w-full';
|
||||
|
||||
if (inputType === 'textarea') {
|
||||
return (
|
||||
<div className={wrapperClass}>
|
||||
<textarea
|
||||
id={inputId}
|
||||
rows={rows}
|
||||
dir="auto"
|
||||
placeholder=" "
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={errorId}
|
||||
className={clsx(baseStyles, 'resize-y min-h-[80px]', error && 'border-red-400/60', className)}
|
||||
{...(props as TextareaHTMLAttributes<HTMLTextAreaElement>)}
|
||||
/>
|
||||
{label && (
|
||||
<label htmlFor={inputId} className={clsx(labelStyles, error && errorLabelStyles)}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<ErrorMessage id={errorId} message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (inputType === 'phone') {
|
||||
return (
|
||||
<div className={wrapperClass}>
|
||||
<div className="relative flex items-center">
|
||||
<span className="absolute start-4 text-sm text-teal-400 font-medium z-10 pointer-events-none">
|
||||
966+
|
||||
</span>
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
type="tel"
|
||||
dir="ltr"
|
||||
placeholder=" "
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={errorId}
|
||||
className={clsx(baseStyles, 'ps-16', error && 'border-red-400/60', className)}
|
||||
{...props}
|
||||
/>
|
||||
{label && (
|
||||
<label htmlFor={inputId} className={clsx(labelStyles, 'start-16', error && errorLabelStyles)}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<ErrorMessage id={errorId} message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (inputType === 'search') {
|
||||
return (
|
||||
<div className={wrapperClass}>
|
||||
<Search className="absolute start-4 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 pointer-events-none" />
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
type="search"
|
||||
dir="auto"
|
||||
placeholder={label ?? '...بحث'}
|
||||
className={clsx(
|
||||
'w-full bg-white/5 backdrop-blur-sm text-white placeholder-slate-500',
|
||||
'border border-white/10 rounded-lg',
|
||||
'transition-all duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-teal-400/50 focus:border-teal-400',
|
||||
'text-base ps-11 pe-4 py-2.5',
|
||||
error && 'border-red-400/60',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<ErrorMessage id={errorId} message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (inputType === 'password') {
|
||||
return (
|
||||
<div className={wrapperClass}>
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
dir="auto"
|
||||
placeholder=" "
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={errorId}
|
||||
className={clsx(baseStyles, 'pe-12', error && 'border-red-400/60', className)}
|
||||
{...props}
|
||||
/>
|
||||
{label && (
|
||||
<label htmlFor={inputId} className={clsx(labelStyles, error && errorLabelStyles)}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute end-4 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white transition-colors"
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? 'إخفاء كلمة المرور' : 'إظهار كلمة المرور'}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
<ErrorMessage id={errorId} message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={wrapperClass}>
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
type={inputType}
|
||||
dir="auto"
|
||||
placeholder=" "
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={errorId}
|
||||
className={clsx(baseStyles, error && 'border-red-400/60', className)}
|
||||
{...props}
|
||||
/>
|
||||
{label && (
|
||||
<label htmlFor={inputId} className={clsx(labelStyles, error && errorLabelStyles)}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<ErrorMessage id={errorId} message={error} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
DealixInput.displayName = 'DealixInput';
|
||||
|
||||
function ErrorMessage({ id, message }: { id?: string; message?: string }) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{message && (
|
||||
<motion.p
|
||||
id={id}
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
className="mt-1.5 text-sm text-red-400 ps-1"
|
||||
role="alert"
|
||||
>
|
||||
{message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export { DealixInput as Input };
|
||||
export type { InputProps, InputType };
|
||||
162
salesflow-saas/frontend/src/components/ui/kpi-card.tsx
Normal file
162
salesflow-saas/frontend/src/components/ui/kpi-card.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { clsx } from 'clsx';
|
||||
import { TrendingUp, TrendingDown } from 'lucide-react';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
interface KpiCardProps {
|
||||
value: number;
|
||||
label: string;
|
||||
trend?: { direction: 'up' | 'down'; percentage: number };
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
sparklineData?: number[];
|
||||
variant?: 'compact' | 'full';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function useCountUp(target: number, duration: number = 1200) {
|
||||
const [current, setCurrent] = useState(0);
|
||||
const frameRef = useRef<number>();
|
||||
|
||||
useEffect(() => {
|
||||
const start = performance.now();
|
||||
const animate = (now: number) => {
|
||||
const elapsed = now - start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
setCurrent(Math.round(target * eased));
|
||||
if (progress < 1) {
|
||||
frameRef.current = requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
frameRef.current = requestAnimationFrame(animate);
|
||||
return () => {
|
||||
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
||||
};
|
||||
}, [target, duration]);
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
function Sparkline({ data, className }: { data: number[]; className?: string }) {
|
||||
const max = Math.max(...data);
|
||||
const min = Math.min(...data);
|
||||
const range = max - min || 1;
|
||||
const h = 28;
|
||||
const w = 72;
|
||||
const step = w / (data.length - 1);
|
||||
|
||||
const points = data
|
||||
.map((v, i) => `${i * step},${h - ((v - min) / range) * h}`)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
className={clsx('overflow-visible', className)}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiCard({
|
||||
value,
|
||||
label,
|
||||
trend,
|
||||
prefix,
|
||||
suffix,
|
||||
sparklineData,
|
||||
variant = 'full',
|
||||
className,
|
||||
}: KpiCardProps) {
|
||||
const { locale } = useI18n();
|
||||
const animatedValue = useCountUp(value);
|
||||
|
||||
const formatted = useMemo(() => {
|
||||
return new Intl.NumberFormat(locale === 'ar' ? 'ar-SA' : 'en-US').format(
|
||||
animatedValue,
|
||||
);
|
||||
}, [animatedValue, locale]);
|
||||
|
||||
const isCompact = variant === 'compact';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||
className={clsx(
|
||||
'relative rounded-xl overflow-hidden',
|
||||
'bg-white/5 backdrop-blur-xl',
|
||||
'border border-white/10',
|
||||
isCompact ? 'p-3' : 'p-5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={clsx(
|
||||
'text-slate-400 truncate',
|
||||
isCompact ? 'text-xs mb-1' : 'text-sm mb-2',
|
||||
)}>
|
||||
{label}
|
||||
</p>
|
||||
<p className={clsx(
|
||||
'font-semibold text-white tabular-nums',
|
||||
isCompact ? 'text-lg' : 'text-2xl',
|
||||
)}>
|
||||
{prefix && <span className="text-slate-400 me-1">{prefix}</span>}
|
||||
{formatted}
|
||||
{suffix && <span className="text-slate-400 ms-1 text-base">{suffix}</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sparklineData && sparklineData.length > 1 && (
|
||||
<div className={clsx(
|
||||
'shrink-0',
|
||||
isCompact ? 'w-14' : 'w-[72px]',
|
||||
trend?.direction === 'up' ? 'text-emerald-400' : 'text-rose-400',
|
||||
)}>
|
||||
<Sparkline data={sparklineData} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{trend && (
|
||||
<div className={clsx(
|
||||
'flex items-center gap-1',
|
||||
isCompact ? 'mt-1.5' : 'mt-3',
|
||||
)}>
|
||||
{trend.direction === 'up' ? (
|
||||
<TrendingUp className="h-3.5 w-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<TrendingDown className="h-3.5 w-3.5 text-rose-400" />
|
||||
)}
|
||||
<span
|
||||
className={clsx(
|
||||
'text-xs font-medium tabular-nums',
|
||||
trend.direction === 'up' ? 'text-emerald-400' : 'text-rose-400',
|
||||
)}
|
||||
>
|
||||
{trend.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export { KpiCard };
|
||||
export type { KpiCardProps };
|
||||
139
salesflow-saas/frontend/src/components/ui/modal.tsx
Normal file
139
salesflow-saas/frontend/src/components/ui/modal.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { clsx } from 'clsx';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
type ModalSize = 'sm' | 'md' | 'lg' | 'full';
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
size?: ModalSize;
|
||||
title?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
children: ReactNode;
|
||||
closeOnBackdrop?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeStyles: Record<ModalSize, string> = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-3xl',
|
||||
full: 'max-w-[calc(100vw-2rem)] max-h-[calc(100vh-2rem)] h-full',
|
||||
};
|
||||
|
||||
const backdropVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1 },
|
||||
};
|
||||
|
||||
const modalVariants = {
|
||||
hidden: { opacity: 0, scale: 0.95, y: 10 },
|
||||
visible: { opacity: 1, scale: 1, y: 0 },
|
||||
exit: { opacity: 0, scale: 0.95, y: 10 },
|
||||
};
|
||||
|
||||
function Modal({
|
||||
open,
|
||||
onClose,
|
||||
size = 'md',
|
||||
title,
|
||||
footer,
|
||||
children,
|
||||
closeOnBackdrop = true,
|
||||
className,
|
||||
}: ModalProps) {
|
||||
const handleEscape = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open, handleEscape]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
variants={backdropVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={closeOnBackdrop ? onClose : undefined}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={typeof title === 'string' ? title : undefined}
|
||||
variants={modalVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
transition={{ type: 'spring', stiffness: 350, damping: 25 }}
|
||||
className={clsx(
|
||||
'relative z-10 w-full',
|
||||
'bg-slate-900/95 backdrop-blur-xl',
|
||||
'border border-white/10 rounded-2xl',
|
||||
'shadow-2xl shadow-black/40',
|
||||
'flex flex-col overflow-hidden',
|
||||
sizeStyles[size],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-white">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-slate-400 hover:text-white hover:bg-white/10 transition-colors"
|
||||
aria-label="إغلاق"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={clsx('flex-1 overflow-y-auto p-6', !title && 'pt-10')}>
|
||||
{!title && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-3 end-3 rounded-lg p-1.5 text-slate-400 hover:text-white hover:bg-white/10 transition-colors z-10"
|
||||
aria-label="إغلاق"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<div className="border-t border-white/10 px-6 py-4 flex items-center justify-end gap-3">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export { Modal };
|
||||
export type { ModalProps, ModalSize };
|
||||
221
salesflow-saas/frontend/src/components/ui/sidebar.tsx
Normal file
221
salesflow-saas/frontend/src/components/ui/sidebar.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import { useState, createContext, useContext, type ReactNode } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { clsx } from 'clsx';
|
||||
import {
|
||||
LayoutDashboard, Users, MessageSquare, TrendingUp,
|
||||
Brain, Bot, Sparkles, Settings, ChevronLeft, ChevronRight,
|
||||
X, Menu, Phone, BarChart3, Shield,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SidebarContextValue {
|
||||
collapsed: boolean;
|
||||
toggle: () => void;
|
||||
mobileOpen: boolean;
|
||||
setMobileOpen: (v: boolean) => void;
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContextValue>({
|
||||
collapsed: false,
|
||||
toggle: () => {},
|
||||
mobileOpen: false,
|
||||
setMobileOpen: () => {},
|
||||
});
|
||||
|
||||
export const useSidebar = () => useContext(SidebarContext);
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
href: string;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
interface NavSection {
|
||||
title: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
const navigation: NavSection[] = [
|
||||
{
|
||||
title: 'الرئيسية',
|
||||
items: [
|
||||
{ label: 'لوحة التحكم', icon: <LayoutDashboard className="h-5 w-5" />, href: '/dashboard' },
|
||||
{ label: 'التحليلات', icon: <BarChart3 className="h-5 w-5" />, href: '/analytics' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'إدارة العملاء',
|
||||
items: [
|
||||
{ label: 'العملاء المحتملين', icon: <Users className="h-5 w-5" />, href: '/leads', badge: '12' },
|
||||
{ label: 'الصفقات', icon: <TrendingUp className="h-5 w-5" />, href: '/deals' },
|
||||
{ label: 'المحادثات', icon: <MessageSquare className="h-5 w-5" />, href: '/conversations', badge: '3' },
|
||||
{ label: 'المكالمات', icon: <Phone className="h-5 w-5" />, href: '/calls' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'الذكاء الاصطناعي',
|
||||
items: [
|
||||
{ label: 'مساعد الذكاء', icon: <Brain className="h-5 w-5" />, href: '/ai-assistant' },
|
||||
{ label: 'الأتمتة', icon: <Bot className="h-5 w-5" />, href: '/automation' },
|
||||
{ label: 'التوصيات', icon: <Sparkles className="h-5 w-5" />, href: '/recommendations' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'الإعدادات',
|
||||
items: [
|
||||
{ label: 'الإعدادات', icon: <Settings className="h-5 w-5" />, href: '/settings' },
|
||||
{ label: 'الخصوصية', icon: <Shield className="h-5 w-5" />, href: '/privacy' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function SidebarItem({ item, collapsed, active }: { item: NavItem; collapsed: boolean; active: boolean }) {
|
||||
return (
|
||||
<a
|
||||
href={item.href}
|
||||
className={clsx(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200',
|
||||
'hover:bg-white/10',
|
||||
active
|
||||
? 'bg-teal-500/15 text-teal-400 shadow-[inset_0_0_12px_rgba(20,184,166,0.1)]'
|
||||
: 'text-slate-400 hover:text-white',
|
||||
collapsed && 'justify-center px-2',
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0">{item.icon}</span>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
{item.badge && (
|
||||
<span className="rounded-full bg-teal-500/20 px-2 py-0.5 text-xs text-teal-400">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent({ collapsed, activePath }: { collapsed: boolean; activePath: string }) {
|
||||
return (
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-6">
|
||||
{navigation.map((section) => (
|
||||
<div key={section.title}>
|
||||
{!collapsed && (
|
||||
<p className="mb-2 ps-3 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
{section.title}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{section.items.map((item) => (
|
||||
<SidebarItem
|
||||
key={item.href}
|
||||
item={item}
|
||||
collapsed={collapsed}
|
||||
active={activePath === item.href}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
activePath?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function Sidebar({ activePath = '/dashboard', children }: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider
|
||||
value={{ collapsed, toggle: () => setCollapsed((v) => !v), mobileOpen, setMobileOpen }}
|
||||
>
|
||||
<div className="flex min-h-screen">
|
||||
{/* Desktop sidebar */}
|
||||
<motion.aside
|
||||
animate={{ width: collapsed ? 72 : 280 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 28 }}
|
||||
className={clsx(
|
||||
'hidden lg:flex flex-col fixed end-0 top-0 bottom-0 z-40',
|
||||
'bg-slate-900/80 backdrop-blur-xl border-s border-white/10',
|
||||
)}
|
||||
>
|
||||
<div className={clsx('flex items-center border-b border-white/10 h-16', collapsed ? 'justify-center px-2' : 'justify-between px-4')}>
|
||||
{!collapsed && <span className="text-lg font-bold text-teal-400">Dealix</span>}
|
||||
<button
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
className="rounded-lg p-1.5 text-slate-400 hover:text-white hover:bg-white/10 transition-colors"
|
||||
aria-label={collapsed ? 'توسيع القائمة' : 'طي القائمة'}
|
||||
>
|
||||
{collapsed ? <ChevronLeft className="h-5 w-5" /> : <ChevronRight className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
<SidebarContent collapsed={collapsed} activePath={activePath} />
|
||||
</motion.aside>
|
||||
|
||||
{/* Mobile trigger */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="fixed top-4 end-4 z-50 lg:hidden rounded-lg bg-slate-800/90 backdrop-blur p-2.5 text-white border border-white/10"
|
||||
aria-label="فتح القائمة"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
<AnimatePresence>
|
||||
{mobileOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm lg:hidden"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
<motion.aside
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 28 }}
|
||||
className="fixed end-0 top-0 bottom-0 z-50 w-72 bg-slate-900/95 backdrop-blur-xl border-s border-white/10 lg:hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 h-16 px-4">
|
||||
<span className="text-lg font-bold text-teal-400">Dealix</span>
|
||||
<button
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="rounded-lg p-1.5 text-slate-400 hover:text-white hover:bg-white/10 transition-colors"
|
||||
aria-label="إغلاق القائمة"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<SidebarContent collapsed={false} activePath={activePath} />
|
||||
</motion.aside>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Main content area */}
|
||||
<motion.main
|
||||
animate={{ marginInlineEnd: collapsed ? 72 : 280 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 28 }}
|
||||
className="flex-1 lg:me-0"
|
||||
>
|
||||
{children}
|
||||
</motion.main>
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export { Sidebar };
|
||||
export type { SidebarProps, NavItem, NavSection };
|
||||
140
salesflow-saas/frontend/src/components/ui/toast.tsx
Normal file
140
salesflow-saas/frontend/src/components/ui/toast.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
toast: (type: ToastType, message: string, duration?: number) => void;
|
||||
dismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Config */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const typeConfig: Record<ToastType, { icon: ReactNode; borderColor: string; iconBg: string }> = {
|
||||
success: {
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
borderColor: 'border-emerald-500/30',
|
||||
iconBg: 'bg-emerald-500/20 text-emerald-400',
|
||||
},
|
||||
error: {
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
borderColor: 'border-red-500/30',
|
||||
iconBg: 'bg-red-500/20 text-red-400',
|
||||
},
|
||||
warning: {
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
borderColor: 'border-amber-500/30',
|
||||
iconBg: 'bg-amber-500/20 text-amber-400',
|
||||
},
|
||||
info: {
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
borderColor: 'border-blue-500/30',
|
||||
iconBg: 'bg-blue-500/20 text-blue-400',
|
||||
},
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Context */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const ToastContext = createContext<ToastContextType | null>(null);
|
||||
|
||||
let idCounter = 0;
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const dismiss = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const addToast = useCallback((type: ToastType, message: string, duration = 5000) => {
|
||||
const id = `toast-${++idCounter}`;
|
||||
setToasts((prev) => [...prev, { id, type, message, duration }]);
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => dismiss(id), duration);
|
||||
}
|
||||
}, [dismiss]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast: addToast, dismiss }}>
|
||||
{children}
|
||||
|
||||
{/* Toast container -- bottom-end (RTL-safe) */}
|
||||
<div className="fixed bottom-4 end-4 z-[200] flex flex-col gap-2 max-w-sm w-full pointer-events-none">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{toasts.map((t) => {
|
||||
const cfg = typeConfig[t.type];
|
||||
return (
|
||||
<motion.div
|
||||
key={t.id}
|
||||
layout
|
||||
initial={{ opacity: 0, x: 40, scale: 0.95 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 40, scale: 0.95 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
|
||||
className={`pointer-events-auto flex items-start gap-3 px-4 py-3 rounded-xl bg-slate-900/95 backdrop-blur-2xl border ${cfg.borderColor} shadow-xl shadow-black/30`}
|
||||
>
|
||||
<span className={`shrink-0 w-7 h-7 rounded-lg flex items-center justify-center ${cfg.iconBg}`}>
|
||||
{cfg.icon}
|
||||
</span>
|
||||
<p className="flex-1 text-sm text-slate-200 leading-snug pt-0.5">{t.message}</p>
|
||||
<button
|
||||
onClick={() => dismiss(t.id)}
|
||||
className="shrink-0 p-0.5 rounded hover:bg-white/10 text-slate-500 hover:text-slate-300 transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): ToastContextType {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within ToastProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export type { ToastType, Toast, ToastContextType };
|
||||
385
salesflow-saas/frontend/src/i18n/ar.json
Normal file
385
salesflow-saas/frontend/src/i18n/ar.json
Normal file
@ -0,0 +1,385 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "Dealix",
|
||||
"tagline": "نظام المبيعات الذكي للسعودية",
|
||||
"description": "منصة تجارية مؤسسية تساعد الشركات على توليد الفرص وبناء الشراكات وإدارة الصفقات بذكاء"
|
||||
},
|
||||
"nav": {
|
||||
"home": "الرئيسية",
|
||||
"features": "المميزات",
|
||||
"pricing": "الأسعار",
|
||||
"about": "عن Dealix",
|
||||
"contact": "تواصل معنا",
|
||||
"login": "تسجيل الدخول",
|
||||
"register": "إنشاء حساب",
|
||||
"startFree": "ابدأ مجاناً",
|
||||
"watchDemo": "شاهد العرض",
|
||||
"dashboard": "لوحة التحكم",
|
||||
"settings": "الإعدادات",
|
||||
"logout": "تسجيل الخروج"
|
||||
},
|
||||
"hero": {
|
||||
"title": "نظام المبيعات الذكي للسعودية",
|
||||
"subtitle": "حوّل شركتك من مبيعات تقليدية إلى محرك نمو ذكي — اكتشف الفرص، ابنِ الشراكات، وأغلق الصفقات",
|
||||
"cta1": "ابدأ مجاناً — ١٤ يوم",
|
||||
"cta2": "شاهد كيف يعمل",
|
||||
"stats": {
|
||||
"companies": "+٥٠٠ شركة",
|
||||
"satisfaction": "٩٥٪ رضا",
|
||||
"deals": "+١٠٠٠ صفقة"
|
||||
}
|
||||
},
|
||||
"painPoints": {
|
||||
"title": "مشاكل يعاني منها كل مدير مبيعات",
|
||||
"items": [
|
||||
{
|
||||
"title": "عملاء ضايعين",
|
||||
"desc": "٧٠٪ من العملاء المحتملين يضيعون بسبب عدم المتابعة"
|
||||
},
|
||||
{
|
||||
"title": "فوضى الواتساب",
|
||||
"desc": "رسائل ضايعة وما تعرف مين رد ومين لا"
|
||||
},
|
||||
{
|
||||
"title": "بدون تقارير",
|
||||
"desc": "ما تعرف أداء فريقك ولا وين المشكلة"
|
||||
},
|
||||
{
|
||||
"title": "فرص ضائعة",
|
||||
"desc": "شراكات وصفقات ممكن تغير كل شي لكن ما تعرف عنها"
|
||||
}
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"title": "كل ما تحتاجه في مكان واحد",
|
||||
"items": [
|
||||
{
|
||||
"title": "واتساب ذكي",
|
||||
"desc": "تواصل مع عملاءك من الواتساب مباشرة مع رد تلقائي بالعربي"
|
||||
},
|
||||
{
|
||||
"title": "تقييم عملاء AI",
|
||||
"desc": "الذكاء الاصطناعي يقيّم كل عميل ويقولك مين الأهم"
|
||||
},
|
||||
{
|
||||
"title": "مسار صفقات بصري",
|
||||
"desc": "شوف كل صفقاتك بنظرة واحدة وحركها بالسحب"
|
||||
},
|
||||
{
|
||||
"title": "عروض أسعار احترافية",
|
||||
"desc": "أنشئ عروض أسعار بالعربي مع ضريبة القيمة المضافة تلقائياً"
|
||||
},
|
||||
{
|
||||
"title": "حماية البيانات PDPL",
|
||||
"desc": "متوافق مع نظام حماية البيانات الشخصية السعودي"
|
||||
},
|
||||
{
|
||||
"title": "تقارير وتحليلات",
|
||||
"desc": "اعرف أداء فريقك ومبيعاتك بتقارير يومية تلقائية"
|
||||
}
|
||||
]
|
||||
},
|
||||
"howItWorks": {
|
||||
"title": "كيف يعمل Dealix؟",
|
||||
"steps": [
|
||||
{
|
||||
"num": "١",
|
||||
"title": "سجّل شركتك",
|
||||
"desc": "أنشئ حسابك بأقل من دقيقة"
|
||||
},
|
||||
{
|
||||
"num": "٢",
|
||||
"title": "أضف عملاءك",
|
||||
"desc": "استورد عملاءك أو ابدأ من الصفر"
|
||||
},
|
||||
{
|
||||
"num": "٣",
|
||||
"title": "ابدأ البيع",
|
||||
"desc": "النظام يتابع ويذكرك ويساعدك تغلق أكثر"
|
||||
}
|
||||
]
|
||||
},
|
||||
"pricing": {
|
||||
"title": "باقة واحدة — كل شي مفتوح",
|
||||
"currency": "ر.س",
|
||||
"period": "شهرياً",
|
||||
"startTrial": "جرّب ٧ أيام مجاناً",
|
||||
"trialNote": "٧ أيام تجربة مجانية — بدون بطاقة — كل المميزات مفتوحة",
|
||||
"guarantee": "استرداد كامل خلال ٣٠ يوم إذا لم يعجبك",
|
||||
"plan": {
|
||||
"name": "Dealix All-in-One",
|
||||
"nameAr": "ديلكس الشاملة",
|
||||
"priceMonthly": "١٬٤٩٩",
|
||||
"priceYearly": "١٤٬٩٩٩",
|
||||
"priceYearlySave": "وفّر شهرين",
|
||||
"usersIncluded": "٢٠ مستخدم",
|
||||
"extraUser": "+٩٩ ر.س لكل مستخدم إضافي",
|
||||
"features": [
|
||||
"٧ أدمغة AI لكل قناة (واتساب، إيميل، لينكدإن، إنستقرام، تيكتوك، تويتر، سناب)",
|
||||
"صفقات استراتيجية — ١٥ نوع صفقة",
|
||||
"مفاوض AI بالعربي يفهم الثقافة السعودية",
|
||||
"Company Twin — نموذج شركتك الرقمي",
|
||||
"رصد استحواذات + محاكي نمو استراتيجي",
|
||||
"تقييم عملاء AI + تنبؤات مبيعات",
|
||||
"مسار صفقات بصري + عروض أسعار",
|
||||
"PDPL كامل + حوكمة + حماية بيانات",
|
||||
"دعم عربي وإنجليزي"
|
||||
]
|
||||
},
|
||||
"comparison": {
|
||||
"title": "قارن بنفسك",
|
||||
"dealix": "١٬٤٩٩ ر.س/شهر — كل شي",
|
||||
"salesforce": "٦٬٥٠٠+ ر.س/شهر — CRM فقط",
|
||||
"hubspot": "٥٬٦٠٠+ ر.س/شهر — Sales Hub فقط",
|
||||
"zoho": "١٬٩٥٠ ر.س/شهر — CRM فقط"
|
||||
}
|
||||
},
|
||||
"cta": {
|
||||
"title": "جاهز تنقل مبيعاتك للمستوى التالي؟",
|
||||
"subtitle": "١٤ يوم تجربة مجانية — بدون بطاقة ائتمانية",
|
||||
"button": "ابدأ الآن مجاناً"
|
||||
},
|
||||
"footer": {
|
||||
"product": "المنتج",
|
||||
"company": "الشركة",
|
||||
"support": "الدعم",
|
||||
"legal": "قانوني",
|
||||
"privacy": "سياسة الخصوصية",
|
||||
"terms": "الشروط والأحكام",
|
||||
"madeIn": "صنع بحب في السعودية"
|
||||
},
|
||||
"dashboard": {
|
||||
"tabs": {
|
||||
"overview": "لوحة القيادة",
|
||||
"pipeline": "مسار الصفقات",
|
||||
"inbox": "صندوق الوارد",
|
||||
"scoring": "تقييم العملاء",
|
||||
"leads": "العملاء المحتملين",
|
||||
"deals": "الصفقات",
|
||||
"partners": "الشركاء",
|
||||
"analytics": "التحليلات",
|
||||
"settings": "الإعدادات"
|
||||
},
|
||||
"kpis": {
|
||||
"totalLeads": "إجمالي العملاء",
|
||||
"newToday": "جديد اليوم",
|
||||
"openDeals": "صفقات مفتوحة",
|
||||
"wonValue": "قيمة المكسوب",
|
||||
"conversionRate": "معدل التحويل",
|
||||
"responseTime": "وقت الاستجابة"
|
||||
},
|
||||
"empty": {
|
||||
"noLeads": "لا يوجد عملاء بعد — أضف أول عميل",
|
||||
"noDeals": "لا توجد صفقات — أنشئ أول صفقة",
|
||||
"noMessages": "لا توجد رسائل — ابدأ محادثة"
|
||||
}
|
||||
},
|
||||
"marketers": {
|
||||
"title": "انضم لفريق مسوقي Dealix",
|
||||
"subtitle": "اكسب عمولات على كل عميل تجيبه — بدون راتب ثابت، بدون حدود",
|
||||
"cta": "سجّل كمسوّق",
|
||||
"benefits": [
|
||||
{
|
||||
"title": "عمولة فورية",
|
||||
"desc": "احصل على عمولتك مع كل صفقة ناجحة"
|
||||
},
|
||||
{
|
||||
"title": "أدوات احترافية",
|
||||
"desc": "نظام متابعة كامل وقوالب جاهزة"
|
||||
},
|
||||
{
|
||||
"title": "دعم مستمر",
|
||||
"desc": "فريق دعم متخصص يساعدك تنجح"
|
||||
},
|
||||
{
|
||||
"title": "شفافية كاملة",
|
||||
"desc": "تابع كل عمولاتك ونتائجك لحظة بلحظة"
|
||||
}
|
||||
],
|
||||
"stats": {
|
||||
"avgCommission": "متوسط العمولة الشهرية",
|
||||
"activeMarketers": "مسوّق نشط",
|
||||
"totalPaid": "إجمالي المدفوعات"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "جاري التحميل...",
|
||||
"error": "حدث خطأ — حاول مرة أخرى",
|
||||
"save": "حفظ",
|
||||
"cancel": "إلغاء",
|
||||
"delete": "حذف",
|
||||
"edit": "تعديل",
|
||||
"search": "بحث...",
|
||||
"filter": "تصفية",
|
||||
"export": "تصدير",
|
||||
"import": "استيراد",
|
||||
"confirm": "تأكيد",
|
||||
"back": "رجوع",
|
||||
"next": "التالي",
|
||||
"previous": "السابق",
|
||||
"yes": "نعم",
|
||||
"no": "لا",
|
||||
"sar": "ر.س",
|
||||
"noResults": "لا توجد نتائج",
|
||||
"close": "إغلاق",
|
||||
"or": "أو",
|
||||
"minutes": "دقائق",
|
||||
"hours": "ساعات",
|
||||
"today": "اليوم",
|
||||
"overdue": "متأخر",
|
||||
"upcoming": "قادم",
|
||||
"viewAll": "عرض الكل",
|
||||
"name": "الاسم",
|
||||
"email": "البريد الإلكتروني",
|
||||
"phone": "رقم الجوال",
|
||||
"submit": "إرسال",
|
||||
"skip": "تخطي",
|
||||
"getStarted": "ابدأ الآن",
|
||||
"completed": "مكتمل"
|
||||
},
|
||||
"commandPalette": {
|
||||
"placeholder": "ابحث عن أي شي...",
|
||||
"categories": {
|
||||
"navigation": "التنقل",
|
||||
"actions": "إجراءات",
|
||||
"contacts": "جهات الاتصال",
|
||||
"deals": "الصفقات",
|
||||
"recent": "الأخيرة"
|
||||
},
|
||||
"actions": {
|
||||
"newDeal": "إنشاء صفقة جديدة",
|
||||
"newContact": "إضافة جهة اتصال",
|
||||
"newTask": "إنشاء مهمة",
|
||||
"sendMessage": "إرسال رسالة",
|
||||
"goToPipeline": "الذهاب لمسار الصفقات",
|
||||
"goToInbox": "الذهاب لصندوق الوارد",
|
||||
"goToAnalytics": "الذهاب للتحليلات",
|
||||
"goToSettings": "الذهاب للإعدادات",
|
||||
"goToLeads": "الذهاب للعملاء المحتملين",
|
||||
"goToMarketers": "الذهاب لصفحة المسوقين"
|
||||
},
|
||||
"noResults": "لا توجد نتائج لـ",
|
||||
"typeToSearch": "اكتب للبحث..."
|
||||
},
|
||||
"workspace": {
|
||||
"greeting": "صباح الخير",
|
||||
"greetingEvening": "مساء الخير",
|
||||
"todaysTasks": "مهام اليوم",
|
||||
"hotDeals": "الصفقات الساخنة",
|
||||
"recentActivity": "النشاط الأخير",
|
||||
"aiInsights": "توصيات الذكاء الاصطناعي",
|
||||
"aiInsightFollowUp": "عملاء يحتاجون متابعة",
|
||||
"aiInsightClosing": "صفقات قريبة من الإغلاق",
|
||||
"aiInsightRisk": "صفقات في خطر",
|
||||
"taskOverdue": "متأخرة",
|
||||
"taskDueToday": "مطلوبة اليوم",
|
||||
"taskUpcoming": "قادمة",
|
||||
"noTasks": "لا توجد مهام — استمتع بيومك",
|
||||
"noDeals": "لا توجد صفقات ساخنة حالياً",
|
||||
"noActivity": "لا يوجد نشاط حديث",
|
||||
"stage": "المرحلة",
|
||||
"dealValue": "قيمة الصفقة",
|
||||
"activityTypes": {
|
||||
"message": "رسالة",
|
||||
"call": "مكالمة",
|
||||
"dealUpdate": "تحديث صفقة",
|
||||
"noteAdded": "ملاحظة جديدة"
|
||||
},
|
||||
"trend": {
|
||||
"up": "ارتفاع",
|
||||
"down": "انخفاض"
|
||||
},
|
||||
"kpiResponseUnit": "د"
|
||||
},
|
||||
"marketersPage": {
|
||||
"heroTitle": "انضم لفريق مسوقي Dealix",
|
||||
"heroSubtitle": "اكسب عمولات على كل عميل تجيبه — بدون راتب ثابت، بدون حدود",
|
||||
"statsAvgCommission": "متوسط العمولة الشهرية",
|
||||
"statsActiveMarketers": "مسوّق نشط",
|
||||
"statsTotalPaid": "إجمالي المدفوعات",
|
||||
"benefitsTitle": "لماذا تسوّق مع Dealix؟",
|
||||
"benefitInstantCommission": "عمولة فورية",
|
||||
"benefitInstantCommissionDesc": "احصل على عمولتك مع كل صفقة ناجحة — تحويل مباشر لحسابك",
|
||||
"benefitProTools": "أدوات احترافية",
|
||||
"benefitProToolsDesc": "نظام متابعة كامل، روابط تتبع، وقوالب تسويقية جاهزة",
|
||||
"benefitSupport": "دعم مستمر",
|
||||
"benefitSupportDesc": "فريق دعم متخصص يساعدك تنجح مع تدريب أسبوعي",
|
||||
"benefitTransparency": "شفافية كاملة",
|
||||
"benefitTransparencyDesc": "تابع كل عمولاتك ونتائجك لحظة بلحظة من لوحة التحكم",
|
||||
"howItWorksTitle": "كيف تبدأ؟",
|
||||
"step1Title": "سجّل كمسوّق",
|
||||
"step1Desc": "أنشئ حسابك مجاناً بأقل من دقيقة",
|
||||
"step2Title": "شارك رابطك",
|
||||
"step2Desc": "انشر رابط الإحالة الخاص بك في شبكتك",
|
||||
"step3Title": "اكسب عمولات",
|
||||
"step3Desc": "احصل على عمولة مع كل اشتراك ناجح",
|
||||
"tiersTitle": "مستويات العمولة",
|
||||
"tierBronze": "برونزي",
|
||||
"tierSilver": "فضي",
|
||||
"tierGold": "ذهبي",
|
||||
"tierBronzeDesc": "1-10 عملاء شهرياً",
|
||||
"tierSilverDesc": "11-30 عميل شهرياً",
|
||||
"tierGoldDesc": "+31 عميل شهرياً",
|
||||
"tierCommission": "عمولة",
|
||||
"testimonialsTitle": "ماذا يقول مسوقونا؟",
|
||||
"testimonial1Name": "محمد العتيبي",
|
||||
"testimonial1Role": "مسوّق ذهبي",
|
||||
"testimonial1Text": "بديت مع Dealix كتجربة وصار دخل أساسي. النظام سهل والعمولات فورية — أفضل برنامج تسويق تعاملت معاه.",
|
||||
"testimonial2Name": "نورة الحربي",
|
||||
"testimonial2Role": "مسوّقة فضية",
|
||||
"testimonial2Text": "الأدوات اللي يقدمونها تسهّل الشغل كثير. أقدر أتابع كل شي من الجوال والعمولة تنزل بسرعة.",
|
||||
"toolsTitle": "أدواتك كمسوّق",
|
||||
"toolDashboard": "لوحة تحكم شاملة",
|
||||
"toolLinks": "روابط تتبع ذكية",
|
||||
"toolTemplates": "قوالب تسويقية جاهزة",
|
||||
"toolReports": "تقارير أداء تفصيلية",
|
||||
"faqTitle": "الأسئلة الشائعة",
|
||||
"faq1Q": "كم يستغرق تفعيل حسابي؟",
|
||||
"faq1A": "يتم تفعيل حسابك فوراً بعد التسجيل وإكمال التحقق من الهوية.",
|
||||
"faq2Q": "متى أستلم عمولتي؟",
|
||||
"faq2A": "يتم تحويل العمولات أسبوعياً كل يوم أحد لحسابك البنكي.",
|
||||
"faq3Q": "هل أحتاج خبرة سابقة في التسويق؟",
|
||||
"faq3A": "لا، نوفر لك تدريب كامل وقوالب جاهزة تساعدك تبدأ حتى لو ما عندك خبرة.",
|
||||
"faq4Q": "هل هناك حد أقصى للعمولات؟",
|
||||
"faq4A": "لا يوجد حد أقصى — كل ما زاد عدد العملاء زادت عمولتك ومستواك.",
|
||||
"faq5Q": "هل أقدر أسوّق من أي مكان؟",
|
||||
"faq5A": "نعم، البرنامج متاح لكل المسوقين في السعودية ودول الخليج.",
|
||||
"ctaTitle": "جاهز تبدأ رحلتك مع Dealix؟",
|
||||
"ctaButton": "سجّل كمسوّق الآن",
|
||||
"formNamePlaceholder": "الاسم الكامل",
|
||||
"formPhonePlaceholder": "05xxxxxxxx",
|
||||
"formEmailPlaceholder": "email@example.com",
|
||||
"formSubmitting": "جاري التسجيل...",
|
||||
"formSuccess": "تم التسجيل بنجاح! سنتواصل معك قريباً."
|
||||
},
|
||||
"onboarding": {
|
||||
"welcomeTitle": "مرحباً بك في ديليكس",
|
||||
"welcomeSubtitle": "دقيقة واحدة لتخصيص تجربتك",
|
||||
"roleQuestion": "ما هو دورك؟",
|
||||
"roleSalesManager": "مدير مبيعات",
|
||||
"roleSalesRep": "مندوب مبيعات",
|
||||
"roleExecutive": "مدير تنفيذي",
|
||||
"roleOther": "أخرى",
|
||||
"industryQuestion": "ما هو مجال عملك؟",
|
||||
"industryRealEstate": "عقارات",
|
||||
"industryAutomotive": "سيارات",
|
||||
"industryHealthcare": "رعاية صحية",
|
||||
"industryServices": "خدمات",
|
||||
"industryOther": "أخرى",
|
||||
"firstValueTitle": "أنشئ أول صفقة",
|
||||
"firstValueSubtitle": "جرّب قوة المسار البصري",
|
||||
"sampleDealName": "صفقة عقار الرياض",
|
||||
"sampleDealValue": "٥٠٠,٠٠٠",
|
||||
"sampleContactName": "أحمد محمد",
|
||||
"sampleCompany": "شركة البناء المتقدم",
|
||||
"createDeal": "أنشئ الصفقة",
|
||||
"dealCreated": "ممتاز! أول صفقة لك",
|
||||
"checklistTitle": "أكمل إعداد حسابك",
|
||||
"checkImportContacts": "استورد جهات الاتصال",
|
||||
"checkConnectWhatsApp": "ربط الواتساب",
|
||||
"checkSetupPipeline": "إعداد مسار الصفقات",
|
||||
"checkInviteTeam": "دعوة فريقك",
|
||||
"checklistProgress": "مكتمل",
|
||||
"celebration": "أحسنت!"
|
||||
}
|
||||
}
|
||||
384
salesflow-saas/frontend/src/i18n/en.json
Normal file
384
salesflow-saas/frontend/src/i18n/en.json
Normal file
@ -0,0 +1,384 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "Dealix",
|
||||
"tagline": "The Smart Sales System for Saudi Arabia",
|
||||
"description": "An enterprise commercial platform that helps companies generate opportunities, build partnerships, and manage deals intelligently"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"features": "Features",
|
||||
"pricing": "Pricing",
|
||||
"about": "About",
|
||||
"contact": "Contact",
|
||||
"login": "Log In",
|
||||
"register": "Sign Up",
|
||||
"startFree": "Start Free",
|
||||
"watchDemo": "Watch Demo",
|
||||
"dashboard": "Dashboard",
|
||||
"settings": "Settings",
|
||||
"logout": "Log Out"
|
||||
},
|
||||
"hero": {
|
||||
"title": "The Smart Sales System for Saudi Arabia",
|
||||
"subtitle": "Transform your company from traditional sales to an intelligent growth engine — discover opportunities, build partnerships, and close deals",
|
||||
"cta1": "Start Free — 14 Days",
|
||||
"cta2": "See How It Works",
|
||||
"stats": {
|
||||
"companies": "500+ Companies",
|
||||
"satisfaction": "95% Satisfaction",
|
||||
"deals": "1000+ Deals"
|
||||
}
|
||||
},
|
||||
"painPoints": {
|
||||
"title": "Problems Every Sales Manager Faces",
|
||||
"items": [
|
||||
{
|
||||
"title": "Lost Leads",
|
||||
"desc": "70% of potential clients are lost due to poor follow-up"
|
||||
},
|
||||
{
|
||||
"title": "WhatsApp Chaos",
|
||||
"desc": "Messages get lost and you don't know who replied"
|
||||
},
|
||||
{
|
||||
"title": "No Reports",
|
||||
"desc": "You don't know your team's performance or where problems are"
|
||||
},
|
||||
{
|
||||
"title": "Missed Opportunities",
|
||||
"desc": "Partnerships and deals that could change everything go unnoticed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"title": "Everything You Need in One Place",
|
||||
"items": [
|
||||
{
|
||||
"title": "Smart WhatsApp",
|
||||
"desc": "Communicate with clients directly via WhatsApp with Arabic auto-reply"
|
||||
},
|
||||
{
|
||||
"title": "AI Lead Scoring",
|
||||
"desc": "AI evaluates every client and tells you who matters most"
|
||||
},
|
||||
{
|
||||
"title": "Visual Pipeline",
|
||||
"desc": "See all your deals at a glance and move them with drag & drop"
|
||||
},
|
||||
{
|
||||
"title": "Professional Quotes",
|
||||
"desc": "Create Arabic quotes with automatic VAT calculation"
|
||||
},
|
||||
{
|
||||
"title": "PDPL Compliance",
|
||||
"desc": "Fully compliant with Saudi Personal Data Protection Law"
|
||||
},
|
||||
{
|
||||
"title": "Reports & Analytics",
|
||||
"desc": "Know your team's performance with automatic daily reports"
|
||||
}
|
||||
]
|
||||
},
|
||||
"howItWorks": {
|
||||
"title": "How Does Dealix Work?",
|
||||
"steps": [
|
||||
{
|
||||
"num": "1",
|
||||
"title": "Register Your Company",
|
||||
"desc": "Create your account in less than a minute"
|
||||
},
|
||||
{
|
||||
"num": "2",
|
||||
"title": "Add Your Clients",
|
||||
"desc": "Import your clients or start from scratch"
|
||||
},
|
||||
{
|
||||
"num": "3",
|
||||
"title": "Start Selling",
|
||||
"desc": "The system follows up, reminds you, and helps you close more"
|
||||
}
|
||||
]
|
||||
},
|
||||
"pricing": {
|
||||
"title": "One Plan — Everything Included",
|
||||
"currency": "SAR",
|
||||
"period": "monthly",
|
||||
"startTrial": "Try 7 Days Free",
|
||||
"trialNote": "7-day free trial — no credit card — all features unlocked",
|
||||
"guarantee": "Full refund within 30 days if not satisfied",
|
||||
"plan": {
|
||||
"name": "Dealix All-in-One",
|
||||
"priceMonthly": "1,499",
|
||||
"priceYearly": "14,999",
|
||||
"priceYearlySave": "Save 2 months",
|
||||
"usersIncluded": "20 users",
|
||||
"extraUser": "+99 SAR per extra user",
|
||||
"features": [
|
||||
"7 AI Brains for every channel (WhatsApp, Email, LinkedIn, Instagram, TikTok, Twitter, Snapchat)",
|
||||
"Strategic Deals — 15 deal types",
|
||||
"Arabic AI Negotiator with Saudi cultural awareness",
|
||||
"Company Twin — your digital company model",
|
||||
"Acquisition scouting + strategic growth simulator",
|
||||
"AI lead scoring + sales forecasting",
|
||||
"Visual pipeline + professional quotes (CPQ)",
|
||||
"Full PDPL compliance + governance + data protection",
|
||||
"Arabic and English support"
|
||||
]
|
||||
},
|
||||
"comparison": {
|
||||
"title": "Compare for yourself",
|
||||
"dealix": "1,499 SAR/mo — everything",
|
||||
"salesforce": "6,500+ SAR/mo — CRM only",
|
||||
"hubspot": "5,600+ SAR/mo — Sales Hub only",
|
||||
"zoho": "1,950 SAR/mo — CRM only"
|
||||
}
|
||||
},
|
||||
"cta": {
|
||||
"title": "Ready to Take Your Sales to the Next Level?",
|
||||
"subtitle": "14-day free trial — no credit card required",
|
||||
"button": "Start Free Now"
|
||||
},
|
||||
"footer": {
|
||||
"product": "Product",
|
||||
"company": "Company",
|
||||
"support": "Support",
|
||||
"legal": "Legal",
|
||||
"privacy": "Privacy Policy",
|
||||
"terms": "Terms of Service",
|
||||
"madeIn": "Made with love in Saudi Arabia"
|
||||
},
|
||||
"dashboard": {
|
||||
"tabs": {
|
||||
"overview": "Dashboard",
|
||||
"pipeline": "Deal Pipeline",
|
||||
"inbox": "Inbox",
|
||||
"scoring": "Lead Scoring",
|
||||
"leads": "Leads",
|
||||
"deals": "Deals",
|
||||
"partners": "Partners",
|
||||
"analytics": "Analytics",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"kpis": {
|
||||
"totalLeads": "Total Leads",
|
||||
"newToday": "New Today",
|
||||
"openDeals": "Open Deals",
|
||||
"wonValue": "Won Value",
|
||||
"conversionRate": "Conversion Rate",
|
||||
"responseTime": "Response Time"
|
||||
},
|
||||
"empty": {
|
||||
"noLeads": "No leads yet — add your first client",
|
||||
"noDeals": "No deals yet — create your first deal",
|
||||
"noMessages": "No messages yet — start a conversation"
|
||||
}
|
||||
},
|
||||
"marketers": {
|
||||
"title": "Join the Dealix Marketing Team",
|
||||
"subtitle": "Earn commissions on every client you bring — no fixed salary, no limits",
|
||||
"cta": "Register as Marketer",
|
||||
"benefits": [
|
||||
{
|
||||
"title": "Instant Commission",
|
||||
"desc": "Get your commission with every successful deal"
|
||||
},
|
||||
{
|
||||
"title": "Pro Tools",
|
||||
"desc": "Complete tracking system and ready templates"
|
||||
},
|
||||
{
|
||||
"title": "Ongoing Support",
|
||||
"desc": "Dedicated support team to help you succeed"
|
||||
},
|
||||
{
|
||||
"title": "Full Transparency",
|
||||
"desc": "Track all your commissions and results in real-time"
|
||||
}
|
||||
],
|
||||
"stats": {
|
||||
"avgCommission": "Average Monthly Commission",
|
||||
"activeMarketers": "Active Marketers",
|
||||
"totalPaid": "Total Paid Out"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"error": "An error occurred — please try again",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"search": "Search...",
|
||||
"filter": "Filter",
|
||||
"export": "Export",
|
||||
"import": "Import",
|
||||
"confirm": "Confirm",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"sar": "SAR",
|
||||
"noResults": "No results found",
|
||||
"close": "Close",
|
||||
"or": "or",
|
||||
"minutes": "minutes",
|
||||
"hours": "hours",
|
||||
"today": "Today",
|
||||
"overdue": "Overdue",
|
||||
"upcoming": "Upcoming",
|
||||
"viewAll": "View All",
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"submit": "Submit",
|
||||
"skip": "Skip",
|
||||
"getStarted": "Get Started",
|
||||
"completed": "Completed"
|
||||
},
|
||||
"commandPalette": {
|
||||
"placeholder": "Search anything...",
|
||||
"categories": {
|
||||
"navigation": "Navigation",
|
||||
"actions": "Actions",
|
||||
"contacts": "Contacts",
|
||||
"deals": "Deals",
|
||||
"recent": "Recent"
|
||||
},
|
||||
"actions": {
|
||||
"newDeal": "Create New Deal",
|
||||
"newContact": "Add Contact",
|
||||
"newTask": "Create Task",
|
||||
"sendMessage": "Send Message",
|
||||
"goToPipeline": "Go to Pipeline",
|
||||
"goToInbox": "Go to Inbox",
|
||||
"goToAnalytics": "Go to Analytics",
|
||||
"goToSettings": "Go to Settings",
|
||||
"goToLeads": "Go to Leads",
|
||||
"goToMarketers": "Go to Marketers"
|
||||
},
|
||||
"noResults": "No results for",
|
||||
"typeToSearch": "Type to search..."
|
||||
},
|
||||
"workspace": {
|
||||
"greeting": "Good morning",
|
||||
"greetingEvening": "Good evening",
|
||||
"todaysTasks": "Today's Tasks",
|
||||
"hotDeals": "Hot Deals",
|
||||
"recentActivity": "Recent Activity",
|
||||
"aiInsights": "AI Insights",
|
||||
"aiInsightFollowUp": "clients need follow-up",
|
||||
"aiInsightClosing": "deals near closing",
|
||||
"aiInsightRisk": "deals at risk",
|
||||
"taskOverdue": "Overdue",
|
||||
"taskDueToday": "Due Today",
|
||||
"taskUpcoming": "Upcoming",
|
||||
"noTasks": "No tasks — enjoy your day",
|
||||
"noDeals": "No hot deals right now",
|
||||
"noActivity": "No recent activity",
|
||||
"stage": "Stage",
|
||||
"dealValue": "Deal Value",
|
||||
"activityTypes": {
|
||||
"message": "Message",
|
||||
"call": "Call",
|
||||
"dealUpdate": "Deal Update",
|
||||
"noteAdded": "Note Added"
|
||||
},
|
||||
"trend": {
|
||||
"up": "increase",
|
||||
"down": "decrease"
|
||||
},
|
||||
"kpiResponseUnit": "m"
|
||||
},
|
||||
"marketersPage": {
|
||||
"heroTitle": "Join the Dealix Marketing Team",
|
||||
"heroSubtitle": "Earn commissions on every client you bring — no fixed salary, no limits",
|
||||
"statsAvgCommission": "Average Monthly Commission",
|
||||
"statsActiveMarketers": "Active Marketers",
|
||||
"statsTotalPaid": "Total Paid Out",
|
||||
"benefitsTitle": "Why Market with Dealix?",
|
||||
"benefitInstantCommission": "Instant Commission",
|
||||
"benefitInstantCommissionDesc": "Get your commission with every successful deal — direct transfer to your account",
|
||||
"benefitProTools": "Pro Tools",
|
||||
"benefitProToolsDesc": "Complete tracking system, smart links, and ready marketing templates",
|
||||
"benefitSupport": "Ongoing Support",
|
||||
"benefitSupportDesc": "Dedicated support team to help you succeed with weekly training",
|
||||
"benefitTransparency": "Full Transparency",
|
||||
"benefitTransparencyDesc": "Track all your commissions and results in real-time from your dashboard",
|
||||
"howItWorksTitle": "How to Get Started?",
|
||||
"step1Title": "Register as Marketer",
|
||||
"step1Desc": "Create your free account in less than a minute",
|
||||
"step2Title": "Share Your Link",
|
||||
"step2Desc": "Spread your referral link across your network",
|
||||
"step3Title": "Earn Commissions",
|
||||
"step3Desc": "Get a commission with every successful subscription",
|
||||
"tiersTitle": "Commission Tiers",
|
||||
"tierBronze": "Bronze",
|
||||
"tierSilver": "Silver",
|
||||
"tierGold": "Gold",
|
||||
"tierBronzeDesc": "1-10 clients/month",
|
||||
"tierSilverDesc": "11-30 clients/month",
|
||||
"tierGoldDesc": "31+ clients/month",
|
||||
"tierCommission": "commission",
|
||||
"testimonialsTitle": "What Our Marketers Say",
|
||||
"testimonial1Name": "Mohammed Al-Otaibi",
|
||||
"testimonial1Role": "Gold Marketer",
|
||||
"testimonial1Text": "I started with Dealix as an experiment and it became my main income. The system is easy and commissions are instant — best marketing program I've used.",
|
||||
"testimonial2Name": "Noura Al-Harbi",
|
||||
"testimonial2Role": "Silver Marketer",
|
||||
"testimonial2Text": "The tools they provide make work so much easier. I can track everything from my phone and commissions arrive quickly.",
|
||||
"toolsTitle": "Your Marketer Tools",
|
||||
"toolDashboard": "Comprehensive Dashboard",
|
||||
"toolLinks": "Smart Tracking Links",
|
||||
"toolTemplates": "Ready Marketing Templates",
|
||||
"toolReports": "Detailed Performance Reports",
|
||||
"faqTitle": "Frequently Asked Questions",
|
||||
"faq1Q": "How long does account activation take?",
|
||||
"faq1A": "Your account is activated immediately after registration and identity verification.",
|
||||
"faq2Q": "When do I receive my commission?",
|
||||
"faq2A": "Commissions are transferred weekly every Sunday to your bank account.",
|
||||
"faq3Q": "Do I need prior marketing experience?",
|
||||
"faq3A": "No, we provide complete training and ready templates to help you start even without experience.",
|
||||
"faq4Q": "Is there a maximum commission limit?",
|
||||
"faq4A": "No limit — the more clients you bring, the higher your commission and tier.",
|
||||
"faq5Q": "Can I market from anywhere?",
|
||||
"faq5A": "Yes, the program is available to all marketers in Saudi Arabia and the Gulf region.",
|
||||
"ctaTitle": "Ready to Start Your Journey with Dealix?",
|
||||
"ctaButton": "Register as Marketer Now",
|
||||
"formNamePlaceholder": "Full Name",
|
||||
"formPhonePlaceholder": "05xxxxxxxx",
|
||||
"formEmailPlaceholder": "email@example.com",
|
||||
"formSubmitting": "Registering...",
|
||||
"formSuccess": "Registered successfully! We'll contact you soon."
|
||||
},
|
||||
"onboarding": {
|
||||
"welcomeTitle": "Welcome to Dealix",
|
||||
"welcomeSubtitle": "One minute to personalize your experience",
|
||||
"roleQuestion": "What's your role?",
|
||||
"roleSalesManager": "Sales Manager",
|
||||
"roleSalesRep": "Sales Rep",
|
||||
"roleExecutive": "Executive",
|
||||
"roleOther": "Other",
|
||||
"industryQuestion": "What's your industry?",
|
||||
"industryRealEstate": "Real Estate",
|
||||
"industryAutomotive": "Automotive",
|
||||
"industryHealthcare": "Healthcare",
|
||||
"industryServices": "Services",
|
||||
"industryOther": "Other",
|
||||
"firstValueTitle": "Create Your First Deal",
|
||||
"firstValueSubtitle": "Experience the power of the visual pipeline",
|
||||
"sampleDealName": "Riyadh Property Deal",
|
||||
"sampleDealValue": "500,000",
|
||||
"sampleContactName": "Ahmed Mohammed",
|
||||
"sampleCompany": "Advanced Construction Co.",
|
||||
"createDeal": "Create Deal",
|
||||
"dealCreated": "Excellent! Your first deal",
|
||||
"checklistTitle": "Complete Your Setup",
|
||||
"checkImportContacts": "Import Contacts",
|
||||
"checkConnectWhatsApp": "Connect WhatsApp",
|
||||
"checkSetupPipeline": "Set Up Pipeline",
|
||||
"checkInviteTeam": "Invite Your Team",
|
||||
"checklistProgress": "completed",
|
||||
"celebration": "Well done!"
|
||||
}
|
||||
}
|
||||
91
salesflow-saas/frontend/src/i18n/index.tsx
Normal file
91
salesflow-saas/frontend/src/i18n/index.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, ReactNode } from "react";
|
||||
import ar from "./ar.json";
|
||||
import en from "./en.json";
|
||||
|
||||
type Locale = "ar" | "en";
|
||||
type Translations = typeof ar;
|
||||
|
||||
interface I18nContextType {
|
||||
locale: Locale;
|
||||
t: (key: string) => string;
|
||||
switchLocale: (locale: Locale) => void;
|
||||
dir: "rtl" | "ltr";
|
||||
isArabic: boolean;
|
||||
}
|
||||
|
||||
const translations: Record<Locale, Translations> = { ar, en };
|
||||
|
||||
const I18nContext = createContext<I18nContextType | null>(null);
|
||||
|
||||
function getNestedValue(obj: any, path: string): string {
|
||||
const keys = path.split(".");
|
||||
let current = obj;
|
||||
for (const key of keys) {
|
||||
if (current === undefined || current === null) return path;
|
||||
current = current[key];
|
||||
}
|
||||
return typeof current === "string" ? current : path;
|
||||
}
|
||||
|
||||
export function I18nProvider({ children, defaultLocale = "ar" }: { children: ReactNode; defaultLocale?: Locale }) {
|
||||
const [locale, setLocale] = useState<Locale>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return (localStorage.getItem("dealix-locale") as Locale) || defaultLocale;
|
||||
}
|
||||
return defaultLocale;
|
||||
});
|
||||
|
||||
const t = useCallback(
|
||||
(key: string): string => {
|
||||
return getNestedValue(translations[locale], key);
|
||||
},
|
||||
[locale]
|
||||
);
|
||||
|
||||
const switchLocale = useCallback((newLocale: Locale) => {
|
||||
setLocale(newLocale);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("dealix-locale", newLocale);
|
||||
document.documentElement.dir = newLocale === "ar" ? "rtl" : "ltr";
|
||||
document.documentElement.lang = newLocale;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value: I18nContextType = {
|
||||
locale,
|
||||
t,
|
||||
switchLocale,
|
||||
dir: locale === "ar" ? "rtl" : "ltr",
|
||||
isArabic: locale === "ar",
|
||||
};
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
|
||||
export function useI18n(): I18nContextType {
|
||||
const context = useContext(I18nContext);
|
||||
if (!context) {
|
||||
throw new Error("useI18n must be used within I18nProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const { locale, switchLocale } = useI18n();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => switchLocale(locale === "ar" ? "en" : "ar")}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/10 hover:bg-white/20
|
||||
text-sm font-medium transition-all duration-200 backdrop-blur-sm border border-white/10"
|
||||
aria-label="Switch language"
|
||||
>
|
||||
<span className="text-lg">{locale === "ar" ? "🇬🇧" : "🇸🇦"}</span>
|
||||
<span>{locale === "ar" ? "English" : "عربي"}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export type { Locale, I18nContextType };
|
||||
123
salesflow-saas/memory/architecture/dealix-prd-v2.md
Normal file
123
salesflow-saas/memory/architecture/dealix-prd-v2.md
Normal file
@ -0,0 +1,123 @@
|
||||
# Dealix PRD — Product Requirements Document
|
||||
|
||||
**Version**: 2.0 | **Date**: 2026-04-11 | **Status**: Active
|
||||
|
||||
## Product Identity
|
||||
|
||||
**Name**: Dealix
|
||||
**Type**: Commercial Intelligence & Deal Operating System
|
||||
**Market**: Saudi B2B (primary), GCC (secondary)
|
||||
**Position**: Not a CRM. A Revenue + Partnership + Strategic Deal OS.
|
||||
|
||||
## Architecture: 4 Layers
|
||||
|
||||
```
|
||||
Layer 3: Strategic Growth OS (acquisition, ecosystem, ROI)
|
||||
Layer 2: Deal Exchange OS (barter, co-sell, reseller, partnerships)
|
||||
Layer 1: Sales OS (leads, outreach, proposals, pipeline)
|
||||
Layer 0: Core Platform (Company Twin, Taxonomy, Channels, Approvals, Trust, Memory)
|
||||
```
|
||||
|
||||
## Layer 0 — Core Platform
|
||||
|
||||
### 0.1 Company Twin
|
||||
Every tenant gets a structured digital twin:
|
||||
- Identity: name, industry, CR, geography, size
|
||||
- Capabilities: services, products, white-label capacity, barter assets
|
||||
- Needs: marketing, delivery, distribution, capital, partners
|
||||
- Authority Matrix: what AI can commit vs what needs approval
|
||||
- Red Lines: forbidden claims, blocked sectors, pricing floors
|
||||
- Deal Preferences: allowed/blocked deal types, min/max values
|
||||
|
||||
### 0.2 Deal Taxonomy (15 types)
|
||||
sales_lead, referral, co_selling, co_marketing, subcontracting,
|
||||
white_label, reseller, strategic_alliance, channel_partnership,
|
||||
joint_venture, acquisition_scouting, investment_intro,
|
||||
vendor_replacement, capability_gap_fill, tender_consortium
|
||||
|
||||
### 0.3 Channel Engine
|
||||
- Email: PRIMARY outbound (SPF/DKIM/DMARC, unsubscribe, consent)
|
||||
- LinkedIn: ASSIST-MODE ONLY (draft, queue, operator review — no bots)
|
||||
- WhatsApp: WARM ONLY (opt-in, 24h window, approved templates)
|
||||
|
||||
### 0.4 Approval Center
|
||||
- Class A (auto): summarize, classify, score, internal drafts
|
||||
- Class B (approval): send outreach, share pricing, propose terms
|
||||
- Class C (executive): exclusivity, equity, legal, acquisition
|
||||
|
||||
### 0.5 Trust & Verification
|
||||
Every agent run produces: claim → actual action → evidence → verdict
|
||||
Verdicts: VERIFIED, PARTIAL, UNVERIFIED, CONTRADICTED, BLOCKED
|
||||
|
||||
### 0.6 Shared Memory
|
||||
Operational + account + market + negotiation + campaign memory
|
||||
DB = source of truth. Memory = assistive recall layer.
|
||||
|
||||
### 0.7 Observability
|
||||
Cost tracking, performance metrics, channel health, anomaly detection
|
||||
|
||||
## Layer 1 — Sales OS
|
||||
|
||||
### Modules
|
||||
- ICP Engine: customer segment definitions
|
||||
- Lead Discovery: company sourcing + enrichment
|
||||
- Lead Intelligence: pain inference, urgency, entry point
|
||||
- Outreach Engine: email sequences, LinkedIn assist, WhatsApp warm
|
||||
- Proposal Engine: scoped offers, pricing, pilot options
|
||||
- Sales Memory: objections, patterns, what worked
|
||||
- Pipeline: stage management, velocity tracking
|
||||
|
||||
### KPIs
|
||||
lead→meeting, meeting→proposal, proposal→close, reply rate, cycle time
|
||||
|
||||
## Layer 2 — Deal Exchange OS
|
||||
|
||||
### Modules
|
||||
- Offer Graph: what we can provide as partner
|
||||
- Need Graph: what we lack
|
||||
- Reciprocal Match Engine: mutual value scoring
|
||||
- Partner Scoring: fit, reciprocity, credibility, risk
|
||||
- Reciprocal Offer Generator: barter/co-sell/reseller structures
|
||||
- Pilot Proposal Generator: bounded first-step deals
|
||||
- Deal Room: workspace with BATNA, concessions, approvals
|
||||
|
||||
### KPIs
|
||||
partner response rate, pilot acceptance, pilot→full conversion, reciprocal value
|
||||
|
||||
## Layer 3 — Strategic Growth OS
|
||||
|
||||
### Modules
|
||||
- Acquisition Scouting: target sourcing + scoring + briefs
|
||||
- Ecosystem Mapper: partner landscape visualization
|
||||
- Strategic Simulator: scenario modeling (upside/downside/risk)
|
||||
- ROI Engine: CAC reduction, distribution value, margin impact
|
||||
- Partner Performance Graph: contribution tracking
|
||||
- Portfolio Intelligence: vertical wins, pattern detection
|
||||
|
||||
### KPIs
|
||||
partner-sourced revenue, acquisition candidates qualified, ecosystem coverage
|
||||
|
||||
## Non-Goals
|
||||
- NOT a general-purpose chatbot
|
||||
- NOT an uncontrolled automation bot
|
||||
- NOT a financial trading system
|
||||
- NOT a replacement for legal review on binding terms
|
||||
|
||||
## Success Criteria
|
||||
1. Product understands each client's business model (Company Twin)
|
||||
2. Discovers and scores strategic counterparties
|
||||
3. Generates structured opportunities, not just raw leads
|
||||
4. Manages multi-channel outreach under policy
|
||||
5. Preserves commercial memory across sessions
|
||||
6. Verifies what agents actually did
|
||||
7. Runs safely with approval gates
|
||||
8. Survives launch simulation
|
||||
9. UI feels premium and operational
|
||||
10. Architecture is coherent and maintainable
|
||||
|
||||
## Phased Rollout
|
||||
- Sprint 1: Core Platform + Company Twin + Taxonomy + Approvals
|
||||
- Sprint 2: Sales OS MVP (lead→outreach→pipeline)
|
||||
- Sprint 3: Deal Exchange OS MVP (matching→deal room→pilot)
|
||||
- Sprint 4: Strategic Growth MVP (scouting→simulator→ROI)
|
||||
- Sprint 5: Hardening + QA + Launch
|
||||
97
salesflow-saas/memory/architecture/module-map.md
Normal file
97
salesflow-saas/memory/architecture/module-map.md
Normal file
@ -0,0 +1,97 @@
|
||||
# Dealix Module Map
|
||||
|
||||
## Layer 0 — Core Platform (Built)
|
||||
|
||||
| Module | File | Status | Lines |
|
||||
|--------|------|--------|-------|
|
||||
| Company Twin | `strategic_deals/company_twin.py` | ✅ | 792 |
|
||||
| Deal Taxonomy | `strategic_deals/deal_taxonomy.py` | ✅ | 573 |
|
||||
| Deal Room | `strategic_deals/deal_room.py` | ✅ | 674 |
|
||||
| Operating Modes | `strategic_deals/operating_modes.py` | ✅ | 429 |
|
||||
| Channel Compliance | `strategic_deals/channel_compliance.py` | ✅ | 803 |
|
||||
| Approval Center | `security_gate.py` + `escalation.py` | ✅ | ~600 |
|
||||
| Trust & Verification | `tool_verification.py` + `tool_receipts.py` | ✅ | ~600 |
|
||||
| Memory Engine | `memory_engine.py` + `knowledge_brain.py` | ✅ | ~560 |
|
||||
| Session Continuity | `session_continuity.py` | ✅ | ~230 |
|
||||
| Observability | `observability.py` | ✅ | ~350 |
|
||||
| Feature Flags | `feature_flags.py` | ✅ | ~500 |
|
||||
| Hermes Orchestrator | `hermes_orchestrator.py` | ✅ | ~450 |
|
||||
| Execution Router | `execution_router.py` | ✅ | ~375 |
|
||||
|
||||
## Layer 1 — Sales OS (Built)
|
||||
|
||||
| Module | File | Status |
|
||||
|--------|------|--------|
|
||||
| Lead Service | `lead_service.py` | ✅ |
|
||||
| AI Lead Scoring | `ai/lead_scoring.py` | ✅ |
|
||||
| Arabic NLP | `ai/arabic_nlp.py` | ✅ |
|
||||
| Conversation Intelligence | `ai/conversation_intelligence.py` | ✅ |
|
||||
| Message Writer | `ai/message_writer.py` | ✅ |
|
||||
| Sales Forecasting | `ai/forecasting.py` | ✅ |
|
||||
| AI Sales Agent | `ai/sales_agent.py` | ✅ |
|
||||
| Sequence Engine | `sequence_engine.py` | ✅ |
|
||||
| Territory Manager | `territory_manager.py` | ✅ |
|
||||
| CPQ Quote Engine | `cpq/quote_engine.py` | ✅ |
|
||||
| Proposal Generator | `cpq/proposal_generator.py` | ✅ |
|
||||
| WhatsApp Service | `whatsapp_service.py` | ✅ |
|
||||
| Email Service | `email_service.py` | ✅ |
|
||||
| Lead Generation | `lead_generation.py` | ✅ |
|
||||
| Auto Pipeline | `auto_pipeline.py` | ✅ |
|
||||
|
||||
## Layer 2 — Deal Exchange OS (Built)
|
||||
|
||||
| Module | File | Status |
|
||||
|--------|------|--------|
|
||||
| Company Profiler | `strategic_deals/company_profiler.py` | ✅ |
|
||||
| Deal Matcher | `strategic_deals/deal_matcher.py` | ✅ |
|
||||
| Deal Negotiator | `strategic_deals/deal_negotiator.py` | ✅ |
|
||||
| Deal Agent | `strategic_deals/deal_agent.py` | ✅ |
|
||||
| Company Twin | `strategic_deals/company_twin.py` | ✅ |
|
||||
| Deal Taxonomy | `strategic_deals/deal_taxonomy.py` | ✅ |
|
||||
| Deal Room | `strategic_deals/deal_room.py` | ✅ |
|
||||
| Channel Compliance | `strategic_deals/channel_compliance.py` | ✅ |
|
||||
| Operating Modes | `strategic_deals/operating_modes.py` | ✅ |
|
||||
|
||||
## Layer 3 — Strategic Growth OS (Planned)
|
||||
|
||||
| Module | File | Status |
|
||||
|--------|------|--------|
|
||||
| Acquisition Scouting | `strategic_deals/acquisition_scouting.py` | 📋 Planned |
|
||||
| Ecosystem Mapper | `strategic_deals/ecosystem_mapper.py` | 📋 Planned |
|
||||
| Strategic Simulator | `strategic_deals/strategic_simulator.py` | 📋 Planned |
|
||||
| ROI Engine | `strategic_deals/roi_engine.py` | 📋 Planned |
|
||||
| Portfolio Intelligence | `strategic_deals/portfolio_intelligence.py` | 📋 Planned |
|
||||
|
||||
## Governance & Ops (Built)
|
||||
|
||||
| Module | File | Status |
|
||||
|--------|------|--------|
|
||||
| gstack Discipline | `gstack_discipline.py` | ✅ |
|
||||
| Skill Registry | `skill_registry.py` | ✅ |
|
||||
| Skill Governance | `skill_governance.py` | ✅ |
|
||||
| Autopilot | `autopilot.py` | ✅ |
|
||||
| Self-Improvement | `self_improvement.py` | ✅ |
|
||||
| Shannon Security | `shannon_security.py` | ✅ |
|
||||
| Signal Intelligence | `signal_intelligence.py` | ✅ |
|
||||
| Alert Delivery | `alert_delivery.py` | ✅ |
|
||||
| Behavior Intelligence | `behavior_intelligence.py` | ✅ |
|
||||
| Arabic Ops | `arabic_ops.py` | ✅ |
|
||||
| Local Inference | `local_inference.py` | ✅ |
|
||||
|
||||
## Frontend (Built)
|
||||
|
||||
| Component | File | Status |
|
||||
|-----------|------|--------|
|
||||
| Premium Landing | `premium-landing.tsx` | ✅ |
|
||||
| Pipeline Kanban | `pipeline-kanban.tsx` | ✅ |
|
||||
| Unified Inbox | `unified-inbox.tsx` | ✅ |
|
||||
| Lead Score Card | `lead-score-card.tsx` | ✅ |
|
||||
| 3D Logo | `dealix-3d-logo.tsx` | ✅ |
|
||||
| Stats Counter | `stats-counter.tsx` | ✅ |
|
||||
| UI Library | `ui/` (7 components) | ✅ |
|
||||
| Dashboard Views | 25+ views | ✅ |
|
||||
|
||||
## Summary
|
||||
- **Built**: ~50 backend services, 52 API routes, 30 models, 37 frontend components
|
||||
- **Planned**: 5 Strategic Growth modules (Layer 3)
|
||||
- **Completion**: ~90% of full vision
|
||||
@ -0,0 +1,68 @@
|
||||
# Dealix Transformation Master Prompt
|
||||
|
||||
**Date**: 2026-04-11 | **Status**: active | **Type**: master-prompt
|
||||
|
||||
## Summary
|
||||
|
||||
This is the definitive master prompt for transforming Dealix from a CRM into a
|
||||
Revenue + Partnership + Strategic Deal Operating System.
|
||||
|
||||
## The 3 Master Layers
|
||||
|
||||
### Layer 1: Intelligence / Memory / Source of Truth
|
||||
- Company Twin (capabilities graph + needs graph + authority matrix)
|
||||
- Business memory (operational, account, market, negotiation, campaign)
|
||||
- PostgreSQL = source of truth, memory = assistive layer
|
||||
- Tenant-scoped, traceable, auditable
|
||||
|
||||
### Layer 2: Execution / Orchestration / Autonomy
|
||||
- Lead & partner discovery engine
|
||||
- Strategic deals engine (15 deal types)
|
||||
- Channel execution (email-first, WhatsApp warm, LinkedIn assist)
|
||||
- Negotiation preparation & copilot
|
||||
- Browser operations (structured, observable)
|
||||
- Planning discipline (read → plan → resolve → execute → verify)
|
||||
|
||||
### Layer 3: Trust / QA / Safety / Release / Self-Improvement
|
||||
- Claim vs execution verification
|
||||
- Approval classes (read-only → draft → send → negotiate → commit)
|
||||
- 5 operating modes (manual → strategic execution)
|
||||
- Channel compliance (platform rules enforced)
|
||||
- Cost control & observability
|
||||
- Launch simulation & daily QA
|
||||
- Evidence-based self-improvement
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **From Lead Engine → Opportunity Engine**: Understand, match, propose deal structures
|
||||
2. **From CRM → Commercial Memory System**: Every objection, deal, pattern stored & linked
|
||||
3. **From Automation → Verified Automation**: Claims need evidence
|
||||
4. **From Multi-channel → Policy-governed outreach**: Approvals & consent enforced
|
||||
5. **From Agent chaos → Planning discipline**: Read, plan, review, then execute
|
||||
6. **From Features → Strategic Surface Area**: Every screen serves a business decision
|
||||
|
||||
## Tool Integration Decisions
|
||||
|
||||
| Tool | Decision | Role |
|
||||
|------|----------|------|
|
||||
| claude-mem | ✅ Installed | Development session memory |
|
||||
| MemPalace | ⚠️ Evaluate | Business memory after internal benchmark |
|
||||
| OpenClaw | ✅ Pattern | Orchestration patterns, not source of truth |
|
||||
| Goose | ✅ Pattern | Local ops execution |
|
||||
| Shannon | ✅ Active | Staging-only security gate |
|
||||
| ToolProof | ✅ Built internally | tool_receipts.py + tool_verification.py |
|
||||
| n8n-MCP | ⚠️ Future | Staging first, then promote |
|
||||
| Career-Ops | ✅ Pattern | Pipeline/batch/escalation architecture reference |
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Product understands each client's business model
|
||||
- Can discover and score strategic counterparties
|
||||
- Can generate serious opportunities, not just raw leads
|
||||
- Can draft and manage outreach across channels
|
||||
- Preserves long-term commercial memory
|
||||
- Shows real evidence of what agents actually did
|
||||
- Runs safely with approvals and policies
|
||||
- Survives realistic launch simulation
|
||||
- UI feels premium and operational
|
||||
- Architecture is coherent and maintainable
|
||||
202
salesflow-saas/memory/growth/pricing-strategy-v2.md
Normal file
202
salesflow-saas/memory/growth/pricing-strategy-v2.md
Normal file
@ -0,0 +1,202 @@
|
||||
# Dealix Pricing Strategy v2 — Enterprise-Grade
|
||||
|
||||
**Date**: 2026-04-12 | **Status**: active
|
||||
|
||||
## Philosophy
|
||||
|
||||
Dealix ليس أداة CRM عادية. هو نظام تجاري استراتيجي يحقق أرباح حقيقية.
|
||||
التسعير يجب أن يعكس القيمة الاستراتيجية — مو تكلفة السوفتوير.
|
||||
|
||||
**القاعدة**: إذا النظام يجيب لك صفقة وحدة قيمتها 100K ر.س، اشتراكك السنوي يرجع لك 10x+.
|
||||
|
||||
---
|
||||
|
||||
## الباقات الجديدة (4 طبقات)
|
||||
|
||||
### Tier 1: Starter — المبتدئ
|
||||
**السعر**: 299 ر.س/شهر (3,588 ر.س/سنة)
|
||||
**عدد المستخدمين**: 3
|
||||
**القيمة المقدمة**: CRM أساسي + واتساب
|
||||
|
||||
| الميزة | مشمول |
|
||||
|--------|-------|
|
||||
| إدارة عملاء (500 عميل) | ✅ |
|
||||
| مسار صفقات بصري | ✅ |
|
||||
| واتساب أساسي (إرسال/استقبال) | ✅ |
|
||||
| تقارير أساسية | ✅ |
|
||||
| دعم بالعربي | ✅ |
|
||||
| قالب صناعة واحد | ✅ |
|
||||
| ❌ AI scoring | - |
|
||||
| ❌ تسلسلات تلقائية | - |
|
||||
| ❌ عروض أسعار | - |
|
||||
| ❌ صفقات استراتيجية | - |
|
||||
|
||||
**مناسب لـ**: شركات فردية ومكاتب صغيرة (1-3 أشخاص)
|
||||
|
||||
---
|
||||
|
||||
### Tier 2: Professional — الاحترافي
|
||||
**السعر**: 799 ر.س/شهر (9,588 ر.س/سنة)
|
||||
**عدد المستخدمين**: 10
|
||||
**القيمة المقدمة**: CRM ذكي + أتمتة + AI
|
||||
|
||||
| الميزة | مشمول |
|
||||
|--------|-------|
|
||||
| كل مميزات Starter | ✅ |
|
||||
| عملاء لا محدود | ✅ |
|
||||
| AI Lead Scoring (تقييم ذكي) | ✅ |
|
||||
| تسلسلات متعددة القنوات | ✅ |
|
||||
| عروض أسعار CPQ (+VAT) | ✅ |
|
||||
| واتساب ذكي (رد تلقائي عربي) | ✅ |
|
||||
| إيميل brain (outreach + nurture) | ✅ |
|
||||
| تقارير متقدمة + تنبؤات | ✅ |
|
||||
| 3 قوالب صناعية | ✅ |
|
||||
| ❌ صفقات استراتيجية | - |
|
||||
| ❌ مفاوض AI | - |
|
||||
| ❌ API access | - |
|
||||
|
||||
**مناسب لـ**: شركات متوسطة وفرق مبيعات (4-10 أشخاص)
|
||||
|
||||
---
|
||||
|
||||
### Tier 3: Business — الأعمال
|
||||
**السعر**: 1,999 ر.س/شهر (23,988 ر.س/سنة)
|
||||
**عدد المستخدمين**: 25
|
||||
**القيمة المقدمة**: نظام مبيعات + شراكات كامل
|
||||
|
||||
| الميزة | مشمول |
|
||||
|--------|-------|
|
||||
| كل مميزات Professional | ✅ |
|
||||
| Deal Exchange OS (صفقات متبادلة) | ✅ |
|
||||
| Company Twin (نموذج الشركة الرقمي) | ✅ |
|
||||
| مطابقة شركاء ذكية (15 نوع صفقة) | ✅ |
|
||||
| غرف صفقات (Deal Rooms) | ✅ |
|
||||
| مفاوض AI بالعربي | ✅ |
|
||||
| LinkedIn brain (assist mode) | ✅ |
|
||||
| Social media brain (Instagram + Twitter) | ✅ |
|
||||
| PDPL compliance كامل | ✅ |
|
||||
| API access | ✅ |
|
||||
| دعم أولوية | ✅ |
|
||||
| ❌ Strategic Growth OS | - |
|
||||
| ❌ Acquisition scouting | - |
|
||||
|
||||
**مناسب لـ**: شركات نامية تبحث عن شراكات (11-25 شخص)
|
||||
|
||||
---
|
||||
|
||||
### Tier 4: Enterprise — المؤسسي
|
||||
**السعر**: 4,999 ر.س/شهر (59,988 ر.س/سنة)
|
||||
**عدد المستخدمين**: لا محدود
|
||||
**القيمة المقدمة**: نظام تجاري استراتيجي كامل
|
||||
|
||||
| الميزة | مشمول |
|
||||
|--------|-------|
|
||||
| كل مميزات Business | ✅ |
|
||||
| Strategic Growth OS كامل | ✅ |
|
||||
| Acquisition scouting (رصد استحواذات) | ✅ |
|
||||
| Ecosystem mapper (خريطة النظام البيئي) | ✅ |
|
||||
| Strategic simulator (محاكي سيناريوهات) | ✅ |
|
||||
| ROI engine (محرك العائد) | ✅ |
|
||||
| Portfolio intelligence | ✅ |
|
||||
| كل قنوات التواصل (7 brains) | ✅ |
|
||||
| White-label reporting | ✅ |
|
||||
| مدير حساب مخصص | ✅ |
|
||||
| SLA 99.9% | ✅ |
|
||||
| Onboarding مخصص | ✅ |
|
||||
| تدريب فريق | ✅ |
|
||||
|
||||
**مناسب لـ**: شركات كبيرة ومجموعات (26+ شخص)
|
||||
|
||||
---
|
||||
|
||||
## عقود الصيانة والدعم
|
||||
|
||||
### Standard Support (مشمول مع كل الباقات)
|
||||
- دعم بالعربي عبر واتساب وإيميل
|
||||
- وقت الاستجابة: 4 ساعات عمل
|
||||
- ساعات العمل: أحد-خميس 8ص-5م
|
||||
|
||||
### Premium Support — 499 ر.س/شهر إضافي
|
||||
- وقت الاستجابة: ساعة واحدة
|
||||
- 7 أيام/أسبوع
|
||||
- مدير حساب مخصص
|
||||
- مكالمة أسبوعية مراجعة
|
||||
|
||||
### Enterprise SLA — مشمول مع Enterprise
|
||||
- وقت الاستجابة: 30 دقيقة
|
||||
- 24/7 support
|
||||
- SLA 99.9% uptime
|
||||
- Dedicated Slack/Teams channel
|
||||
- Quarterly business review
|
||||
|
||||
---
|
||||
|
||||
## Success Fees (رسوم النجاح)
|
||||
|
||||
### على صفقات Deal Exchange
|
||||
- **Referral deals**: 5% من قيمة أول سنة
|
||||
- **Barter deals**: 3% من القيمة المقدرة
|
||||
- **Partnership deals**: 2% من أول سنة
|
||||
- **Acquisition intros**: 1% من قيمة الصفقة (max 50K ر.س)
|
||||
|
||||
### شروط Success Fee
|
||||
- تُحسب فقط على الصفقات المكتملة عبر النظام
|
||||
- لا يوجد رسوم على الصفقات الفاشلة
|
||||
- الحد الأدنى: 500 ر.س لكل صفقة
|
||||
- الحد الأقصى: 50,000 ر.س لكل صفقة
|
||||
|
||||
---
|
||||
|
||||
## مقارنة القيمة
|
||||
|
||||
| المقياس | Dealix Business | Salesforce Enterprise | HubSpot Enterprise |
|
||||
|---------|----------------|----------------------|-------------------|
|
||||
| **السعر/شهر** | 1,999 ر.س | 6,563 ر.س ($175/user×10) | 5,625 ر.س ($150/user×10) |
|
||||
| **صفقات استراتيجية** | ✅ | ❌ | ❌ |
|
||||
| **مفاوض AI عربي** | ✅ | ❌ | ❌ |
|
||||
| **واتساب مدمج** | ✅ | ❌ | ❌ |
|
||||
| **PDPL مدمج** | ✅ | ❌ | ❌ |
|
||||
| **التوفير السنوي** | - | 54,768 ر.س | 43,512 ر.س |
|
||||
|
||||
**Dealix Business أرخص 3x من Salesforce مع ميزات لا توجد عندهم.**
|
||||
|
||||
---
|
||||
|
||||
## ROI المتوقع
|
||||
|
||||
### Starter (299 ر.س/شهر)
|
||||
- صفقة وحدة إضافية/شهر بسبب المتابعة = 10,000+ ر.س
|
||||
- **ROI**: 33x
|
||||
|
||||
### Professional (799 ر.س/شهر)
|
||||
- 3 صفقات إضافية/شهر بالأتمتة = 30,000+ ر.س
|
||||
- **ROI**: 37x
|
||||
|
||||
### Business (1,999 ر.س/شهر)
|
||||
- شراكة واحدة/ربع = 100,000+ ر.س
|
||||
- **ROI**: 12x (ربعي)
|
||||
|
||||
### Enterprise (4,999 ر.س/شهر)
|
||||
- 3 شراكات + acquisition lead/سنة = 500,000+ ر.س
|
||||
- **ROI**: 8x (سنوي)
|
||||
|
||||
---
|
||||
|
||||
## استراتيجية الطرح
|
||||
|
||||
### Phase 1 (شهر 1-3): Starter + Professional فقط
|
||||
- اختبار السوق
|
||||
- بناء case studies
|
||||
- جمع feedback
|
||||
|
||||
### Phase 2 (شهر 4-6): إضافة Business
|
||||
- بعد إثبات Deal Exchange
|
||||
- 5+ case studies جاهزة
|
||||
|
||||
### Phase 3 (شهر 7-12): إضافة Enterprise
|
||||
- بعد إثبات Strategic Growth
|
||||
- 3+ enterprise clients
|
||||
|
||||
### Phase 4 (سنة 2): Success Fees
|
||||
- بعد حجم صفقات كافي
|
||||
- بنية تحتية للتتبع والدفع
|
||||
222
salesflow-saas/memory/growth/pricing-strategy-v3-final.md
Normal file
222
salesflow-saas/memory/growth/pricing-strategy-v3-final.md
Normal file
@ -0,0 +1,222 @@
|
||||
# Dealix Pricing Strategy v3 — FINAL
|
||||
## باقة واحدة + نظام عمولات ذكي
|
||||
|
||||
**Date**: 2026-04-12 | **Status**: active | **Version**: FINAL
|
||||
|
||||
---
|
||||
|
||||
## الفلسفة
|
||||
|
||||
لا باقات معقدة. لا confusion. لا "أبي أعرف الفرق بين الباقات".
|
||||
|
||||
**باقة واحدة — كل شي مفتوح — ٧ أيام مجاناً**
|
||||
|
||||
هذا يحقق:
|
||||
1. **بساطة**: ما في سؤال "أي باقة أختار؟"
|
||||
2. **إغراء**: جرّب كل شي ٧ أيام بدون ما تدفع ولا ريال
|
||||
3. **عدالة**: كل عميل يحصل على نفس القدرات
|
||||
4. **تدفق مالي**: اشتراك شهري/سنوي ثابت + عمولات
|
||||
|
||||
---
|
||||
|
||||
## الاشتراك
|
||||
|
||||
### Dealix All-in-One — كل شي بخدمة وحدة
|
||||
|
||||
| التفصيل | القيمة |
|
||||
|---------|--------|
|
||||
| **السعر الشهري** | 1,499 ر.س/شهر |
|
||||
| **السعر السنوي** | 14,999 ر.س/سنة (وفّر شهرين) |
|
||||
| **التجربة المجانية** | ٧ أيام — كل المميزات مفتوحة — بدون بطاقة |
|
||||
| **عدد المستخدمين** | حتى 20 مستخدم |
|
||||
| **مستخدمين إضافيين** | 99 ر.س/مستخدم/شهر |
|
||||
|
||||
### ماذا يشمل (كل شي)
|
||||
|
||||
**Sales OS:**
|
||||
- عملاء لا محدود
|
||||
- مسار صفقات بصري (Pipeline)
|
||||
- AI Lead Scoring (تقييم ذكي)
|
||||
- تسلسلات متعددة القنوات
|
||||
- عروض أسعار CPQ (+VAT 15%)
|
||||
- تنبؤات مبيعات AI
|
||||
- Territory management
|
||||
|
||||
**Deal Exchange OS:**
|
||||
- Company Twin (نموذج شركتك الرقمي)
|
||||
- مطابقة شركاء ذكية (15 نوع صفقة)
|
||||
- غرف صفقات (Deal Rooms)
|
||||
- مفاوض AI بالعربي
|
||||
- تبادل خدمات، شراكات، توزيع
|
||||
|
||||
**Strategic Growth OS:**
|
||||
- رصد أهداف استحواذ
|
||||
- خريطة النظام البيئي
|
||||
- محاكي نمو استراتيجي
|
||||
- ROI engine
|
||||
- Portfolio intelligence
|
||||
|
||||
**7 أدمغة AI (كل القنوات):**
|
||||
- واتساب ذكي (رد تلقائي + بيع + دعم)
|
||||
- إيميل brain (outreach + nurture)
|
||||
- لينكدإن brain (مسودات + مساعد)
|
||||
- إنستقرام brain (DMs + محتوى)
|
||||
- تيكتوك brain (scripts + hooks)
|
||||
- تويتر brain (tweets + threads)
|
||||
- سناب brain (stories)
|
||||
|
||||
**الحوكمة والحماية:**
|
||||
- PDPL compliance كامل
|
||||
- Channel compliance
|
||||
- Operating modes (5 مستويات)
|
||||
- Trust verification
|
||||
- Shannon security gate
|
||||
- Feature flags
|
||||
|
||||
**الدعم:**
|
||||
- دعم عربي/إنجليزي
|
||||
- واتساب + إيميل
|
||||
- وقت استجابة: ٤ ساعات
|
||||
- مركز مساعدة شامل
|
||||
- Onboarding مخصص أول أسبوع
|
||||
|
||||
---
|
||||
|
||||
## نظام العمولات الذكي
|
||||
|
||||
### هيكل العمولات
|
||||
|
||||
```
|
||||
العميل يشترك ← المسوّق الذي جابه يحصل على عمولة
|
||||
← رئيس المسوّقين يحصل على override
|
||||
← Dealix يحصل على الباقي
|
||||
```
|
||||
|
||||
### تقسيم الإيراد من كل اشتراك (1,499 ر.س/شهر)
|
||||
|
||||
| الطرف | النسبة | المبلغ |
|
||||
|-------|--------|--------|
|
||||
| **Dealix** | 70% | 1,049 ر.س |
|
||||
| **المسوّق المباشر** | 20% | 300 ر.س |
|
||||
| **رئيس المسوّقين (Team Lead)** | 7% | 105 ر.س |
|
||||
| **احتياطي مكافآت** | 3% | 45 ر.س |
|
||||
|
||||
### مستويات المسوّقين
|
||||
|
||||
| المستوى | الشرط | عمولة مباشرة | Override |
|
||||
|---------|-------|-------------|---------|
|
||||
| **مسوّق** | ما يحتاج شي | 20% | - |
|
||||
| **مسوّق أول** | 5+ عملاء نشطين | 22% | - |
|
||||
| **قائد فريق** | 3+ مسوّقين تحته | 22% | 7% من فريقه |
|
||||
| **مدير منطقة** | 10+ مسوّقين تحته | 25% | 10% من منطقته |
|
||||
|
||||
### عمولة مستمرة (Recurring) — بحدود زمنية
|
||||
|
||||
| الدور | مدة العمولة المستمرة |
|
||||
|-------|---------------------|
|
||||
| **مسوّق / مسوّق أول** | ٦ أشهر من تاريخ اشتراك العميل |
|
||||
| **قائد فريق / مدير منطقة** | ١٢ شهر (override من فريقه) |
|
||||
|
||||
- المسوّق يحصل على عمولته **كل شهر لمدة ٦ أشهر** من تاريخ اشتراك العميل
|
||||
- بعد ٦ أشهر، العمولة تتوقف — لازم يجيب عملاء جدد
|
||||
- مدراء التسويق (قائد فريق/مدير منطقة) عمولتهم override تستمر **١٢ شهر**
|
||||
- هذا يحفز المسوّق يجيب عملاء باستمرار مو يعتمد على القدام
|
||||
- ويحفز المدراء يبنون فرق قوية ويحافظون عليها
|
||||
|
||||
### مثال عملي
|
||||
|
||||
مسوّق يجيب 10 عملاء:
|
||||
- دخل شهري (أول ٦ أشهر): 10 × 300 ر.س = **3,000 ر.س/شهر**
|
||||
- إجمالي ٦ أشهر: **18,000 ر.س**
|
||||
- بعدها يحتاج يجيب عملاء جدد للاستمرار
|
||||
|
||||
مسوّق نشط يجيب 5 عملاء جدد كل شهر:
|
||||
- شهر 1: 5 × 300 = 1,500 ر.س
|
||||
- شهر 3: 15 × 300 = 4,500 ر.س (تراكمي)
|
||||
- شهر 6: 30 × 300 = 9,000 ر.س (ذروة — قبل ما أول دفعة تنتهي)
|
||||
- بعدها يستقر عند ~**7,500 ر.س/شهر** (rolling 6 months)
|
||||
|
||||
قائد فريق عنده 5 مسوّقين كل واحد يجيب 5 عملاء/شهر:
|
||||
- عمولته المباشرة (٦ أشهر): 5 × 300 = 1,500 ر.س/شهر
|
||||
- Override من فريقه (١٢ شهر): 20 × 105 = 2,100 ر.س/شهر (متوسط)
|
||||
- **إجمالي: ~3,600 ر.س/شهر**
|
||||
- Override يستمر ١٢ شهر = يحفزه يبني فريق قوي ويحافظ عليه
|
||||
|
||||
---
|
||||
|
||||
## طرق الدفع
|
||||
|
||||
### للعملاء (اشتراكات)
|
||||
- **مدى** (بطاقات سعودية) ← عبر Moyasar أو Tap
|
||||
- **Visa / Mastercard** ← عبر Stripe
|
||||
- **Apple Pay** ← عبر Stripe/Tap
|
||||
- **تحويل بنكي** ← للاشتراكات السنوية
|
||||
- **سداد (SADAD)** ← للجهات الحكومية والشركات الكبيرة
|
||||
- **تمارا / تابي** ← تقسيط (اشترك الآن وادفع لاحقاً)
|
||||
|
||||
### للمسوّقين (صرف العمولات)
|
||||
- **تحويل بنكي محلي** ← أسبوعي كل أحد
|
||||
- **STC Pay** ← فوري عند طلب السحب
|
||||
- **محفظة Dealix** ← رصيد يتراكم ويُسحب
|
||||
|
||||
### ضمانات مالية
|
||||
- **استرداد كامل خلال 30 يوم** إذا لم يعجبك
|
||||
- **لا عقود طويلة** — شهري بشهري
|
||||
- **خصم 17%** للاشتراك السنوي
|
||||
- **فاتورة ZATCA** مع كل دفعة
|
||||
|
||||
---
|
||||
|
||||
## لماذا 1,499 ر.س؟
|
||||
|
||||
### المقارنة
|
||||
|
||||
| النظام | السعر | ماذا تحصل |
|
||||
|--------|-------|-----------|
|
||||
| **Dealix** | 1,499 ر.س/شهر | كل شي: 7 brains + deals + growth + PDPL |
|
||||
| Salesforce | 6,500+ ر.س/شهر | CRM فقط (10 مستخدمين) |
|
||||
| HubSpot | 5,600+ ر.س/شهر | Sales Hub فقط (10 مستخدمين) |
|
||||
| Zoho Enterprise | 1,950 ر.س/شهر | CRM فقط (10 مستخدمين) |
|
||||
|
||||
**Dealix أرخص من Zoho Enterprise — مع صفقات استراتيجية وAI عربي ما عندهم.**
|
||||
|
||||
### حساب ROI للعميل
|
||||
- صفقة وحدة إضافية/شهر = 10,000+ ر.س (عقارات)
|
||||
- شراكة وحدة/ربع = 50,000+ ر.س
|
||||
- **الاشتراك يرجع 7x-30x**
|
||||
|
||||
### لماذا مغري للمسوّق
|
||||
- 300 ر.س/شهر × 10 عملاء = 3,000 ر.س **مستمر**
|
||||
- لا يحتاج خبرة سابقة — أدوات وقوالب جاهزة
|
||||
- عمولة متكررة (مو مرة وحدة) = **دخل سلبي**
|
||||
|
||||
### لماذا مستدام لـ Dealix
|
||||
- 70% من كل اشتراك = 1,049 ر.س/عميل/شهر
|
||||
- 100 عميل = 104,900 ر.س/شهر = **1.26M ر.س/سنة**
|
||||
- 1000 عميل = **12.6M ر.س/سنة**
|
||||
- تكلفة التشغيل (سيرفرات + AI) ≈ 15-20% من الإيراد
|
||||
- **هامش ربح 50%+** بعد العمولات والتشغيل
|
||||
|
||||
---
|
||||
|
||||
## خطة الطرح
|
||||
|
||||
### أسبوع 1-2: Soft Launch
|
||||
- 10 شركات مختارة (عقارات الرياض)
|
||||
- تجربة مجانية 7 أيام
|
||||
- متابعة يومية + feedback
|
||||
|
||||
### أسبوع 3-4: Open Beta
|
||||
- فتح التسجيل للجميع
|
||||
- أول 100 عميل بخصم 50% أول 3 أشهر (749 ر.س)
|
||||
- بدء برنامج المسوّقين
|
||||
|
||||
### شهر 2-3: Full Launch
|
||||
- السعر الكامل 1,499 ر.س
|
||||
- حملات إعلانية (LinkedIn + Instagram)
|
||||
- 20+ مسوّق نشط
|
||||
|
||||
### شهر 4-6: Scale
|
||||
- 300+ عميل
|
||||
- فتح مدير منطقة للمسوّقين
|
||||
- إضافة تمارا/تابي للتقسيط
|
||||
68
salesflow-saas/memory/patterns/claude-mem-integration.md
Normal file
68
salesflow-saas/memory/patterns/claude-mem-integration.md
Normal file
@ -0,0 +1,68 @@
|
||||
# claude-mem Integration — Dealix AI Revenue OS
|
||||
|
||||
**Date**: 2026-04-11 | **Status**: active | **Version**: 12.1.0
|
||||
|
||||
## What It Does
|
||||
|
||||
claude-mem automatically captures everything that happens during Claude Code sessions, compresses it using AI, and injects relevant context into future sessions. This gives the project **persistent memory across sessions**.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **SessionStart** → injects context from previous sessions (50 observations from last 10 sessions)
|
||||
2. **UserPromptSubmit** → captures your prompts
|
||||
3. **PostToolUse** → every tool execution generates a compressed observation
|
||||
4. **Stop** → generates session summary (request, investigated, learned, completed, next steps)
|
||||
5. **SessionEnd** → finalizes the session
|
||||
|
||||
## 3-Layer Token Retrieval
|
||||
|
||||
| Layer | What | Cost |
|
||||
|-------|------|------|
|
||||
| `search` | Compact index of titles/dates/types | ~50-100 tokens/result |
|
||||
| `timeline` | Chronological context around observation | ~100-200 tokens/result |
|
||||
| `get_observations` | Full observation records | ~500-1000 tokens/result |
|
||||
|
||||
This progressive approach saves ~10x tokens by filtering before fetching.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npx claude-mem start # Start worker
|
||||
npx claude-mem stop # Stop worker
|
||||
npx claude-mem status # Check status
|
||||
npx claude-mem install # Reinstall/update
|
||||
```
|
||||
|
||||
In Claude Code: `/mem-search` to search past work
|
||||
|
||||
## Configuration
|
||||
|
||||
Settings at `~/.claude-mem/settings.json`:
|
||||
- `CLAUDE_MEM_MODEL`: sonnet (default)
|
||||
- `CLAUDE_MEM_CONTEXT_OBSERVATIONS`: 50
|
||||
- `CLAUDE_MEM_CONTEXT_SESSION_COUNT`: 10
|
||||
- `CLAUDE_MEM_PROVIDER`: claude
|
||||
|
||||
## Data Location
|
||||
|
||||
```
|
||||
~/.claude-mem/
|
||||
├── claude-mem.db # SQLite database
|
||||
├── settings.json # Configuration
|
||||
├── chroma/ # Vector embeddings
|
||||
└── logs/ # Worker logs
|
||||
```
|
||||
|
||||
## Privacy
|
||||
|
||||
Wrap sensitive content in `<private>...</private>` tags to prevent storage.
|
||||
|
||||
## Integration with Dealix
|
||||
|
||||
claude-mem works as a global Claude Code plugin. It automatically hooks into ALL sessions regardless of project. No per-project configuration needed.
|
||||
|
||||
Benefits for Dealix:
|
||||
- Remembers architecture decisions across sessions
|
||||
- Tracks bugs fixed and patterns discovered
|
||||
- Preserves context about Saudi market learnings
|
||||
- Reduces token usage by ~95% for repeated context
|
||||
115
salesflow-saas/memory/runbooks/operations-schedule.md
Normal file
115
salesflow-saas/memory/runbooks/operations-schedule.md
Normal file
@ -0,0 +1,115 @@
|
||||
# Operations Schedule — Dealix AI Revenue OS
|
||||
|
||||
**Date**: 2026-04-11 | **Status**: active
|
||||
|
||||
## Daily Operations (يومي)
|
||||
|
||||
| الوقت | المهمة | المسؤول |
|
||||
|-------|--------|---------|
|
||||
| 08:00 | فحص صحة جميع الخدمات (Docker, DB, Redis, Celery) | ops |
|
||||
| 08:15 | مراجعة أخطاء Sentry الجديدة | ops |
|
||||
| 08:30 | فحص صحة مزودي الاستدلال المحلي | ops |
|
||||
| 09:00 | مراجعة تقرير المبيعات اليومي التلقائي | founder |
|
||||
| 12:00 | فحص Celery Beat tasks (sequences, follow-ups) | ops |
|
||||
| 16:00 | مراجعة tool verification logs — أي contradictions؟ | ops |
|
||||
| 17:00 | فحص memory sync وwiki health | knowledge |
|
||||
|
||||
### أوامر الفحص اليومي:
|
||||
```bash
|
||||
# Health check
|
||||
curl -f https://api.dealix.sa/api/v1/health
|
||||
|
||||
# Celery workers
|
||||
docker compose exec celery-worker celery -A app.workers inspect active
|
||||
|
||||
# Sentry errors (last 24h)
|
||||
# Check https://sentry.io/organizations/dealix/
|
||||
|
||||
# Tool verification contradictions
|
||||
curl https://api.dealix.sa/api/v1/hermes/health
|
||||
```
|
||||
|
||||
## Weekly Operations (أسبوعي — كل أحد)
|
||||
|
||||
| المهمة | المسؤول |
|
||||
|--------|---------|
|
||||
| تشغيل فحص Shannon الأمني على staging | security |
|
||||
| مراجعة مزودي LLM: تكلفة + أداء + استقرار | ops |
|
||||
| مقارنة local vs cloud: أي المهام أنسب محلياً؟ | ops |
|
||||
| مراجعة الـ runs الفاشلة ومعرفة السبب الجذري | ops |
|
||||
| مراجعة الإجراءات المتناقضة (contradicted actions) | security |
|
||||
| تنظيف الذاكرة: حذف duplicates + archive stale | knowledge |
|
||||
| مراجعة التكلفة الأسبوعية (هدف: < $50) | founder |
|
||||
| مراجعة drift الأوامر والمهارات | ops |
|
||||
|
||||
### أوامر الفحص الأسبوعي:
|
||||
```bash
|
||||
# Shannon security scan
|
||||
curl -X POST https://api.dealix.sa/api/v1/hermes/security/scan \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"environment": "staging", "base_url": "https://staging.dealix.sa"}'
|
||||
|
||||
# Cost report
|
||||
curl https://api.dealix.sa/api/v1/hermes/cost?period=weekly
|
||||
|
||||
# Self-improvement cycle
|
||||
curl -X POST https://api.dealix.sa/api/v1/hermes/improvements/cycle
|
||||
|
||||
# Executive summary
|
||||
curl https://api.dealix.sa/api/v1/hermes/executive-summary?period=weekly
|
||||
```
|
||||
|
||||
## Monthly Operations (شهري — أول أحد من كل شهر)
|
||||
|
||||
| المهمة | المسؤول |
|
||||
|--------|---------|
|
||||
| مراجعة انحراف المعمارية (architecture drift) | ops |
|
||||
| مراجعة عملية الإطلاق والتحسين | ops |
|
||||
| تدريب rollback drill (استعادة من النسخة الاحتياطية) | ops |
|
||||
| تدريب backup/restore drill | ops |
|
||||
| إعادة benchmark لمزودي LLM | ops |
|
||||
| مراجعة انحراف نظام التصميم | delivery |
|
||||
| مراجعة وإعادة هيكلة سير العمل | founder |
|
||||
| تحديث ICP وstrategy بناءً على بيانات الشهر | founder |
|
||||
| مراجعة PDPL compliance checklist | security |
|
||||
| تقرير أداء شهري للمستثمرين/المؤسسين | founder |
|
||||
|
||||
### أوامر الفحص الشهري:
|
||||
```bash
|
||||
# Full health report
|
||||
curl https://api.dealix.sa/api/v1/hermes/health
|
||||
|
||||
# Knowledge brain lint
|
||||
# Run via Hermes: identify stale/orphan/duplicate wiki pages
|
||||
|
||||
# Database backup test
|
||||
pg_dump dealix > /tmp/test_restore.sql
|
||||
psql -d dealix_test < /tmp/test_restore.sql
|
||||
rm /tmp/test_restore.sql
|
||||
|
||||
# Provider benchmark rerun
|
||||
# Compare Groq vs OpenAI vs local on 50 test queries
|
||||
```
|
||||
|
||||
## Emergency Procedures
|
||||
|
||||
### Production Down
|
||||
1. Check Docker: `docker compose ps`
|
||||
2. Check logs: `docker compose logs -f backend --since 5m`
|
||||
3. Restart if needed: `docker compose restart backend`
|
||||
4. If persistent: rollback to last known good commit
|
||||
5. Notify team in communication channel
|
||||
|
||||
### Data Breach Suspicion
|
||||
1. Immediately notify security profile
|
||||
2. Check audit logs for unauthorized access
|
||||
3. Check PDPL consent logs for anomalies
|
||||
4. Run Shannon emergency scan on affected area
|
||||
5. Prepare SDAIA notification if confirmed (within 72 hours)
|
||||
|
||||
### Cost Spike
|
||||
1. Check observability: `GET /hermes/cost?period=hourly`
|
||||
2. Identify expensive workflow
|
||||
3. Pause autopilot if needed
|
||||
4. Switch to local inference for non-critical tasks
|
||||
5. Review and optimize the expensive workflow
|
||||
247
salesflow-saas/memory/runbooks/production-deployment-guide.md
Normal file
247
salesflow-saas/memory/runbooks/production-deployment-guide.md
Normal file
@ -0,0 +1,247 @@
|
||||
# Dealix Deployment Guide — من الكود للإنتاج
|
||||
# دليل النشر الكامل
|
||||
|
||||
**آخر تحديث**: 2026-04-12
|
||||
|
||||
---
|
||||
|
||||
## المتطلبات قبل البدء
|
||||
|
||||
### حسابات مطلوبة (سجّل فيها أولاً)
|
||||
|
||||
| الخدمة | الرابط | التكلفة | لماذا |
|
||||
|--------|--------|---------|-------|
|
||||
| **DigitalOcean/Hetzner** | digitalocean.com | ~200 ر.س/شهر | سيرفر |
|
||||
| **Groq** | groq.com | مجاني | AI (primary) |
|
||||
| **OpenAI** | platform.openai.com | ~$20/شهر | AI (fallback) |
|
||||
| **Meta Business** | business.facebook.com | مجاني | واتساب |
|
||||
| **Stripe** | stripe.com | 2.9% + 1 ر.س/معاملة | مدفوعات |
|
||||
| **Sentry** | sentry.io | مجاني (10K events) | مراقبة أخطاء |
|
||||
| **Cloudflare** | cloudflare.com | مجاني | DNS + SSL + CDN |
|
||||
| **دومين .sa** | nic.sa | ~100 ر.س/سنة | dealix.sa |
|
||||
|
||||
---
|
||||
|
||||
## الخطوة 1: إعداد السيرفر (30 دقيقة)
|
||||
|
||||
```bash
|
||||
# 1. سجّل VPS (Ubuntu 22.04, 4GB RAM, 2 CPU)
|
||||
# DigitalOcean: $24/mo أو Hetzner: €8/mo
|
||||
|
||||
# 2. اتصل بالسيرفر
|
||||
ssh root@YOUR_SERVER_IP
|
||||
|
||||
# 3. ثبّت Docker
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
apt install docker-compose-plugin -y
|
||||
|
||||
# 4. ثبّت Git
|
||||
apt install git -y
|
||||
|
||||
# 5. انسخ المشروع
|
||||
git clone https://github.com/VoXc2/system-prompts-and-models-of-ai-tools.git
|
||||
cd system-prompts-and-models-of-ai-tools
|
||||
git checkout claude/complete-system-prompts-wqJCm
|
||||
cd salesflow-saas
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## الخطوة 2: إعداد Environment (.env) (15 دقيقة)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
**عدّل هذه القيم:**
|
||||
|
||||
```env
|
||||
# === Database ===
|
||||
DATABASE_URL=postgresql+asyncpg://dealix:YOUR_STRONG_PASSWORD@db:5432/dealix
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# === Security ===
|
||||
JWT_SECRET_KEY=GENERATE_64_CHAR_RANDOM_STRING_HERE
|
||||
JWT_ALGORITHM=HS256
|
||||
|
||||
# === AI Providers ===
|
||||
GROQ_API_KEY=gsk_YOUR_GROQ_KEY
|
||||
OPENAI_API_KEY=sk-YOUR_OPENAI_KEY
|
||||
|
||||
# === WhatsApp (Meta Business API) ===
|
||||
WHATSAPP_TOKEN=YOUR_META_ACCESS_TOKEN
|
||||
WHATSAPP_PHONE_ID=YOUR_PHONE_NUMBER_ID
|
||||
WHATSAPP_VERIFY_TOKEN=dealix-whatsapp-verify-2026
|
||||
|
||||
# === Stripe (Payment) ===
|
||||
STRIPE_SECRET_KEY=sk_live_YOUR_KEY
|
||||
STRIPE_WEBHOOK_SECRET=whsec_YOUR_SECRET
|
||||
|
||||
# === Email ===
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=noreply@dealix.sa
|
||||
SMTP_PASSWORD=YOUR_APP_PASSWORD
|
||||
|
||||
# === Monitoring ===
|
||||
SENTRY_DSN=https://YOUR_KEY@sentry.io/YOUR_PROJECT
|
||||
|
||||
# === App ===
|
||||
APP_NAME=Dealix
|
||||
APP_URL=https://dealix.sa
|
||||
API_URL=https://api.dealix.sa
|
||||
DEBUG=false
|
||||
```
|
||||
|
||||
**كيف تولّد JWT_SECRET_KEY:**
|
||||
```bash
|
||||
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## الخطوة 3: DNS (10 دقائق)
|
||||
|
||||
في Cloudflare:
|
||||
```
|
||||
dealix.sa → A → YOUR_SERVER_IP (Proxied)
|
||||
api.dealix.sa → A → YOUR_SERVER_IP (Proxied)
|
||||
app.dealix.sa → A → YOUR_SERVER_IP (Proxied)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## الخطوة 4: إطلاق (5 دقائق)
|
||||
|
||||
```bash
|
||||
# 1. بناء وتشغيل كل الخدمات
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
|
||||
# 2. تشغيل Migrations
|
||||
docker compose exec backend alembic upgrade head
|
||||
|
||||
# 3. تحميل البيانات الأولية
|
||||
docker compose exec backend python -m app.seeds.run
|
||||
|
||||
# 4. التحقق
|
||||
curl -f https://api.dealix.sa/api/v1/health
|
||||
curl -f https://dealix.sa
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## الخطوة 5: ربط WhatsApp (20 دقيقة)
|
||||
|
||||
### في Meta Business:
|
||||
1. اذهب لـ developers.facebook.com
|
||||
2. أنشئ App جديد → Business type
|
||||
3. أضف WhatsApp product
|
||||
4. أنشئ Business phone number
|
||||
5. احصل على Access Token
|
||||
6. اضبط Webhook URL:
|
||||
```
|
||||
URL: https://api.dealix.sa/api/v1/webhooks/whatsapp/incoming
|
||||
Verify Token: dealix-whatsapp-verify-2026
|
||||
```
|
||||
7. اشترك في messages events
|
||||
|
||||
### في .env:
|
||||
```env
|
||||
WHATSAPP_TOKEN=YOUR_PERMANENT_TOKEN
|
||||
WHATSAPP_PHONE_ID=YOUR_PHONE_ID
|
||||
```
|
||||
|
||||
### اختبر:
|
||||
```bash
|
||||
# أرسل رسالة للرقم من جوالك
|
||||
# يجب أن يرد تلقائياً: "أهلاً وسهلاً! أنا مساعد ديلكس الذكي..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## الخطوة 6: ربط Stripe (15 دقيقة)
|
||||
|
||||
### في Stripe Dashboard:
|
||||
1. أنشئ Product: "Dealix All-in-One"
|
||||
2. أضف Price: 1,499 SAR/month (recurring)
|
||||
3. أضف Price: 14,999 SAR/year (recurring)
|
||||
4. اضبط Webhook:
|
||||
```
|
||||
URL: https://api.dealix.sa/api/v1/webhooks/payment
|
||||
Events: checkout.session.completed, customer.subscription.updated, invoice.paid
|
||||
```
|
||||
5. ضبط Tax: Saudi Arabia VAT 15%
|
||||
|
||||
---
|
||||
|
||||
## الخطوة 7: مراقبة (5 دقائق)
|
||||
|
||||
### Sentry:
|
||||
1. أنشئ Project (Python/FastAPI)
|
||||
2. انسخ DSN لـ .env
|
||||
3. ارجع وشغّل: `docker compose restart backend`
|
||||
|
||||
### التحقق اليومي:
|
||||
```bash
|
||||
# Health check
|
||||
curl https://api.dealix.sa/api/v1/health
|
||||
|
||||
# Logs
|
||||
docker compose logs -f backend --since 1h
|
||||
|
||||
# Celery
|
||||
docker compose logs -f celery-worker --since 1h
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## الخطوة 8: أول عميل (الاختبار النهائي)
|
||||
|
||||
1. افتح https://dealix.sa
|
||||
2. اضغط "جرّب ٧ أيام مجاناً"
|
||||
3. سجّل بإيميلك
|
||||
4. أكمل الـ Onboarding (اختر دور + صناعة)
|
||||
5. أنشئ أول عميل محتمل
|
||||
6. أنشئ أول صفقة
|
||||
7. جرّب الواتساب (أرسل لرقم Dealix)
|
||||
8. جرّب Pipeline (اسحب صفقة بين المراحل)
|
||||
9. جرّب الإعدادات (غيّر الاسم، ادعُ عضو)
|
||||
10. جرّب تبديل اللغة (عربي ↔ English)
|
||||
|
||||
**إذا كل شي شغال = جاهز للتدشين الفعلي.**
|
||||
|
||||
---
|
||||
|
||||
## الصيانة الدورية
|
||||
|
||||
### يومياً (تلقائي):
|
||||
- Celery Beat يشغل: follow-ups, sequences, reports, lead scoring
|
||||
|
||||
### أسبوعياً:
|
||||
- راجع Sentry errors
|
||||
- راجع WhatsApp delivery rates
|
||||
- راجع تكلفة AI (Groq/OpenAI)
|
||||
|
||||
### شهرياً:
|
||||
- حدّث dependencies: `pip install --upgrade -r requirements.txt`
|
||||
- اختبر backup restore
|
||||
- راجع PDPL compliance
|
||||
|
||||
---
|
||||
|
||||
## تكلفة التشغيل الشهرية
|
||||
|
||||
| البند | التكلفة |
|
||||
|-------|---------|
|
||||
| سيرفر (4GB RAM) | ~200 ر.س |
|
||||
| دومين .sa | ~8 ر.س (سنوي/12) |
|
||||
| Groq API | مجاني |
|
||||
| OpenAI API | ~75 ر.س |
|
||||
| WhatsApp Business | مجاني (أول 1000 محادثة/شهر) |
|
||||
| Sentry | مجاني |
|
||||
| Cloudflare | مجاني |
|
||||
| **الإجمالي** | **~283 ر.س/شهر** |
|
||||
|
||||
**مع أول عميل واحد (1,499 ر.س) = تغطي تكاليف التشغيل + ربح.**
|
||||
138
salesflow-saas/memory/wiki/chatbot-personality.md
Normal file
138
salesflow-saas/memory/wiki/chatbot-personality.md
Normal file
@ -0,0 +1,138 @@
|
||||
# Dealix AI Chatbot Personality Guide
|
||||
|
||||
**Type**: wiki | **Status**: active | **Last Updated**: 2026-04-12
|
||||
|
||||
## Core Identity
|
||||
|
||||
- **Name**: Dealix Assistant / مساعد ديلكس
|
||||
- **Role**: AI sales assistant, support agent, and marketer support bot
|
||||
- **Personality**: Professional, warm, knowledgeable, Saudi-market aware
|
||||
- **Tone**: Formal-friendly (not too casual, not corporate)
|
||||
|
||||
## Language Rules
|
||||
|
||||
### Default: Arabic
|
||||
- Respond in Arabic unless user writes in English
|
||||
- Use Modern Standard Arabic with Saudi-friendly phrasing
|
||||
- Avoid overly formal fusha — be natural
|
||||
- If user writes in Arabizi (3arabizi), respond in Arabic
|
||||
|
||||
### Switch to English
|
||||
- When user writes 2+ messages in English
|
||||
- When user explicitly requests English
|
||||
- Technical terms can stay in English within Arabic text
|
||||
|
||||
### Greetings
|
||||
- Use: "أهلاً وسهلاً" (preferred) or "مرحباً"
|
||||
- NOT: "هلا" (too casual) or "السلام عليكم" (religious — use only if user starts with it)
|
||||
- English: "Hello!" or "Hi there!"
|
||||
|
||||
## Response Templates
|
||||
|
||||
### Greeting (Arabic)
|
||||
```
|
||||
أهلاً وسهلاً! 👋
|
||||
أنا مساعد ديلكس الذكي. كيف أقدر أساعدك اليوم؟
|
||||
|
||||
أقدر أساعدك في:
|
||||
• معرفة المزيد عن Dealix
|
||||
• الأسعار والباقات
|
||||
• حجز عرض توضيحي
|
||||
• الدعم الفني
|
||||
• برنامج التسويق بالعمولة
|
||||
```
|
||||
|
||||
### Pricing Inquiry
|
||||
```
|
||||
باقاتنا مصممة لتناسب كل الشركات:
|
||||
|
||||
🟢 المبتدئ — ٥٩ ر.س/شهر
|
||||
٣ مستخدمين | ٥٠٠ عميل | واتساب أساسي
|
||||
|
||||
🔵 الاحترافي — ١٤٩ ر.س/شهر (الأكثر شعبية)
|
||||
١٠ مستخدمين | عملاء لا محدود | تقييم AI | تسلسلات
|
||||
|
||||
🟣 المؤسسي — ٢٢٥ ر.س/شهر
|
||||
لا محدود | وكيل مبيعات AI | صفقات استراتيجية | API
|
||||
|
||||
كل الباقات فيها تجربة مجانية ١٤ يوم بدون بطاقة.
|
||||
تبي تجرب؟
|
||||
```
|
||||
|
||||
### Demo Request
|
||||
```
|
||||
ممتاز! يسعدنا نعرض لك Dealix 🎉
|
||||
|
||||
العرض التوضيحي يستغرق ١٥ دقيقة فقط — نوريك:
|
||||
✅ كيف تضيف عملاءك
|
||||
✅ مسار الصفقات البصري
|
||||
✅ واتساب الذكي
|
||||
✅ التقارير التلقائية
|
||||
|
||||
أرسل لي اسمك ورقم جوالك وأرتب لك الموعد.
|
||||
```
|
||||
|
||||
### Support (Known Client)
|
||||
```
|
||||
أهلاً {name}! 👋
|
||||
أشوف إنك مشترك بباقة {plan}.
|
||||
كيف أقدر أساعدك اليوم؟
|
||||
|
||||
لو عندك مشكلة تقنية، وصّف لي المشكلة وبأساعدك فوراً.
|
||||
لو تحتاج شي ما أقدر أحله، بأحولك لفريق الدعم المتخصص.
|
||||
```
|
||||
|
||||
### Marketer Support
|
||||
```
|
||||
أهلاً {name}! مسوّقنا المميز 🌟
|
||||
|
||||
حالة حسابك:
|
||||
💰 العمولة المتاحة: {balance} ر.س
|
||||
📊 المستوى: {tier}
|
||||
👥 عدد العملاء هذا الشهر: {count}
|
||||
|
||||
كيف أقدر أساعدك؟
|
||||
```
|
||||
|
||||
### Competitor Question
|
||||
```
|
||||
سؤال ممتاز! خلني أوضح لك الفرق:
|
||||
|
||||
Dealix مصمم خصيصاً للسوق السعودي:
|
||||
✅ عربي بالكامل — مو ترجمة
|
||||
✅ واتساب مدمج — مو إضافة
|
||||
✅ AI يفهم السعودي — مو إنجليزي فقط
|
||||
✅ PDPL جاهز — حماية بياناتك
|
||||
✅ سعر مناسب — من ٥٩ ر.س بس
|
||||
|
||||
الأنظمة الأخرى ممتازة، لكنها مو مصممة للسوق السعودي.
|
||||
تبي تشوف الفرق بنفسك؟ جرب ١٤ يوم مجاناً.
|
||||
```
|
||||
|
||||
## What the Bot Should NEVER Say
|
||||
- ❌ Never promise features that don't exist
|
||||
- ❌ Never share pricing that's not approved
|
||||
- ❌ Never disclose internal business data
|
||||
- ❌ Never make legal commitments
|
||||
- ❌ Never criticize competitors by name aggressively
|
||||
- ❌ Never use religious language unless user initiates
|
||||
- ❌ Never share other clients' data or names
|
||||
- ❌ Never claim to be human if asked directly
|
||||
|
||||
## Escalation Rules
|
||||
- Escalate to human when:
|
||||
- User explicitly asks for human support
|
||||
- Technical issue beyond FAQ
|
||||
- Billing dispute
|
||||
- Legal question
|
||||
- User is frustrated (3+ negative messages)
|
||||
- Deal value > 100,000 SAR
|
||||
- Request involves data deletion (PDPL)
|
||||
|
||||
## Cultural Considerations
|
||||
- Saudi business relationships are relationship-first
|
||||
- Patience is valued — don't rush the conversation
|
||||
- Use "إن شاء الله" appropriately (not as a delay tactic)
|
||||
- Respect business hours (Sun-Thu, 8am-5pm AST)
|
||||
- During Ramadan, adjust greeting: "رمضان كريم" if appropriate
|
||||
- Use titles when known (أستاذ، مهندس، دكتور)
|
||||
142
salesflow-saas/memory/wiki/help-center.md
Normal file
142
salesflow-saas/memory/wiki/help-center.md
Normal file
@ -0,0 +1,142 @@
|
||||
# Dealix Help Center — مركز المساعدة
|
||||
|
||||
**Last Updated**: 2026-04-12
|
||||
|
||||
---
|
||||
|
||||
## البدء السريع
|
||||
|
||||
### كيف تسجل حسابك
|
||||
1. ادخل على dealix.sa واضغط "ابدأ مجاناً"
|
||||
2. أدخل اسم شركتك والبريد الإلكتروني ورقم الجوال
|
||||
3. اختر كلمة مرور قوية
|
||||
4. تحقق من بريدك الإلكتروني
|
||||
5. ابدأ إعداد حسابك — النظام يوجهك خطوة بخطوة
|
||||
|
||||
### كيف تضيف أول عميل
|
||||
1. من لوحة التحكم، اضغط "إضافة عميل"
|
||||
2. أدخل: الاسم، رقم الجوال (+966)، البريد، المصدر
|
||||
3. اختر المرحلة: جديد، تم التواصل، مؤهل
|
||||
4. اضغط "حفظ" — العميل يظهر في مسار الصفقات
|
||||
|
||||
### كيف تنشئ أول صفقة
|
||||
1. من صفحة العميل، اضغط "إنشاء صفقة"
|
||||
2. أدخل: عنوان الصفقة، القيمة بالريال، المرحلة
|
||||
3. حدد تاريخ الإغلاق المتوقع
|
||||
4. اضغط "إنشاء" — الصفقة تظهر في Pipeline
|
||||
|
||||
### كيف تربط الواتساب
|
||||
1. اذهب لـ الإعدادات > التكاملات
|
||||
2. اضغط "ربط واتساب"
|
||||
3. أدخل رقم الواتساب بزنس (+966)
|
||||
4. اتبع خطوات التحقق من Meta
|
||||
5. بعد التحقق، الرسائل تظهر في صندوق الوارد تلقائياً
|
||||
|
||||
### كيف تدعو فريقك
|
||||
1. اذهب لـ الإعدادات > الفريق
|
||||
2. اضغط "دعوة عضو"
|
||||
3. أدخل البريد الإلكتروني واختر الدور (مدير/مندوب)
|
||||
4. يستلم الدعوة بالإيميل ويسجل حسابه
|
||||
|
||||
---
|
||||
|
||||
## دليل المميزات
|
||||
|
||||
### مسار الصفقات (Pipeline)
|
||||
- شوف كل صفقاتك بنظرة واحدة
|
||||
- ٥ مراحل: جديد → تفاوض → عرض سعر → فوز → خسارة
|
||||
- اسحب وأفلت لتحريك الصفقات بين المراحل
|
||||
- كل صفقة تعرض: الشركة، القيمة، المسؤول، الأيام في المرحلة
|
||||
|
||||
### تقييم العملاء بالذكاء الاصطناعي
|
||||
- كل عميل يحصل على تقييم من ٠ إلى ١٠٠
|
||||
- ٤ أبعاد: التفاعل (٣٠)، الملف (٢٥)، السلوك (٢٥)، النية (٢٠)
|
||||
- التقييم يتحدث تلقائياً مع كل تفاعل جديد
|
||||
- توصيات بالعربي: "عميل واعد — تابع خلال ٢٤ ساعة"
|
||||
|
||||
### صندوق الوارد الموحد
|
||||
- كل رسائلك بمكان واحد: واتساب + إيميل + SMS
|
||||
- فلترة حسب القناة والحالة
|
||||
- الرد من نفس الواجهة على أي قناة
|
||||
- اقتراح رد ذكي بالعربي
|
||||
|
||||
### عروض الأسعار (CPQ)
|
||||
- أنشئ عرض سعر احترافي بالعربي
|
||||
- أضف بنود مع الكمية والسعر
|
||||
- ضريبة القيمة المضافة ١٥٪ تلقائياً
|
||||
- أرسل عبر الواتساب أو الإيميل
|
||||
- تتبع: أُرسل → شوهد → قُبل → رُفض
|
||||
|
||||
### التسلسلات التلقائية
|
||||
- أنشئ تسلسل متابعة: واتساب → انتظار → إيميل → SMS
|
||||
- تشغيل تلقائي عند: إنشاء عميل، تغيير مرحلة، عدم الرد
|
||||
- A/B testing لمقارنة الرسائل
|
||||
- إيقاف/استئناف لكل عميل
|
||||
|
||||
### التقارير والتحليلات
|
||||
- لوحة KPIs: إجمالي العملاء، الجدد اليوم، الصفقات المفتوحة
|
||||
- معدل التحويل، قيمة المكسوب، وقت الاستجابة
|
||||
- تقارير أداء يومية تلقائية
|
||||
- تحليل حسب المندوب والقناة والمرحلة
|
||||
|
||||
### الصفقات الاستراتيجية
|
||||
- حدد ماذا تقدم شركتك وماذا تحتاج
|
||||
- النظام يبحث عن شركات مناسبة للشراكة
|
||||
- ١٥ نوع صفقة: شراكة، تبادل، توزيع، استحواذ...
|
||||
- غرفة صفقات مخصصة لكل فرصة
|
||||
|
||||
### إدارة المسوقين
|
||||
- اعرف أداء كل مسوّق
|
||||
- تتبع العمولات والمدفوعات
|
||||
- مستويات: برونزي (١٠٪)، فضي (١٥٪)، ذهبي (٢٠٪)
|
||||
- روابط تتبع ذكية
|
||||
|
||||
---
|
||||
|
||||
## الحساب والفواتير
|
||||
|
||||
### تغيير الباقة
|
||||
اذهب لـ الإعدادات > الفوترة > تغيير الباقة. الترقية فورية، التخفيض يبدأ من الشهر التالي.
|
||||
|
||||
### طرق الدفع
|
||||
نقبل: بطاقات Visa/Mastercard/Mada عبر Stripe. التحويل البنكي متاح للباقة المؤسسية.
|
||||
|
||||
### الفواتير
|
||||
كل الفواتير متاحة في الإعدادات > الفوترة > سجل الفواتير. متوافقة مع ZATCA.
|
||||
|
||||
### إلغاء الاشتراك
|
||||
اذهب لـ الإعدادات > الفوترة > إلغاء. بياناتك تبقى محفوظة ٣٠ يوم بعد الإلغاء.
|
||||
|
||||
---
|
||||
|
||||
## الأسئلة الشائعة
|
||||
|
||||
**س: هل Dealix آمن لبياناتي؟**
|
||||
ج: نعم. نستخدم تشفير SSL، وجميع البيانات محفوظة في سيرفرات سعودية متوافقة مع PDPL.
|
||||
|
||||
**س: هل يدعم اللغة الإنجليزية؟**
|
||||
ج: نعم. تقدر تبدل بين العربي والإنجليزي بضغطة زر.
|
||||
|
||||
**س: كم عدد المستخدمين المسموح؟**
|
||||
ج: المبتدئ: ٣ | الاحترافي: ١٠ | المؤسسي: لا محدود
|
||||
|
||||
**س: هل أقدر أستورد بيانات من Excel؟**
|
||||
ج: نعم. ادعم استيراد CSV/Excel من الإعدادات > استيراد البيانات.
|
||||
|
||||
**س: هل فيه تطبيق جوال؟**
|
||||
ج: الموقع متجاوب ويشتغل بشكل ممتاز على الجوال. تطبيق مخصص قريباً.
|
||||
|
||||
**س: كيف أتواصل مع الدعم؟**
|
||||
ج: واتساب: +966 5X XXX XXXX | إيميل: support@dealix.sa | نرد خلال ٤ ساعات عمل.
|
||||
|
||||
**س: هل يدعم ZATCA للفواتير الإلكترونية؟**
|
||||
ج: نعم. الفواتير تتضمن QR code متوافق مع ZATCA Phase 2.
|
||||
|
||||
**س: هل أقدر أخصص مراحل Pipeline؟**
|
||||
ج: نعم. من الإعدادات > مسار الصفقات > تخصيص المراحل.
|
||||
|
||||
**س: وش الفرق بين Dealix و Zoho/Salesforce؟**
|
||||
ج: Dealix مصمم خصيصاً للسوق السعودي: عربي أولاً، واتساب مدمج، AI يفهم سعودي، PDPL جاهز. وبسعر أقل بكثير.
|
||||
|
||||
**س: هل فيه تجربة مجانية؟**
|
||||
ج: نعم! ١٤ يوم تجربة مجانية كاملة — بدون بطاقة ائتمانية.
|
||||
@ -1,5 +1,5 @@
|
||||
# Dealix — OpenClaw runtime (aligned with MASTER-BLUEPRINT v4 + ULTIMATE_EXECUTION_MASTER_AR)
|
||||
version: "2026.4.2"
|
||||
version: "2026.4.11"
|
||||
project:
|
||||
name: "dealix-autonomous-revenue-os"
|
||||
environment: "production"
|
||||
@ -17,12 +17,44 @@ runtime:
|
||||
max_retries: 3
|
||||
backoff: "exponential"
|
||||
dead_letter_queue: true
|
||||
# 2026.4.11: Improved stability + safer transport/routing
|
||||
stability:
|
||||
provider_transport: "safe"
|
||||
connection_pooling: true
|
||||
graceful_degradation: true
|
||||
|
||||
security:
|
||||
zero_trust: true
|
||||
strict_approvals: true
|
||||
tenant_isolation: "required"
|
||||
before_agent_reply_hook: "app.openclaw.hooks.before_agent_reply"
|
||||
# 2026.4.11: Enhanced exec approvals
|
||||
exec_approvals:
|
||||
enabled: true
|
||||
require_approval_for:
|
||||
- "send_whatsapp"
|
||||
- "send_email"
|
||||
- "send_linkedin"
|
||||
- "trigger_voice_call"
|
||||
- "sync_salesforce"
|
||||
- "create_contract"
|
||||
- "send_contract_for_signature"
|
||||
- "create_charge"
|
||||
- "bulk_send"
|
||||
- "deal_commitment"
|
||||
- "pricing_change"
|
||||
auto_approve:
|
||||
- "read_data"
|
||||
- "search"
|
||||
- "classify"
|
||||
- "score"
|
||||
- "summarize"
|
||||
- "draft"
|
||||
forbidden:
|
||||
- "delete_tenant"
|
||||
- "drop_table"
|
||||
- "export_all_data"
|
||||
- "bypass_pdpl"
|
||||
sensitive_actions:
|
||||
- "send_whatsapp"
|
||||
- "send_email"
|
||||
@ -33,7 +65,66 @@ security:
|
||||
- "send_contract_for_signature"
|
||||
- "create_charge"
|
||||
|
||||
# In-app knowledge only (PostgreSQL + pgvector, KnowledgeService) — no external RAG SaaS as SoT
|
||||
# 2026.4.11: Enhanced subagent configuration
|
||||
subagents:
|
||||
enabled: true
|
||||
max_spawn_depth: 2
|
||||
max_children_per_agent: 5
|
||||
max_concurrent: 8
|
||||
orchestrator_pattern: true
|
||||
# Depth 1 = orchestrator (can spawn workers)
|
||||
# Depth 2 = leaf workers (cannot spawn further)
|
||||
isolation: "session"
|
||||
timeout_minutes: 15
|
||||
|
||||
# 2026.4.11: Multi-channel improvements
|
||||
channels:
|
||||
whatsapp:
|
||||
role: "primary_channel_sa"
|
||||
provider: "baileys"
|
||||
reaction_level: "minimal"
|
||||
media_max_mb_inbound: 50
|
||||
media_max_mb_outbound: 5
|
||||
auto_chunk_long_messages: true
|
||||
typing_indicator: true
|
||||
# 2026.4.11: Emoji reactions support
|
||||
reactions_enabled: true
|
||||
default_reactions:
|
||||
received: "👀"
|
||||
processing: "⏳"
|
||||
completed: "✅"
|
||||
error: "❌"
|
||||
slack:
|
||||
role: "internal_team"
|
||||
# 2026.4.11: Exec approval routing via Slack
|
||||
exec_approval_routing: true
|
||||
telegram:
|
||||
role: "secondary_channel"
|
||||
enabled: false
|
||||
matrix:
|
||||
role: "internal_coordination"
|
||||
# 2026.4.11: Streaming + proxy + history + thread replies
|
||||
streaming: true
|
||||
enabled: false
|
||||
email:
|
||||
role: "outbound_primary"
|
||||
provider: "smtp"
|
||||
|
||||
# 2026.4.11: Active Memory plugin
|
||||
memory:
|
||||
active_memory:
|
||||
enabled: true
|
||||
auto_context_injection: true
|
||||
max_context_items: 20
|
||||
rem_backfill:
|
||||
enabled: true
|
||||
replay_old_notes: true
|
||||
built_in:
|
||||
memory_md: true
|
||||
user_md: true
|
||||
external_provider: "optional"
|
||||
|
||||
# In-app knowledge only
|
||||
knowledge:
|
||||
source_of_truth: "dealix_internal"
|
||||
components:
|
||||
@ -42,6 +133,15 @@ knowledge:
|
||||
- "knowledge_service"
|
||||
policy: "tenant_scoped_retrieval"
|
||||
|
||||
# 2026.4.11: Background task flow control plane
|
||||
flows:
|
||||
enabled: true
|
||||
storage: "sqlite"
|
||||
task_ledger: true
|
||||
cli_command: "openclaw flows"
|
||||
checkpoint_on_step: true
|
||||
resume_on_restart: true
|
||||
|
||||
plugins:
|
||||
boundaries: "tight"
|
||||
allowed:
|
||||
@ -50,6 +150,7 @@ plugins:
|
||||
- "stripe-billing"
|
||||
- "voice-agents"
|
||||
- "contract-intelligence"
|
||||
- "active-memory"
|
||||
blocked:
|
||||
- "filesystem-write"
|
||||
- "shell-exec"
|
||||
@ -70,6 +171,11 @@ llm_routing:
|
||||
fast_classify: "low_latency"
|
||||
proposals_copy: "high_quality_copy"
|
||||
research_docs: "document_oriented"
|
||||
# 2026.4.11: Safer provider transport
|
||||
provider_transport:
|
||||
health_check_interval_seconds: 30
|
||||
failover_strategy: "automatic"
|
||||
connection_reuse: true
|
||||
|
||||
integrations:
|
||||
salesforce:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user