Add SalesMatic AI Sales SaaS Platform - Complete Foundation

Full-stack AI-powered sales automation platform for Saudi SMEs:

Backend (FastAPI + PostgreSQL):
- Multi-tenant architecture with row-level isolation
- JWT auth with RBAC (owner/manager/agent/admin)
- Lead, Customer, Deal, Pipeline, Activity, Message, Proposal models
- Dashboard analytics API (overview, pipeline, revenue)
- WhatsApp Business API, Email (SMTP/SendGrid), SMS (Unifonic) integrations
- Celery + Redis workers for automated follow-ups and scheduled messages
- Property model for Real Estate module (Riyadh districts)
- Hijri date utilities, Arabic/English localization

Frontend (Next.js + Tailwind):
- Professional Arabic RTL landing page with 10 sections
- Brand identity: SalesMatic (سيلزماتك) with custom SVG logo
- Color system: Trust Blue #0F4C81, Growth Teal #00BFA6, CTA Orange #FF6B35
- IBM Plex Sans Arabic + Inter typography
- Responsive design, dark hero section, pricing table, FAQ

Industry Templates:
- Healthcare/Clinics: pipeline stages, WhatsApp message templates, auto-workflows
- Real Estate Riyadh: 20 districts, property tours, payment plans, matching

Infrastructure:
- Docker Compose (PostgreSQL, Redis, Backend, Celery, Frontend, Nginx)
- Nginx reverse proxy config
- Makefile for common operations

https://claude.ai/code/session_01LLR7jzpyNRwDA9kojtT3CW
This commit is contained in:
Claude 2026-03-28 03:06:53 +00:00
parent ae37329aea
commit f1852c1121
No known key found for this signature in database
70 changed files with 2803 additions and 0 deletions

View File

@ -0,0 +1,42 @@
# Database
DB_NAME=salesflow
DB_USER=salesflow
DB_PASSWORD=change_me_in_production
DATABASE_URL=postgresql+asyncpg://salesflow:change_me_in_production@db:5432/salesflow
# Redis
REDIS_URL=redis://redis:6379/0
# Security
SECRET_KEY=change-this-to-a-random-secret-key-in-production
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=7
# API
API_URL=http://localhost:8000
FRONTEND_URL=http://localhost:3000
# WhatsApp Business API
WHATSAPP_API_TOKEN=
WHATSAPP_PHONE_NUMBER_ID=
WHATSAPP_BUSINESS_ACCOUNT_ID=
WHATSAPP_VERIFY_TOKEN=
# Email (SendGrid or SMTP)
EMAIL_PROVIDER=smtp
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SENDGRID_API_KEY=
# SMS (Unifonic - Saudi)
UNIFONIC_APP_SID=
UNIFONIC_SENDER_ID=SalesMatic
# App Settings
APP_NAME=SalesMatic
APP_NAME_AR=سيلزماتك
DEFAULT_TIMEZONE=Asia/Riyadh
DEFAULT_CURRENCY=SAR
DEFAULT_LOCALE=ar

47
salesflow-saas/.gitignore vendored Normal file
View File

@ -0,0 +1,47 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
.eggs/
*.egg
venv/
.venv/
# Node
node_modules/
.next/
out/
# Environment
.env
.env.local
.env.production
# IDE
.vscode/
.idea/
*.swp
*.swo
# Docker
postgres_data/
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# SSL
nginx/ssl/*.pem
nginx/ssl/*.key
nginx/ssl/*.crt
# Coverage
htmlcov/
.coverage
coverage/

49
salesflow-saas/Makefile Normal file
View File

@ -0,0 +1,49 @@
.PHONY: up down build logs migrate seed test
# Start all services
up:
docker compose up -d
# Stop all services
down:
docker compose down
# Build and start
build:
docker compose up -d --build
# View logs
logs:
docker compose logs -f
# Backend logs only
logs-backend:
docker compose logs -f backend celery_worker
# Run database migrations
migrate:
docker compose exec backend alembic upgrade head
# Create new migration
migration:
docker compose exec backend alembic revision --autogenerate -m "$(msg)"
# Seed initial data
seed:
docker compose exec backend python -m app.seeds.seed_data
# Run tests
test:
docker compose exec backend pytest -v
# Restart backend only
restart-backend:
docker compose restart backend celery_worker celery_beat
# Shell into backend
shell:
docker compose exec backend bash
# Check service health
health:
curl -s http://localhost:8000/api/v1/health | python3 -m json.tool

View File

@ -0,0 +1,16 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libpq-dev curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@ -0,0 +1,36 @@
[alembic]
script_location = alembic
sqlalchemy.url = postgresql+asyncpg://salesflow:salesflow_secret_2024@db:5432/salesflow
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

View File

@ -0,0 +1,50 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from uuid import UUID
from app.database import get_db
from app.utils.security import decode_token
from app.models.user import User
from app.models.tenant import Tenant
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> User:
payload = decode_token(credentials.credentials)
if not payload or payload.get("type") != "access":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")
user_id = payload.get("sub")
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
result = await db.execute(select(User).where(User.id == UUID(user_id), User.is_active == True))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
return user
async def get_current_tenant(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Tenant:
result = await db.execute(select(Tenant).where(Tenant.id == current_user.tenant_id, Tenant.is_active == True))
tenant = result.scalar_one_or_none()
if not tenant:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Tenant not found or inactive")
return tenant
def require_role(*roles: str):
async def role_checker(current_user: User = Depends(get_current_user)):
if current_user.role not in roles:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
return current_user
return role_checker

View File

@ -0,0 +1,118 @@
import re
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models.tenant import Tenant
from app.models.user import User
from app.models.subscription import Subscription
from app.schemas.auth import RegisterRequest, LoginRequest, TokenResponse, RefreshRequest
from app.utils.security import hash_password, verify_password, create_access_token, create_refresh_token, decode_token
router = APIRouter()
def _make_slug(name: str) -> str:
slug = re.sub(r"[^\w\s-]", "", name.lower().strip())
return re.sub(r"[\s_]+", "-", slug)
@router.post("/register", response_model=TokenResponse)
async def register(data: RegisterRequest, db: AsyncSession = Depends(get_db)):
existing = await db.execute(select(User).where(User.email == data.email))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
slug = _make_slug(data.company_name)
existing_tenant = await db.execute(select(Tenant).where(Tenant.slug == slug))
if existing_tenant.scalar_one_or_none():
slug = f"{slug}-{int(datetime.now(timezone.utc).timestamp()) % 10000}"
tenant = Tenant(
name=data.company_name,
name_ar=data.company_name_ar,
slug=slug,
industry=data.industry,
email=data.email,
phone=data.phone,
)
db.add(tenant)
await db.flush()
user = User(
tenant_id=tenant.id,
email=data.email,
password_hash=hash_password(data.password),
full_name=data.full_name,
phone=data.phone,
role="owner",
)
db.add(user)
subscription = Subscription(
tenant_id=tenant.id,
plan="basic",
status="trial",
price_monthly=0,
currency="SAR",
)
db.add(subscription)
await db.flush()
access_token = create_access_token({"sub": str(user.id), "tenant_id": str(tenant.id), "role": user.role})
refresh_token = create_refresh_token({"sub": str(user.id)})
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
user_id=str(user.id),
tenant_id=str(tenant.id),
role=user.role,
)
@router.post("/login", response_model=TokenResponse)
async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == data.email, User.is_active == True))
user = result.scalar_one_or_none()
if not user or not verify_password(data.password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
user.last_login = datetime.now(timezone.utc)
await db.flush()
access_token = create_access_token({"sub": str(user.id), "tenant_id": str(user.tenant_id), "role": user.role})
refresh_token = create_refresh_token({"sub": str(user.id)})
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
user_id=str(user.id),
tenant_id=str(user.tenant_id),
role=user.role,
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(data: RefreshRequest, db: AsyncSession = Depends(get_db)):
payload = decode_token(data.refresh_token)
if not payload or payload.get("type") != "refresh":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
result = await db.execute(select(User).where(User.id == payload["sub"], User.is_active == True))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
access_token = create_access_token({"sub": str(user.id), "tenant_id": str(user.tenant_id), "role": user.role})
new_refresh = create_refresh_token({"sub": str(user.id)})
return TokenResponse(
access_token=access_token,
refresh_token=new_refresh,
user_id=str(user.id),
tenant_id=str(user.tenant_id),
role=user.role,
)

View File

@ -0,0 +1,78 @@
from datetime import datetime, timezone, timedelta
from decimal import Decimal
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.database import get_db
from app.api.deps import get_current_user
from app.models.user import User
from app.models.lead import Lead
from app.models.deal import Deal
from app.models.message import Message
from app.schemas.dashboard import DashboardOverview, PipelineSummary
router = APIRouter()
@router.get("/overview", response_model=DashboardOverview)
async def dashboard_overview(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tid = current_user.tenant_id
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
total_leads = (await db.execute(select(func.count()).where(Lead.tenant_id == tid))).scalar() or 0
new_today = (await db.execute(select(func.count()).where(Lead.tenant_id == tid, Lead.created_at >= today_start))).scalar() or 0
total_deals = (await db.execute(select(func.count()).where(Deal.tenant_id == tid))).scalar() or 0
open_value = (await db.execute(
select(func.coalesce(func.sum(Deal.value), 0)).where(Deal.tenant_id == tid, Deal.stage.notin_(["closed_won", "closed_lost"]))
)).scalar() or Decimal("0")
won_value = (await db.execute(
select(func.coalesce(func.sum(Deal.value), 0)).where(Deal.tenant_id == tid, Deal.stage == "closed_won")
)).scalar() or Decimal("0")
won_count = (await db.execute(
select(func.count()).where(Deal.tenant_id == tid, Deal.stage == "closed_won")
)).scalar() or 0
msgs_today = (await db.execute(
select(func.count()).where(Message.tenant_id == tid, Message.created_at >= today_start, Message.direction == "outbound")
)).scalar() or 0
conversion = (won_count / total_leads * 100) if total_leads > 0 else 0
return DashboardOverview(
total_leads=total_leads,
new_leads_today=new_today,
total_deals=total_deals,
open_deals_value=open_value,
closed_won_value=won_value,
closed_won_count=won_count,
messages_sent_today=msgs_today,
conversion_rate=round(conversion, 2),
active_workflows=0,
)
@router.get("/pipeline", response_model=PipelineSummary)
async def pipeline_summary(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tid = current_user.tenant_id
result = await db.execute(
select(Deal.stage, func.count(), func.coalesce(func.sum(Deal.value), 0))
.where(Deal.tenant_id == tid)
.group_by(Deal.stage)
)
stages = {}
values = {}
for stage, count, value in result.all():
stages[stage] = count
values[stage] = float(value)
return PipelineSummary(stages=stages, total_value_by_stage=values)

View File

@ -0,0 +1,110 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from uuid import UUID
from decimal import Decimal
from app.database import get_db
from app.api.deps import get_current_user
from app.models.user import User
from app.models.deal import Deal
from app.schemas.deal import DealCreate, DealUpdate, DealResponse, StageUpdate, PipelineResponse
router = APIRouter()
PIPELINE_STAGES = ["new", "negotiation", "proposal", "closed_won", "closed_lost"]
@router.get("", response_model=list[DealResponse])
async def list_deals(
stage: str = Query(None),
assigned_to: UUID = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(Deal).where(Deal.tenant_id == current_user.tenant_id)
if stage:
query = query.where(Deal.stage == stage)
if assigned_to:
query = query.where(Deal.assigned_to == assigned_to)
query = query.order_by(Deal.created_at.desc())
result = await db.execute(query)
return [DealResponse.model_validate(d) for d in result.scalars().all()]
@router.get("/pipeline", response_model=PipelineResponse)
async def get_pipeline(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Deal).where(Deal.tenant_id == current_user.tenant_id))
deals = result.scalars().all()
stages = {s: [] for s in PIPELINE_STAGES}
total_value = Decimal("0")
for deal in deals:
stage_key = deal.stage if deal.stage in stages else "new"
stages[stage_key].append(DealResponse.model_validate(deal))
if deal.value:
total_value += deal.value
return PipelineResponse(stages=stages, total_value=total_value, total_deals=len(deals))
@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),
):
deal = Deal(tenant_id=current_user.tenant_id, **data.model_dump(exclude_none=True))
db.add(deal)
await db.flush()
await db.refresh(deal)
return DealResponse.model_validate(deal)
@router.put("/{deal_id}", response_model=DealResponse)
async def update_deal(
deal_id: UUID,
data: DealUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Deal).where(Deal.id == deal_id, Deal.tenant_id == current_user.tenant_id))
deal = result.scalar_one_or_none()
if not deal:
raise HTTPException(status_code=404, detail="Deal not found")
for field, value in data.model_dump(exclude_none=True).items():
setattr(deal, field, value)
await db.flush()
await db.refresh(deal)
return DealResponse.model_validate(deal)
@router.put("/{deal_id}/stage", response_model=DealResponse)
async def update_deal_stage(
deal_id: UUID,
data: StageUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if data.stage not in PIPELINE_STAGES:
raise HTTPException(status_code=400, detail=f"Invalid stage. Must be one of: {PIPELINE_STAGES}")
result = await db.execute(select(Deal).where(Deal.id == deal_id, Deal.tenant_id == current_user.tenant_id))
deal = result.scalar_one_or_none()
if not deal:
raise HTTPException(status_code=404, detail="Deal not found")
deal.stage = data.stage
if data.stage in ("closed_won", "closed_lost"):
deal.closed_at = datetime.now(timezone.utc)
deal.probability = 100 if data.stage == "closed_won" else 0
await db.flush()
await db.refresh(deal)
return DealResponse.model_validate(deal)

View File

@ -0,0 +1,107 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from uuid import UUID
from app.database import get_db
from app.api.deps import get_current_user
from app.models.user import User
from app.models.lead import Lead
from app.schemas.lead import LeadCreate, LeadUpdate, LeadResponse, LeadListResponse
router = APIRouter()
@router.get("", response_model=LeadListResponse)
async def list_leads(
status: str = Query(None),
source: str = Query(None),
assigned_to: UUID = Query(None),
search: str = Query(None),
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),
):
query = select(Lead).where(Lead.tenant_id == current_user.tenant_id)
if status:
query = query.where(Lead.status == status)
if source:
query = query.where(Lead.source == source)
if assigned_to:
query = query.where(Lead.assigned_to == assigned_to)
if search:
query = query.where(Lead.name.ilike(f"%{search}%") | Lead.phone.ilike(f"%{search}%") | Lead.email.ilike(f"%{search}%"))
count_query = select(func.count()).select_from(query.subquery())
total = (await db.execute(count_query)).scalar()
query = query.order_by(Lead.created_at.desc()).offset((page - 1) * per_page).limit(per_page)
result = await db.execute(query)
leads = result.scalars().all()
return LeadListResponse(items=[LeadResponse.model_validate(l) for l in leads], total=total, page=page, per_page=per_page)
@router.post("", response_model=LeadResponse, status_code=201)
async def create_lead(
data: LeadCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
lead = Lead(tenant_id=current_user.tenant_id, **data.model_dump(exclude_none=True))
db.add(lead)
await db.flush()
await db.refresh(lead)
return LeadResponse.model_validate(lead)
@router.get("/{lead_id}", response_model=LeadResponse)
async def get_lead(
lead_id: UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Lead).where(Lead.id == lead_id, Lead.tenant_id == current_user.tenant_id))
lead = result.scalar_one_or_none()
if not lead:
raise HTTPException(status_code=404, detail="Lead not found")
return LeadResponse.model_validate(lead)
@router.put("/{lead_id}", response_model=LeadResponse)
async def update_lead(
lead_id: UUID,
data: LeadUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Lead).where(Lead.id == lead_id, Lead.tenant_id == current_user.tenant_id))
lead = result.scalar_one_or_none()
if not lead:
raise HTTPException(status_code=404, detail="Lead not found")
for field, value in data.model_dump(exclude_none=True).items():
setattr(lead, field, value)
await db.flush()
await db.refresh(lead)
return LeadResponse.model_validate(lead)
@router.post("/{lead_id}/assign", response_model=LeadResponse)
async def assign_lead(
lead_id: UUID,
assigned_to: UUID = Query(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Lead).where(Lead.id == lead_id, Lead.tenant_id == current_user.tenant_id))
lead = result.scalar_one_or_none()
if not lead:
raise HTTPException(status_code=404, detail="Lead not found")
lead.assigned_to = assigned_to
await db.flush()
await db.refresh(lead)
return LeadResponse.model_validate(lead)

View File

@ -0,0 +1,11 @@
from fastapi import APIRouter
from app.api.v1 import auth, leads, deals, dashboard, tenants, users
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
api_router.include_router(tenants.router, prefix="/tenant", tags=["Tenant"])
api_router.include_router(users.router, prefix="/users", tags=["Users"])
api_router.include_router(leads.router, prefix="/leads", tags=["Leads"])
api_router.include_router(deals.router, prefix="/deals", tags=["Deals"])
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["Dashboard"])

View File

@ -0,0 +1,60 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from typing import Optional
from uuid import UUID
from datetime import datetime
from app.database import get_db
from app.api.deps import get_current_user, get_current_tenant, require_role
from app.models.user import User
from app.models.tenant import Tenant
router = APIRouter()
class TenantResponse(BaseModel):
id: UUID
name: str
name_ar: Optional[str]
slug: str
industry: Optional[str]
plan: str
logo_url: Optional[str]
phone: Optional[str]
email: Optional[str]
whatsapp_number: Optional[str]
settings: Optional[dict]
is_active: bool
created_at: datetime
model_config = {"from_attributes": True}
class TenantUpdate(BaseModel):
name: Optional[str] = None
name_ar: Optional[str] = None
phone: Optional[str] = None
email: Optional[str] = None
whatsapp_number: Optional[str] = None
industry: Optional[str] = None
settings: Optional[dict] = None
@router.get("", response_model=TenantResponse)
async def get_tenant(tenant: Tenant = Depends(get_current_tenant)):
return TenantResponse.model_validate(tenant)
@router.put("", response_model=TenantResponse)
async def update_tenant(
data: TenantUpdate,
tenant: Tenant = Depends(get_current_tenant),
current_user: User = Depends(require_role("owner", "admin")),
db: AsyncSession = Depends(get_db),
):
for field, value in data.model_dump(exclude_none=True).items():
setattr(tenant, field, value)
await db.flush()
await db.refresh(tenant)
return TenantResponse.model_validate(tenant)

View File

@ -0,0 +1,116 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from typing import Optional
from uuid import UUID
from datetime import datetime
from app.database import get_db
from app.api.deps import get_current_user, require_role
from app.models.user import User
from app.utils.security import hash_password
router = APIRouter()
class UserResponse(BaseModel):
id: UUID
tenant_id: UUID
email: str
full_name: Optional[str]
full_name_ar: Optional[str]
role: str
phone: Optional[str]
is_active: bool
last_login: Optional[datetime]
created_at: datetime
model_config = {"from_attributes": True}
class UserCreate(BaseModel):
email: str
password: str
full_name: str
full_name_ar: Optional[str] = None
role: str = "agent"
phone: Optional[str] = None
class UserUpdate(BaseModel):
full_name: Optional[str] = None
full_name_ar: Optional[str] = None
role: Optional[str] = None
phone: Optional[str] = None
is_active: Optional[bool] = None
@router.get("", response_model=list[UserResponse])
async def list_users(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.tenant_id == current_user.tenant_id).order_by(User.created_at))
return [UserResponse.model_validate(u) for u in result.scalars().all()]
@router.post("", response_model=UserResponse, status_code=201)
async def create_user(
data: UserCreate,
current_user: User = Depends(require_role("owner", "admin", "manager")),
db: AsyncSession = Depends(get_db),
):
existing = await db.execute(select(User).where(User.email == data.email, User.tenant_id == current_user.tenant_id))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already exists in this company")
user = User(
tenant_id=current_user.tenant_id,
email=data.email,
password_hash=hash_password(data.password),
full_name=data.full_name,
full_name_ar=data.full_name_ar,
role=data.role,
phone=data.phone,
)
db.add(user)
await db.flush()
await db.refresh(user)
return UserResponse.model_validate(user)
@router.put("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: UUID,
data: UserUpdate,
current_user: User = Depends(require_role("owner", "admin")),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id, User.tenant_id == current_user.tenant_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
for field, value in data.model_dump(exclude_none=True).items():
setattr(user, field, value)
await db.flush()
await db.refresh(user)
return UserResponse.model_validate(user)
@router.delete("/{user_id}", status_code=204)
async def delete_user(
user_id: UUID,
current_user: User = Depends(require_role("owner", "admin")),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id, User.tenant_id == current_user.tenant_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
user.is_active = False
await db.flush()

View File

@ -0,0 +1,55 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
# App
APP_NAME: str = "SalesMatic"
APP_NAME_AR: str = "سيلزماتك"
DEBUG: bool = False
DEFAULT_TIMEZONE: str = "Asia/Riyadh"
DEFAULT_CURRENCY: str = "SAR"
DEFAULT_LOCALE: str = "ar"
# Database
DATABASE_URL: str = "postgresql+asyncpg://salesflow:salesflow_secret_2024@db:5432/salesflow"
# Redis
REDIS_URL: str = "redis://redis:6379/0"
# Security
SECRET_KEY: str = "change-this-to-a-random-secret-key"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# URLs
API_URL: str = "http://localhost:8000"
FRONTEND_URL: str = "http://localhost:3000"
# WhatsApp
WHATSAPP_API_TOKEN: str = ""
WHATSAPP_PHONE_NUMBER_ID: str = ""
WHATSAPP_BUSINESS_ACCOUNT_ID: str = ""
WHATSAPP_VERIFY_TOKEN: str = ""
# Email
EMAIL_PROVIDER: str = "smtp"
SMTP_HOST: str = "smtp.gmail.com"
SMTP_PORT: int = 587
SMTP_USER: str = ""
SMTP_PASSWORD: str = ""
SENDGRID_API_KEY: str = ""
# SMS (Unifonic)
UNIFONIC_APP_SID: str = ""
UNIFONIC_SENDER_ID: str = "SalesMatic"
class Config:
env_file = ".env"
case_sensitive = True
@lru_cache()
def get_settings() -> Settings:
return Settings()

View File

@ -0,0 +1,31 @@
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
settings = get_settings()
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
pool_size=20,
max_overflow=10,
pool_pre_ping=True,
)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
async with async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()

View File

@ -0,0 +1,29 @@
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from app.config import get_settings
settings = get_settings()
async def send_email(to_email: str, subject: str, body_html: str, from_name: str = None) -> dict:
"""Send email via SMTP."""
if not settings.SMTP_USER or not settings.SMTP_PASSWORD:
return {"status": "error", "detail": "Email not configured"}
sender_name = from_name or settings.APP_NAME
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = f"{sender_name} <{settings.SMTP_USER}>"
msg["To"] = to_email
msg.attach(MIMEText(body_html, "html", "utf-8"))
try:
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server:
server.starttls()
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
server.sendmail(settings.SMTP_USER, to_email, msg.as_string())
return {"status": "sent"}
except Exception as e:
return {"status": "error", "detail": str(e)}

View File

@ -0,0 +1,26 @@
import httpx
from app.config import get_settings
settings = get_settings()
UNIFONIC_API_URL = "https://el.cloud.unifonic.com/rest/SMS/messages"
async def send_sms(phone: str, message: str) -> dict:
"""Send SMS via Unifonic (Saudi market)."""
if not settings.UNIFONIC_APP_SID:
return {"status": "error", "detail": "Unifonic SMS not configured"}
payload = {
"AppSid": settings.UNIFONIC_APP_SID,
"SenderID": settings.UNIFONIC_SENDER_ID,
"Recipient": phone,
"Body": message,
}
async with httpx.AsyncClient() as client:
response = await client.post(UNIFONIC_API_URL, data=payload)
result = response.json()
if result.get("success"):
return {"status": "sent", "message_id": result.get("data", {}).get("MessageID")}
return {"status": "error", "detail": result.get("message", "Unknown error")}

View File

@ -0,0 +1,55 @@
import httpx
from app.config import get_settings
settings = get_settings()
WHATSAPP_API_URL = "https://graph.facebook.com/v21.0"
async def send_whatsapp_message(phone: str, message: str) -> dict:
"""Send a text message via WhatsApp Business API."""
if not settings.WHATSAPP_API_TOKEN or not settings.WHATSAPP_PHONE_NUMBER_ID:
return {"status": "error", "detail": "WhatsApp not configured"}
url = f"{WHATSAPP_API_URL}/{settings.WHATSAPP_PHONE_NUMBER_ID}/messages"
headers = {
"Authorization": f"Bearer {settings.WHATSAPP_API_TOKEN}",
"Content-Type": "application/json",
}
payload = {
"messaging_product": "whatsapp",
"to": phone,
"type": "text",
"text": {"body": message},
}
async with httpx.AsyncClient() as client:
response = await client.post(url, json=payload, headers=headers)
return response.json()
async def send_whatsapp_template(phone: str, template_name: str, language: str = "ar", components: list = None) -> dict:
"""Send a template message via WhatsApp Business API."""
if not settings.WHATSAPP_API_TOKEN:
return {"status": "error", "detail": "WhatsApp not configured"}
url = f"{WHATSAPP_API_URL}/{settings.WHATSAPP_PHONE_NUMBER_ID}/messages"
headers = {
"Authorization": f"Bearer {settings.WHATSAPP_API_TOKEN}",
"Content-Type": "application/json",
}
payload = {
"messaging_product": "whatsapp",
"to": phone,
"type": "template",
"template": {
"name": template_name,
"language": {"code": language},
},
}
if components:
payload["template"]["components"] = components
async with httpx.AsyncClient() as client:
response = await client.post(url, json=payload, headers=headers)
return response.json()

View File

@ -0,0 +1,34 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import get_settings
from app.api.v1.router import api_router
settings = get_settings()
app = FastAPI(
title=f"{settings.APP_NAME} API",
description="AI Sales SaaS Platform for SMEs - Multi-tenant, Multi-industry Sales Automation",
version="1.0.0",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
)
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.FRONTEND_URL, "http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix="/api/v1")
@app.get("/api/v1/health")
async def health_check():
return {
"status": "healthy",
"app": settings.APP_NAME,
"version": "1.0.0",
}

View File

@ -0,0 +1,20 @@
from app.models.base import BaseModel, TenantModel
from app.models.tenant import Tenant
from app.models.user import User
from app.models.lead import Lead
from app.models.customer import Customer
from app.models.deal import Deal
from app.models.activity import Activity
from app.models.message import Message
from app.models.proposal import Proposal
from app.models.notification import Notification
from app.models.subscription import Subscription
from app.models.template import IndustryTemplate
from app.models.property import Property
from app.models.audit_log import AuditLog
__all__ = [
"BaseModel", "TenantModel", "Tenant", "User", "Lead", "Customer",
"Deal", "Activity", "Message", "Proposal", "Notification",
"Subscription", "IndustryTemplate", "Property", "AuditLog",
]

View File

@ -0,0 +1,22 @@
from sqlalchemy import Column, String, Text, DateTime, Boolean, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.models.base import TenantModel
class Activity(TenantModel):
__tablename__ = "activities"
lead_id = Column(UUID(as_uuid=True), ForeignKey("leads.id"), nullable=True)
deal_id = Column(UUID(as_uuid=True), ForeignKey("deals.id"), nullable=True)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
type = Column(String(50), nullable=False) # call, email, whatsapp, meeting, note, follow_up
subject = Column(String(255))
description = Column(Text)
scheduled_at = Column(DateTime(timezone=True))
completed_at = Column(DateTime(timezone=True))
is_automated = Column(Boolean, default=False)
lead = relationship("Lead", back_populates="activities")
deal = relationship("Deal", back_populates="activities")
user = relationship("User", back_populates="activities")

View File

@ -0,0 +1,14 @@
from sqlalchemy import Column, String, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, JSONB, INET
from app.models.base import TenantModel
class AuditLog(TenantModel):
__tablename__ = "audit_logs"
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
action = Column(String(100))
entity_type = Column(String(100))
entity_id = Column(UUID(as_uuid=True))
changes = Column(JSONB)
ip_address = Column(INET)

View File

@ -0,0 +1,18 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import Column, DateTime, Boolean
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class BaseModel(Base):
__abstract__ = True
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
class TenantModel(BaseModel):
__abstract__ = True
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)

View File

@ -0,0 +1,20 @@
from sqlalchemy import Column, String, ForeignKey, Numeric
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from app.models.base import TenantModel
class Customer(TenantModel):
__tablename__ = "customers"
lead_id = Column(UUID(as_uuid=True), ForeignKey("leads.id"), nullable=True)
name = Column(String(255), nullable=False)
phone = Column(String(20))
email = Column(String(255))
company_name = Column(String(255))
metadata = Column(JSONB, default=dict)
lifetime_value = Column(Numeric(12, 2), default=0)
tenant = relationship("Tenant", back_populates="customers")
lead = relationship("Lead")
messages = relationship("Message", back_populates="customer")

View File

@ -0,0 +1,29 @@
from sqlalchemy import Column, String, Integer, Text, DateTime, Date, ForeignKey, Numeric
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from app.models.base import TenantModel
class Deal(TenantModel):
__tablename__ = "deals"
lead_id = Column(UUID(as_uuid=True), ForeignKey("leads.id"), nullable=True)
customer_id = Column(UUID(as_uuid=True), ForeignKey("customers.id"), nullable=True)
assigned_to = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
title = Column(String(255), nullable=False)
value = Column(Numeric(12, 2))
currency = Column(String(3), default="SAR")
stage = Column(String(50), default="new") # new, negotiation, proposal, closed_won, closed_lost
probability = Column(Integer, default=0)
expected_close_date = Column(Date)
closed_at = Column(DateTime(timezone=True))
notes = Column(Text)
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
tenant = relationship("Tenant", back_populates="deals")
lead = relationship("Lead", back_populates="deals")
customer = relationship("Customer")
assigned_user = relationship("User", foreign_keys=[assigned_to])
activities = relationship("Activity", back_populates="deal")
proposals = relationship("Proposal", back_populates="deal")

View File

@ -0,0 +1,26 @@
from sqlalchemy import Column, String, Integer, Text, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from app.models.base import TenantModel
class Lead(TenantModel):
__tablename__ = "leads"
assigned_to = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
name = Column(String(255), nullable=False)
phone = Column(String(20))
email = Column(String(255))
source = Column(String(100)) # whatsapp, website, referral, social, phone
status = Column(String(50), default="new") # new, contacted, qualified, proposal, won, lost
score = Column(Integer, default=0)
notes = Column(Text)
metadata = Column(JSONB, default=dict) # industry-specific flexible data
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
tenant = relationship("Tenant", back_populates="leads")
assigned_user = relationship("User", foreign_keys=[assigned_to])
activities = relationship("Activity", back_populates="lead")
messages = relationship("Message", back_populates="lead")
deals = relationship("Deal", back_populates="lead")

View File

@ -0,0 +1,20 @@
from sqlalchemy import Column, String, Text, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from app.models.base import TenantModel
class Message(TenantModel):
__tablename__ = "messages"
lead_id = Column(UUID(as_uuid=True), ForeignKey("leads.id"), nullable=True)
customer_id = Column(UUID(as_uuid=True), ForeignKey("customers.id"), nullable=True)
channel = Column(String(50), nullable=False) # whatsapp, email, sms
direction = Column(String(10), nullable=False) # inbound, outbound
content = Column(Text)
status = Column(String(50), default="pending") # pending, sent, delivered, read, failed
sent_at = Column(DateTime(timezone=True))
metadata = Column(JSONB, default=dict)
lead = relationship("Lead", back_populates="messages")
customer = relationship("Customer", back_populates="messages")

View File

@ -0,0 +1,14 @@
from sqlalchemy import Column, String, Text, Boolean, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.models.base import TenantModel
class Notification(TenantModel):
__tablename__ = "notifications"
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
type = Column(String(50))
title = Column(String(255))
body = Column(Text)
is_read = Column(Boolean, default=False)
metadata = Column(JSONB, default=dict)

View File

@ -0,0 +1,32 @@
from sqlalchemy import Column, String, Integer, Text, DateTime, Numeric, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from app.models.base import TenantModel
class Property(TenantModel):
__tablename__ = "properties"
title = Column(String(255), nullable=False)
title_ar = Column(String(255))
property_type = Column(String(50)) # apartment, villa, land, office, commercial
status = Column(String(50), default="available") # available, reserved, sold, rented
price = Column(Numeric(14, 2))
currency = Column(String(3), default="SAR")
area_sqm = Column(Numeric(10, 2))
bedrooms = Column(Integer)
bathrooms = Column(Integer)
district = Column(String(100)) # حي النرجس، حي الياسمين، etc.
city = Column(String(100), default="الرياض")
address = Column(Text)
latitude = Column(Numeric(10, 8))
longitude = Column(Numeric(11, 8))
images = Column(JSONB, default=list)
features = Column(JSONB, default=list) # مسبح، حديقة، مصعد، etc.
description = Column(Text)
description_ar = Column(Text)
assigned_to = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
assigned_user = relationship("User", foreign_keys=[assigned_to])

View File

@ -0,0 +1,22 @@
from sqlalchemy import Column, String, DateTime, Date, ForeignKey, Numeric
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from app.models.base import TenantModel
class Proposal(TenantModel):
__tablename__ = "proposals"
deal_id = Column(UUID(as_uuid=True), ForeignKey("deals.id"), nullable=True)
lead_id = Column(UUID(as_uuid=True), ForeignKey("leads.id"), nullable=True)
title = Column(String(255))
content = Column(JSONB, nullable=False)
total_amount = Column(Numeric(12, 2))
currency = Column(String(3), default="SAR")
status = Column(String(50), default="draft") # draft, sent, viewed, accepted, rejected
valid_until = Column(Date)
sent_at = Column(DateTime(timezone=True))
viewed_at = Column(DateTime(timezone=True))
deal = relationship("Deal", back_populates="proposals")
lead = relationship("Lead")

View File

@ -0,0 +1,14 @@
from sqlalchemy import Column, String, Date, ForeignKey, Numeric
from sqlalchemy.dialects.postgresql import UUID
from app.models.base import TenantModel
class Subscription(TenantModel):
__tablename__ = "subscriptions"
plan = Column(String(50), nullable=False) # basic, professional, enterprise
status = Column(String(50), default="active") # active, past_due, cancelled, trial
price_monthly = Column(Numeric(10, 2))
currency = Column(String(3), default="SAR")
current_period_start = Column(Date)
current_period_end = Column(Date)

View File

@ -0,0 +1,16 @@
from sqlalchemy import Column, String, Boolean
from sqlalchemy.dialects.postgresql import JSONB
from app.models.base import BaseModel
class IndustryTemplate(BaseModel):
__tablename__ = "industry_templates"
industry = Column(String(100), nullable=False, index=True)
name = Column(String(255))
name_ar = Column(String(255))
pipeline_stages = Column(JSONB)
message_templates = Column(JSONB)
proposal_templates = Column(JSONB)
workflow_templates = Column(JSONB)
is_active = Column(Boolean, default=True)

View File

@ -0,0 +1,27 @@
from sqlalchemy import Column, String, Boolean, DateTime
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from app.models.base import BaseModel
class Tenant(BaseModel):
__tablename__ = "tenants"
name = Column(String(255), nullable=False)
name_ar = Column(String(255))
slug = Column(String(100), unique=True, nullable=False, index=True)
industry = Column(String(100))
plan = Column(String(50), default="basic")
logo_url = Column(String(500))
phone = Column(String(20))
email = Column(String(255))
whatsapp_number = Column(String(20))
settings = Column(JSONB, default=dict)
is_active = Column(Boolean, default=True)
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
users = relationship("User", back_populates="tenant", cascade="all, delete-orphan")
leads = relationship("Lead", back_populates="tenant", cascade="all, delete-orphan")
customers = relationship("Customer", back_populates="tenant", cascade="all, delete-orphan")
deals = relationship("Deal", back_populates="tenant", cascade="all, delete-orphan")

View File

@ -0,0 +1,21 @@
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.models.base import TenantModel
class User(TenantModel):
__tablename__ = "users"
__table_args__ = (UniqueConstraint("tenant_id", "email", name="uq_user_tenant_email"),)
email = Column(String(255), nullable=False)
password_hash = Column(String(255), nullable=False)
full_name = Column(String(255))
full_name_ar = Column(String(255))
role = Column(String(50), nullable=False, default="agent") # owner, manager, agent, admin
phone = Column(String(20))
is_active = Column(Boolean, default=True)
last_login = Column(DateTime(timezone=True))
tenant = relationship("Tenant", back_populates="users")
activities = relationship("Activity", back_populates="user")

View File

@ -0,0 +1,30 @@
from pydantic import BaseModel, EmailStr
from typing import Optional
class RegisterRequest(BaseModel):
company_name: str
company_name_ar: Optional[str] = None
industry: Optional[str] = None
full_name: str
email: str
password: str
phone: Optional[str] = None
class LoginRequest(BaseModel):
email: str
password: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
user_id: str
tenant_id: str
role: str
class RefreshRequest(BaseModel):
refresh_token: str

View File

@ -0,0 +1,32 @@
from pydantic import BaseModel
from decimal import Decimal
class DashboardOverview(BaseModel):
total_leads: int
new_leads_today: int
total_deals: int
open_deals_value: Decimal
closed_won_value: Decimal
closed_won_count: int
messages_sent_today: int
conversion_rate: float
active_workflows: int
class PipelineSummary(BaseModel):
stages: dict[str, int]
total_value_by_stage: dict[str, float]
class RevenueAnalytics(BaseModel):
total_revenue: Decimal
monthly_revenue: list[dict]
top_sources: list[dict]
average_deal_value: Decimal
class PerformanceMetrics(BaseModel):
agents: list[dict]
top_performer: dict
team_conversion_rate: float

View File

@ -0,0 +1,58 @@
from pydantic import BaseModel
from typing import Optional
from uuid import UUID
from datetime import datetime, date
from decimal import Decimal
class DealCreate(BaseModel):
title: str
lead_id: Optional[UUID] = None
customer_id: Optional[UUID] = None
assigned_to: Optional[UUID] = None
value: Optional[Decimal] = None
currency: str = "SAR"
stage: str = "new"
probability: int = 0
expected_close_date: Optional[date] = None
notes: Optional[str] = None
class DealUpdate(BaseModel):
title: Optional[str] = None
value: Optional[Decimal] = None
stage: Optional[str] = None
probability: Optional[int] = None
expected_close_date: Optional[date] = None
assigned_to: Optional[UUID] = None
notes: Optional[str] = None
class DealResponse(BaseModel):
id: UUID
tenant_id: UUID
title: str
lead_id: Optional[UUID]
customer_id: Optional[UUID]
assigned_to: Optional[UUID]
value: Optional[Decimal]
currency: str
stage: str
probability: int
expected_close_date: Optional[date]
closed_at: Optional[datetime]
notes: Optional[str]
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class StageUpdate(BaseModel):
stage: str
class PipelineResponse(BaseModel):
stages: dict[str, list[DealResponse]]
total_value: Decimal
total_deals: int

View File

@ -0,0 +1,50 @@
from pydantic import BaseModel
from typing import Optional
from uuid import UUID
from datetime import datetime
class LeadCreate(BaseModel):
name: str
phone: Optional[str] = None
email: Optional[str] = None
source: Optional[str] = None
notes: Optional[str] = None
metadata: Optional[dict] = None
class LeadUpdate(BaseModel):
name: Optional[str] = None
phone: Optional[str] = None
email: Optional[str] = None
source: Optional[str] = None
status: Optional[str] = None
score: Optional[int] = None
notes: Optional[str] = None
assigned_to: Optional[UUID] = None
metadata: Optional[dict] = None
class LeadResponse(BaseModel):
id: UUID
tenant_id: UUID
name: str
phone: Optional[str]
email: Optional[str]
source: Optional[str]
status: str
score: int
notes: Optional[str]
metadata: Optional[dict]
assigned_to: Optional[UUID]
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class LeadListResponse(BaseModel):
items: list[LeadResponse]
total: int
page: int
per_page: int

View File

@ -0,0 +1,31 @@
from datetime import date
def gregorian_to_hijri_approx(greg_date: date) -> str:
"""Approximate Gregorian to Hijri conversion for display purposes.
For production, use the hijri-converter package.
"""
# Approximate calculation
jd = _greg_to_jd(greg_date.year, greg_date.month, greg_date.day)
l = jd - 1948440 + 10632
n = (l - 1) // 10631
l = l - 10631 * n + 354
j = ((10985 - l) // 5316) * ((50 * l) // 17719) + (l // 5670) * ((43 * l) // 15238)
l = l - ((30 - j) // 15) * ((17719 * j) // 50) - (j // 16) * ((15238 * j) // 43) + 29
m = (24 * l) // 709
d = l - (709 * m) // 24
y = 30 * n + j - 30
months_ar = [
"", "محرم", "صفر", "ربيع الأول", "ربيع الآخر",
"جمادى الأولى", "جمادى الآخرة", "رجب", "شعبان",
"رمضان", "شوال", "ذو القعدة", "ذو الحجة"
]
if 1 <= m <= 12:
return f"{d} {months_ar[m]} {y}"
return f"{d}/{m}/{y}"
def _greg_to_jd(y: int, m: int, d: int) -> int:
return (1461 * (y + 4800 + (m - 14) // 12)) // 4 + (367 * (m - 2 - 12 * ((m - 14) // 12))) // 12 - (3 * ((y + 4900 + (m - 14) // 12) // 100)) // 4 + d - 32075

View File

@ -0,0 +1,34 @@
TRANSLATIONS = {
"lead_status": {
"new": {"ar": "جديد", "en": "New"},
"contacted": {"ar": "تم التواصل", "en": "Contacted"},
"qualified": {"ar": "مؤهل", "en": "Qualified"},
"proposal": {"ar": "عرض سعر", "en": "Proposal"},
"won": {"ar": "تم الإغلاق", "en": "Won"},
"lost": {"ar": "مفقود", "en": "Lost"},
},
"deal_stage": {
"new": {"ar": "جديد", "en": "New"},
"negotiation": {"ar": "تفاوض", "en": "Negotiation"},
"proposal": {"ar": "عرض سعر", "en": "Proposal"},
"closed_won": {"ar": "تم الإغلاق", "en": "Closed Won"},
"closed_lost": {"ar": "خسرنا", "en": "Closed Lost"},
},
"user_role": {
"owner": {"ar": "مالك", "en": "Owner"},
"manager": {"ar": "مدير", "en": "Manager"},
"agent": {"ar": "موظف مبيعات", "en": "Sales Agent"},
"admin": {"ar": "مسؤول النظام", "en": "Administrator"},
},
"channels": {
"whatsapp": {"ar": "واتساب", "en": "WhatsApp"},
"email": {"ar": "بريد إلكتروني", "en": "Email"},
"sms": {"ar": "رسالة نصية", "en": "SMS"},
"phone": {"ar": "اتصال", "en": "Phone"},
"website": {"ar": "الموقع", "en": "Website"},
},
}
def t(category: str, key: str, locale: str = "ar") -> str:
return TRANSLATIONS.get(category, {}).get(key, {}).get(locale, key)

View File

@ -0,0 +1,38 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.config import get_settings
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire, "type": "access"})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def create_refresh_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def decode_token(token: str) -> Optional[dict]:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload
except JWTError:
return None

View File

@ -0,0 +1,44 @@
from celery import Celery
from app.config import get_settings
settings = get_settings()
celery_app = Celery(
"salesmatic",
broker=settings.REDIS_URL,
backend=settings.REDIS_URL,
include=[
"app.workers.follow_up_tasks",
"app.workers.message_tasks",
"app.workers.notification_tasks",
],
)
celery_app.conf.update(
task_serializer="json",
accept_content=["json"],
result_serializer="json",
timezone="Asia/Riyadh",
enable_utc=True,
task_track_started=True,
task_acks_late=True,
worker_prefetch_multiplier=1,
)
celery_app.conf.beat_schedule = {
"check-pending-followups": {
"task": "app.workers.follow_up_tasks.process_pending_followups",
"schedule": 300.0, # every 5 minutes
},
"send-scheduled-messages": {
"task": "app.workers.message_tasks.send_scheduled_messages",
"schedule": 60.0, # every minute
},
"daily-report": {
"task": "app.workers.notification_tasks.send_daily_report",
"schedule": {
"hour": 8,
"minute": 0,
},
},
}

View File

@ -0,0 +1,19 @@
from app.workers.celery_app import celery_app
@celery_app.task(name="app.workers.follow_up_tasks.process_pending_followups")
def process_pending_followups():
"""Check for leads that need follow-up and trigger automated messages."""
# TODO: Query leads with no response after configured time
# TODO: Execute automation workflow actions
# TODO: Create activities for completed follow-ups
pass
@celery_app.task(name="app.workers.follow_up_tasks.execute_workflow")
def execute_workflow(workflow_id: str, lead_id: str):
"""Execute a specific automation workflow for a lead."""
# TODO: Load workflow definition
# TODO: Check conditions
# TODO: Execute actions (send message, create task, notify)
pass

View File

@ -0,0 +1,35 @@
from app.workers.celery_app import celery_app
@celery_app.task(name="app.workers.message_tasks.send_scheduled_messages")
def send_scheduled_messages():
"""Send messages that are scheduled for delivery."""
# TODO: Query pending messages
# TODO: Send via appropriate channel (WhatsApp, Email, SMS)
# TODO: Update message status
pass
@celery_app.task(name="app.workers.message_tasks.send_whatsapp")
def send_whatsapp(phone: str, message: str, tenant_id: str):
"""Send a WhatsApp message via Business API."""
# TODO: Call WhatsApp Business API
# TODO: Store message record
# TODO: Handle delivery status
pass
@celery_app.task(name="app.workers.message_tasks.send_email")
def send_email(to_email: str, subject: str, body: str, tenant_id: str):
"""Send an email via configured provider."""
# TODO: Send via SMTP or SendGrid
# TODO: Store message record
pass
@celery_app.task(name="app.workers.message_tasks.send_sms")
def send_sms(phone: str, message: str, tenant_id: str):
"""Send SMS via Unifonic."""
# TODO: Call Unifonic API
# TODO: Store message record
pass

View File

@ -0,0 +1,18 @@
from app.workers.celery_app import celery_app
@celery_app.task(name="app.workers.notification_tasks.send_daily_report")
def send_daily_report():
"""Generate and send daily sales report to all active tenants."""
# TODO: Query each tenant's daily stats
# TODO: Generate report
# TODO: Send to owner via email/WhatsApp
pass
@celery_app.task(name="app.workers.notification_tasks.notify_user")
def notify_user(user_id: str, title: str, body: str, notification_type: str = "info"):
"""Create an in-app notification for a user."""
# TODO: Create notification record
# TODO: Push via WebSocket if connected
pass

View File

@ -0,0 +1,21 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
alembic==1.14.1
pydantic==2.10.4
pydantic-settings==2.7.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.19
celery[redis]==5.4.0
redis==5.2.1
httpx==0.28.1
jinja2==3.1.5
python-dateutil==2.9.0
emails==0.6
aiofiles==24.1.0
pillow==11.1.0
openpyxl==3.1.5
pytest==8.3.4
pytest-asyncio==0.25.0

View File

@ -0,0 +1,10 @@
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac

View File

@ -0,0 +1,97 @@
version: "3.8"
services:
db:
image: postgres:16-alpine
restart: always
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: ${DB_NAME:-salesflow}
POSTGRES_USER: ${DB_USER:-salesflow}
POSTGRES_PASSWORD: ${DB_PASSWORD:-salesflow_secret_2024}
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-salesflow}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
restart: always
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
backend:
build: ./backend
restart: always
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
volumes:
- ./backend/app:/app/app
env_file:
- .env
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "8000:8000"
celery_worker:
build: ./backend
restart: always
command: celery -A app.workers.celery_app worker -l info -c 4
volumes:
- ./backend/app:/app/app
env_file:
- .env
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
celery_beat:
build: ./backend
restart: always
command: celery -A app.workers.celery_app beat -l info
volumes:
- ./backend/app:/app/app
env_file:
- .env
depends_on:
- redis
frontend:
build: ./frontend
restart: always
ports:
- "3000:3000"
environment:
NEXT_PUBLIC_API_URL: ${API_URL:-http://localhost:8000}
depends_on:
- backend
nginx:
image: nginx:alpine
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
depends_on:
- backend
- frontend
volumes:
postgres_data:

View File

@ -0,0 +1,18 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

View File

@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
};
module.exports = nextConfig;

View File

@ -0,0 +1,26 @@
{
"name": "salesmatic-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "15.1.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"lucide-react": "0.469.0",
"clsx": "2.1.1"
},
"devDependencies": {
"@types/node": "22.10.5",
"@types/react": "19.0.3",
"typescript": "5.7.3",
"tailwindcss": "3.4.17",
"postcss": "8.4.49",
"autoprefixer": "10.4.20"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<defs>
<linearGradient id="fg" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#0F4C81" />
<stop offset="100%" style="stop-color:#00BFA6" />
</linearGradient>
</defs>
<rect rx="6" width="32" height="32" fill="url(#fg)" />
<path d="M20 10 C20 7, 17 5.5, 15 5.5 C12 5.5, 10 7, 10 9.5 C10 12, 12 13, 15 14 C18 15, 21 16, 21 19.5 C21 23, 18 25, 15 25 C12 25, 9.5 23, 9.5 20.5"
fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" />
<path d="M24 12 L24 5 M24 5 L21 8 M24 5 L27 8"
fill="none" stroke="#FF6B35" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 744 B

View File

@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
<defs>
<linearGradient id="grad" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#0F4C81;stop-opacity:1" />
<stop offset="100%" style="stop-color:#00BFA6;stop-opacity:1" />
</linearGradient>
<linearGradient id="arrow" x1="0%" y1="100%" x2="0%" y2="0%">
<stop offset="0%" style="stop-color:#00BFA6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#FF6B35;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background circle -->
<circle cx="100" cy="100" r="92" fill="url(#grad)" />
<!-- S letter -->
<path d="M125 65 C125 50, 110 42, 95 42 C75 42, 60 52, 60 68 C60 84, 75 90, 95 95 C115 100, 130 106, 130 125 C130 145, 112 155, 92 155 C72 155, 58 145, 58 130"
fill="none" stroke="white" stroke-width="14" stroke-linecap="round" />
<!-- Upward arrow -->
<path d="M148 85 L148 40 M148 40 L132 56 M148 40 L164 56"
fill="none" stroke="url(#arrow)" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,28 @@
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600;700;800&family=Tajawal:wght@700;800;900&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
scroll-behavior: smooth;
}
body {
font-family: 'IBM Plex Sans Arabic', 'Inter', sans-serif;
}
@layer utilities {
.text-gradient {
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-secondary;
}
.bg-hero-gradient {
background: linear-gradient(135deg, #0F4C81 0%, #1A1A2E 50%, #0F4C81 100%);
}
.bg-grid {
background-image:
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
background-size: 60px 60px;
}
}

View File

@ -0,0 +1,23 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "SalesMatic - سيلزماتك | منصة مبيعات ذكية للشركات",
description: "منصة ذكاء اصطناعي لأتمتة المبيعات. تدير عملاءك، تتابعهم تلقائياً، وتغلق الصفقات. مصممة للسوق السعودي.",
keywords: "مبيعات, CRM, SaaS, ذكاء اصطناعي, أتمتة, عيادات, عقارات, الرياض, السعودية",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ar" dir="rtl">
<head>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
</head>
<body className="antialiased">{children}</body>
</html>
);
}

View File

@ -0,0 +1,393 @@
import {
Users, MessageSquare, BarChart3, Target, Zap, Phone,
CheckCircle2, ArrowLeft, Star, ChevronDown, Building2,
Stethoscope, Home, Clock, Shield, Globe
} from "lucide-react";
const features = [
{ icon: Users, title: "إدارة العملاء المحتملين", titleEn: "Lead Management", desc: "التقط العملاء من واتساب، الموقع، ووسائل التواصل تلقائياً" },
{ icon: MessageSquare, title: "المتابعة التلقائية", titleEn: "Auto Follow-up", desc: "الذكاء الاصطناعي يرسل رسائل وتذكيرات بدون تدخلك" },
{ icon: Target, title: "خط أنابيب المبيعات", titleEn: "Sales Pipeline", desc: "تابع صفقاتك بصرياً وحرّك كل صفقة بين المراحل بسهولة" },
{ icon: Zap, title: "عروض أسعار ذكية", titleEn: "Smart Proposals", desc: "أنشئ وأرسل عروض أسعار احترافية في دقائق" },
{ icon: BarChart3, title: "تقارير وتحليلات", titleEn: "Reports & Analytics", desc: "لوحات بيانات فورية تتابع إيراداتك وأداء فريقك" },
{ icon: Phone, title: "واتساب بزنس", titleEn: "WhatsApp Business", desc: "أرسل واستقبل الرسائل مباشرة من المنصة" },
];
const painPoints = [
{ emoji: "😰", text: "تضيع عملاء لأن المتابعة متأخرة؟" },
{ emoji: "😵", text: "فريقك يشتغل بدون نظام واضح؟" },
{ emoji: "💸", text: "ما تعرف وين فلوسك رايحة؟" },
];
const steps = [
{ num: "01", title: "سجّل شركتك", desc: "في دقيقتين فقط", icon: Building2 },
{ num: "02", title: "اختر قالب قطاعك", desc: "عيادات، عقارات، أو غيرها", icon: Globe },
{ num: "03", title: "المنصة تبدأ تبيع لك", desc: "المتابعة التلقائية تبدأ فوراً", icon: Zap },
];
const plans = [
{
name: "أساسي", nameEn: "Basic", price: "299", popular: false,
features: ["2 مستخدمين", "100 عميل محتمل/شهر", "500 رسالة واتساب", "3 أتمتة", "تقارير أساسية", "دعم بالإيميل"],
},
{
name: "احترافي", nameEn: "Professional", price: "699", popular: true,
features: ["10 مستخدمين", "1,000 عميل محتمل/شهر", "5,000 رسالة واتساب", "20 أتمتة", "تقارير متقدمة", "دعم أولوية", "قوالب قطاعية"],
},
{
name: "مؤسسات", nameEn: "Enterprise", price: "1,499", popular: false,
features: ["مستخدمين بلا حدود", "عملاء بلا حدود", "رسائل بلا حدود", "أتمتة بلا حدود", "تقارير مخصصة", "دعم مخصص", "API كامل", "مدير حساب خاص"],
},
];
const faqs = [
{ q: "هل يدعم الواتساب؟", a: "نعم، نربط مع واتساب بزنس API مباشرة. ترسل وتستقبل الرسائل من داخل المنصة." },
{ q: "هل بياناتي آمنة؟", a: "تشفير كامل لكل البيانات. سيرفرات آمنة مع نسخ احتياطية يومية." },
{ q: "كم يوم التجربة المجانية؟", a: "14 يوم كاملة بكل المميزات بدون بطاقة ائتمان." },
{ q: "هل يدعم العربي؟", a: "المنصة كاملة بالعربي والإنجليزي. مصممة للسوق السعودي." },
{ q: "أقدر ألغي أي وقت؟", a: "نعم، بدون أي التزام أو رسوم إلغاء." },
];
const stats = [
{ value: "+500", label: "شركة تثق بنا" },
{ value: "+10,000", label: "صفقة تم إغلاقها" },
{ value: "+2M", label: "رسالة تم إرسالها" },
{ value: "24/7", label: "أتمتة مستمرة" },
];
export default function LandingPage() {
return (
<div className="min-h-screen bg-white text-gray-900">
{/* Navigation */}
<nav className="fixed top-0 w-full z-50 bg-white/80 backdrop-blur-md border-b border-gray-100">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center gap-2">
<img src="/logo.svg" alt="SalesMatic" className="h-9 w-9" />
<span className="text-xl font-bold text-primary">SalesMatic</span>
<span className="text-sm text-gray-400 hidden sm:block">سيلزماتك</span>
</div>
<div className="hidden md:flex items-center gap-8 text-sm">
<a href="#features" className="text-gray-600 hover:text-primary transition">المميزات</a>
<a href="#how-it-works" className="text-gray-600 hover:text-primary transition">كيف يعمل</a>
<a href="#industries" className="text-gray-600 hover:text-primary transition">القطاعات</a>
<a href="#pricing" className="text-gray-600 hover:text-primary transition">الأسعار</a>
</div>
<div className="flex items-center gap-3">
<a href="/ar/login" className="text-sm text-gray-600 hover:text-primary transition hidden sm:block">تسجيل دخول</a>
<a href="/ar/register" className="bg-accent hover:bg-accent-600 text-white px-5 py-2 rounded-lg text-sm font-medium transition shadow-lg shadow-accent/25">
ابدأ مجاناً
</a>
</div>
</div>
</div>
</nav>
{/* Hero Section */}
<section className="bg-hero-gradient bg-grid pt-32 pb-20 px-4 text-white overflow-hidden relative">
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-primary/20"></div>
<div className="max-w-7xl mx-auto relative z-10">
<div className="max-w-3xl mx-auto text-center">
<div className="inline-flex items-center gap-2 bg-white/10 rounded-full px-4 py-1.5 text-sm mb-6 backdrop-blur-sm">
<span className="w-2 h-2 bg-secondary rounded-full animate-pulse"></span>
صنع في السعودية للسوق السعودي
</div>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold font-arabic leading-tight mb-6">
حوّل مبيعاتك إلى
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-l from-secondary to-emerald-300">
ماكينة أرباح تعمل 24/7
</span>
</h1>
<p className="text-lg sm:text-xl text-gray-300 mb-8 max-w-2xl mx-auto leading-relaxed">
منصة ذكاء اصطناعي تدير عملاءك، تتابعهم تلقائياً، وتغلق الصفقات بدون تدخل. مصممة للشركات الصغيرة والمتوسطة.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<a href="/ar/register" className="bg-accent hover:bg-accent-600 text-white px-8 py-4 rounded-xl text-lg font-bold transition shadow-2xl shadow-accent/30 flex items-center gap-2">
ابدأ مجاناً لمدة 14 يوم
<ArrowLeft className="w-5 h-5" />
</a>
<a href="#features" className="text-white/80 hover:text-white transition flex items-center gap-2">
اكتشف المميزات
<ChevronDown className="w-4 h-4" />
</a>
</div>
<p className="text-sm text-gray-400 mt-4">بدون بطاقة ائتمان إلغاء أي وقت</p>
</div>
{/* Dashboard Mockup */}
<div className="mt-16 max-w-4xl mx-auto">
<div className="bg-white/5 backdrop-blur-xl rounded-2xl border border-white/10 p-6 shadow-2xl">
<div className="flex gap-2 mb-4">
<div className="w-3 h-3 rounded-full bg-red-400"></div>
<div className="w-3 h-3 rounded-full bg-yellow-400"></div>
<div className="w-3 h-3 rounded-full bg-green-400"></div>
</div>
<div className="grid grid-cols-4 gap-4 mb-4">
{[
{ label: "عملاء جدد اليوم", value: "23", color: "text-secondary" },
{ label: "صفقات مفتوحة", value: "47", color: "text-accent" },
{ label: "إيرادات الشهر", value: "185K", color: "text-emerald-400" },
{ label: "معدل التحويل", value: "34%", color: "text-purple-400" },
].map((stat, i) => (
<div key={i} className="bg-white/5 rounded-lg p-3 text-center">
<div className={`text-2xl font-bold ${stat.color}`}>{stat.value}</div>
<div className="text-xs text-gray-400 mt-1">{stat.label}</div>
</div>
))}
</div>
<div className="grid grid-cols-5 gap-3">
{["جديد", "تم التواصل", "موعد محجوز", "عرض سعر", "تم الإغلاق"].map((stage, i) => (
<div key={i} className="bg-white/5 rounded-lg p-2">
<div className="text-xs text-gray-400 mb-2 text-center">{stage}</div>
{Array.from({ length: 3 - Math.floor(i * 0.5) }).map((_, j) => (
<div key={j} className="bg-white/10 rounded h-8 mb-1.5"></div>
))}
</div>
))}
</div>
</div>
</div>
</div>
</section>
{/* Pain Points */}
<section className="py-16 bg-gray-50">
<div className="max-w-5xl mx-auto px-4">
<div className="grid md:grid-cols-3 gap-6">
{painPoints.map((p, i) => (
<div key={i} className="bg-white rounded-xl p-6 text-center shadow-sm border border-gray-100 hover:shadow-md transition">
<div className="text-4xl mb-3">{p.emoji}</div>
<p className="text-lg font-medium text-gray-800">{p.text}</p>
<p className="text-sm text-secondary mt-2 font-medium">SalesMatic يحل هذي المشكلة</p>
</div>
))}
</div>
</div>
</section>
{/* Features */}
<section id="features" className="py-20 px-4">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-14">
<h2 className="text-3xl sm:text-4xl font-bold mb-4">كل اللي تحتاجه لزيادة مبيعاتك</h2>
<p className="text-gray-500 text-lg max-w-2xl mx-auto">منصة متكاملة تجمع كل أدوات المبيعات في مكان واحد</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{features.map((f, i) => (
<div key={i} className="group bg-white border border-gray-100 rounded-2xl p-6 hover:shadow-xl hover:border-primary/20 transition-all duration-300">
<div className="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center mb-4 group-hover:bg-primary group-hover:text-white transition-all">
<f.icon className="w-6 h-6 text-primary group-hover:text-white" />
</div>
<h3 className="text-lg font-bold mb-1">{f.title}</h3>
<p className="text-xs text-gray-400 mb-2">{f.titleEn}</p>
<p className="text-gray-500 text-sm leading-relaxed">{f.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* How It Works */}
<section id="how-it-works" className="py-20 bg-gray-50 px-4">
<div className="max-w-5xl mx-auto">
<div className="text-center mb-14">
<h2 className="text-3xl sm:text-4xl font-bold mb-4">ابدأ في 3 خطوات</h2>
<p className="text-gray-500 text-lg">من التسجيل إلى أول صفقة في دقائق</p>
</div>
<div className="grid md:grid-cols-3 gap-8">
{steps.map((s, i) => (
<div key={i} className="text-center relative">
<div className="w-20 h-20 bg-primary rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg shadow-primary/25">
<s.icon className="w-10 h-10 text-white" />
</div>
<div className="text-xs text-primary font-bold mb-2">الخطوة {s.num}</div>
<h3 className="text-xl font-bold mb-2">{s.title}</h3>
<p className="text-gray-500 text-sm">{s.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* Industry Templates */}
<section id="industries" className="py-20 px-4">
<div className="max-w-5xl mx-auto">
<div className="text-center mb-14">
<h2 className="text-3xl sm:text-4xl font-bold mb-4">قوالب جاهزة لقطاعك</h2>
<p className="text-gray-500 text-lg">اختر قالب قطاعك وابدأ فوراً</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-gradient-to-br from-primary to-primary-700 rounded-2xl p-6 text-white shadow-xl">
<Stethoscope className="w-10 h-10 mb-4" />
<h3 className="text-lg font-bold mb-1">العيادات والصحة</h3>
<p className="text-sm text-white/70 mb-4">إدارة المرضى والمواعيد والمتابعة</p>
<div className="text-xs bg-white/20 rounded-full px-3 py-1 inline-block">متاح الآن</div>
</div>
<div className="bg-gradient-to-br from-secondary to-secondary-700 rounded-2xl p-6 text-white shadow-xl">
<Home className="w-10 h-10 mb-4" />
<h3 className="text-lg font-bold mb-1">عقارات الرياض</h3>
<p className="text-sm text-white/70 mb-4">عقارات، جولات، عروض، أحياء الرياض</p>
<div className="text-xs bg-white/20 rounded-full px-3 py-1 inline-block">متاح الآن</div>
</div>
<div className="bg-gray-100 rounded-2xl p-6 text-gray-400">
<Building2 className="w-10 h-10 mb-4" />
<h3 className="text-lg font-bold mb-1 text-gray-500">المقاولات</h3>
<p className="text-sm mb-4">إدارة المشاريع والعملاء</p>
<div className="text-xs bg-gray-200 rounded-full px-3 py-1 inline-block">قريباً</div>
</div>
<div className="bg-gray-100 rounded-2xl p-6 text-gray-400">
<Star className="w-10 h-10 mb-4" />
<h3 className="text-lg font-bold mb-1 text-gray-500">الصالونات</h3>
<p className="text-sm mb-4">حجوزات ومتابعة العملاء</p>
<div className="text-xs bg-gray-200 rounded-full px-3 py-1 inline-block">قريباً</div>
</div>
</div>
</div>
</section>
{/* Stats */}
<section className="py-16 bg-dark text-white">
<div className="max-w-5xl mx-auto px-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
{stats.map((s, i) => (
<div key={i} className="text-center">
<div className="text-3xl sm:text-4xl font-bold text-secondary mb-1">{s.value}</div>
<div className="text-sm text-gray-400">{s.label}</div>
</div>
))}
</div>
</div>
</section>
{/* Pricing */}
<section id="pricing" className="py-20 px-4">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-14">
<h2 className="text-3xl sm:text-4xl font-bold mb-4">خطط تناسب حجم شركتك</h2>
<p className="text-gray-500 text-lg">ابدأ مجاناً 14 يوم بدون بطاقة ائتمان</p>
</div>
<div className="grid md:grid-cols-3 gap-6">
{plans.map((plan, i) => (
<div key={i} className={`rounded-2xl p-6 border-2 transition-all ${
plan.popular
? "border-primary bg-primary/5 shadow-xl scale-105 relative"
: "border-gray-100 bg-white hover:border-gray-200"
}`}>
{plan.popular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-white text-xs px-4 py-1 rounded-full font-medium">
الأكثر شعبية
</div>
)}
<div className="text-center mb-6">
<h3 className="text-lg font-bold">{plan.name}</h3>
<p className="text-xs text-gray-400">{plan.nameEn}</p>
<div className="mt-4">
<span className="text-4xl font-bold">{plan.price}</span>
<span className="text-gray-500 text-sm"> ر.س/شهر</span>
</div>
</div>
<ul className="space-y-3 mb-6">
{plan.features.map((f, j) => (
<li key={j} className="flex items-center gap-2 text-sm">
<CheckCircle2 className="w-4 h-4 text-secondary flex-shrink-0" />
{f}
</li>
))}
</ul>
<a href="/ar/register" className={`block text-center py-3 rounded-xl font-medium transition ${
plan.popular
? "bg-primary text-white hover:bg-primary-600 shadow-lg"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}>
ابدأ تجربة مجانية
</a>
</div>
))}
</div>
</div>
</section>
{/* FAQ */}
<section className="py-20 bg-gray-50 px-4">
<div className="max-w-3xl mx-auto">
<div className="text-center mb-14">
<h2 className="text-3xl font-bold mb-4">أسئلة شائعة</h2>
</div>
<div className="space-y-4">
{faqs.map((faq, i) => (
<details key={i} className="bg-white rounded-xl border border-gray-100 group">
<summary className="flex items-center justify-between p-5 cursor-pointer font-medium hover:text-primary transition">
{faq.q}
<ChevronDown className="w-5 h-5 text-gray-400 group-open:rotate-180 transition-transform" />
</summary>
<div className="px-5 pb-5 text-gray-500 text-sm leading-relaxed">{faq.a}</div>
</details>
))}
</div>
</div>
</section>
{/* Final CTA */}
<section className="py-20 bg-hero-gradient text-white px-4">
<div className="max-w-3xl mx-auto text-center">
<h2 className="text-3xl sm:text-4xl font-bold mb-4">جاهز تزيد مبيعاتك؟</h2>
<p className="text-lg text-gray-300 mb-8">انضم لمئات الشركات اللي زادت مبيعاتها مع SalesMatic</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<a href="/ar/register" className="bg-accent hover:bg-accent-600 text-white px-8 py-4 rounded-xl text-lg font-bold transition shadow-2xl">
ابدأ مجاناً الآن
</a>
<a href="https://wa.me/966XXXXXXXXXX" className="bg-green-500 hover:bg-green-600 text-white px-8 py-4 rounded-xl text-lg font-bold transition shadow-2xl flex items-center gap-2">
<Phone className="w-5 h-5" />
تواصل عبر واتساب
</a>
</div>
</div>
</section>
{/* Footer */}
<footer className="bg-dark text-gray-400 py-12 px-4">
<div className="max-w-7xl mx-auto">
<div className="grid md:grid-cols-4 gap-8 mb-8">
<div>
<div className="flex items-center gap-2 mb-4">
<img src="/logo.svg" alt="SalesMatic" className="h-8 w-8" />
<span className="text-white font-bold text-lg">SalesMatic</span>
</div>
<p className="text-sm mb-4 leading-relaxed">مبيعاتك تشتغل وأنت ترتاح</p>
<p className="text-sm">Sales on Autopilot</p>
</div>
<div>
<h4 className="text-white font-medium mb-4">المنصة</h4>
<ul className="space-y-2 text-sm">
<li><a href="#features" className="hover:text-white transition">المميزات</a></li>
<li><a href="#pricing" className="hover:text-white transition">الأسعار</a></li>
<li><a href="#industries" className="hover:text-white transition">القطاعات</a></li>
</ul>
</div>
<div>
<h4 className="text-white font-medium mb-4">الشركة</h4>
<ul className="space-y-2 text-sm">
<li><a href="#" className="hover:text-white transition">من نحن</a></li>
<li><a href="#" className="hover:text-white transition">تواصل معنا</a></li>
<li><a href="#" className="hover:text-white transition">سياسة الخصوصية</a></li>
</ul>
</div>
<div>
<h4 className="text-white font-medium mb-4">تواصل معنا</h4>
<ul className="space-y-2 text-sm">
<li className="flex items-center gap-2"><Phone className="w-4 h-4" /> واتساب بزنس</li>
<li className="flex items-center gap-2"><Shield className="w-4 h-4" /> دعم فني</li>
<li className="flex items-center gap-2"><Clock className="w-4 h-4" /> 24/7 متاح</li>
</ul>
</div>
</div>
<div className="border-t border-gray-800 pt-8 flex flex-col sm:flex-row justify-between items-center gap-4">
<p className="text-sm">© 2024 SalesMatic. جميع الحقوق محفوظة</p>
<p className="text-sm flex items-center gap-1">صنع بـ في السعودية 🇸🇦</p>
</div>
</div>
</footer>
</div>
);
}

View File

@ -0,0 +1,60 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
primary: {
DEFAULT: "#0F4C81",
50: "#E8F0F8",
100: "#C5D9EE",
200: "#8BB3DD",
300: "#518DCC",
400: "#2A6AA8",
500: "#0F4C81",
600: "#0D4173",
700: "#0A3460",
800: "#08274D",
900: "#051A3A",
},
secondary: {
DEFAULT: "#00BFA6",
50: "#E0FFF9",
100: "#B3FFE8",
200: "#66FFD1",
300: "#33EFBA",
400: "#00D4B3",
500: "#00BFA6",
600: "#009C87",
700: "#007A6A",
800: "#00574C",
900: "#00352E",
},
accent: {
DEFAULT: "#FF6B35",
50: "#FFF0EB",
100: "#FFD9C8",
200: "#FFB391",
300: "#FF8D5A",
400: "#FF7948",
500: "#FF6B35",
600: "#E55A28",
700: "#CC4A1B",
800: "#993813",
900: "#66250D",
},
dark: "#1A1A2E",
success: "#22C55E",
warning: "#F59E0B",
error: "#EF4444",
},
fontFamily: {
arabic: ["IBM Plex Sans Arabic", "Tajawal", "sans-serif"],
sans: ["Inter", "IBM Plex Sans Arabic", "sans-serif"],
},
},
},
plugins: [],
};

View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@ -0,0 +1,53 @@
events {
worker_connections 1024;
}
http {
upstream backend {
server backend:8000;
}
upstream frontend {
server frontend:3000;
}
server {
listen 80;
server_name _;
client_max_body_size 10M;
# API routes
location /api/ {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket support for real-time
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
# Webhook endpoints
location /webhooks/ {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Frontend
location / {
proxy_pass http://frontend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}

View File

@ -0,0 +1,95 @@
{
"industry": "healthcare",
"name": "Healthcare & Clinics",
"name_ar": "العيادات والمراكز الصحية",
"pipeline_stages": [
{"key": "new_inquiry", "name_en": "New Inquiry", "name_ar": "استفسار جديد", "order": 1, "probability": 10},
{"key": "contacted", "name_en": "Contacted", "name_ar": "تم التواصل", "order": 2, "probability": 25},
{"key": "appointment_booked", "name_en": "Appointment Booked", "name_ar": "موعد محجوز", "order": 3, "probability": 50},
{"key": "quote_sent", "name_en": "Quote Sent", "name_ar": "عرض سعر", "order": 4, "probability": 70},
{"key": "closed_won", "name_en": "Closed Won", "name_ar": "تم الإغلاق", "order": 5, "probability": 100},
{"key": "lost", "name_en": "Lost", "name_ar": "مفقود", "order": 6, "probability": 0}
],
"message_templates": [
{
"name": "welcome",
"name_ar": "رسالة ترحيب",
"channel": "whatsapp",
"trigger": "lead_created",
"content_ar": "مرحباً {name}! شكراً لتواصلك مع {company}. كيف نقدر نساعدك اليوم؟ فريقنا الطبي جاهز لخدمتك.",
"content_en": "Hello {name}! Thank you for contacting {company}. How can we help you today? Our medical team is ready to serve you.",
"delay_minutes": 0
},
{
"name": "appointment_reminder",
"name_ar": "تذكير بالموعد",
"channel": "whatsapp",
"trigger": "scheduled",
"content_ar": "تذكير: عندك موعد في {company} يوم {date} الساعة {time}. للتأكيد أو التغيير تواصل معنا. نتطلع لزيارتك! 🏥",
"content_en": "Reminder: You have an appointment at {company} on {date} at {time}. To confirm or reschedule, please contact us.",
"delay_minutes": -1440
},
{
"name": "no_response_followup",
"name_ar": "متابعة عدم الرد",
"channel": "whatsapp",
"trigger": "no_response",
"content_ar": "مرحباً {name}، لاحظنا إنك تواصلت معنا وحبينا نتأكد إن كل شي تمام. هل تحتاج أي مساعدة؟ نقدر نحجز لك موعد استشارة مجانية.",
"content_en": "Hi {name}, we noticed you reached out and wanted to check in. Do you need any help? We can book a free consultation for you.",
"delay_minutes": 2880
},
{
"name": "post_visit",
"name_ar": "بعد الزيارة",
"channel": "whatsapp",
"trigger": "deal_closed_won",
"content_ar": "شكراً لزيارتك {name}! نتمنى تكون التجربة ممتازة. صحتك تهمنا، لا تتردد تتواصل معنا لأي استفسار. ⭐",
"content_en": "Thank you for your visit {name}! We hope the experience was excellent. Your health matters to us.",
"delay_minutes": 1440
}
],
"proposal_templates": [
{
"name": "treatment_plan",
"name_ar": "خطة علاجية",
"sections": [
{"title_ar": "التشخيص", "title_en": "Diagnosis"},
{"title_ar": "الخطة العلاجية", "title_en": "Treatment Plan"},
{"title_ar": "التكلفة التفصيلية", "title_en": "Detailed Cost"},
{"title_ar": "مدة العلاج", "title_en": "Treatment Duration"},
{"title_ar": "ملاحظات", "title_en": "Notes"}
]
},
{
"name": "package_offer",
"name_ar": "عرض باقة",
"sections": [
{"title_ar": "الباقة", "title_en": "Package"},
{"title_ar": "الخدمات المشمولة", "title_en": "Included Services"},
{"title_ar": "السعر الخاص", "title_en": "Special Price"},
{"title_ar": "الصلاحية", "title_en": "Validity"}
]
}
],
"workflow_templates": [
{
"name": "new_patient_flow",
"name_ar": "تدفق مريض جديد",
"trigger": "lead_created",
"actions": [
{"type": "send_message", "template": "welcome", "delay_minutes": 0},
{"type": "create_task", "subject": "اتصل بالمريض الجديد", "delay_minutes": 30},
{"type": "send_message", "template": "no_response_followup", "delay_minutes": 2880, "condition": "no_response"}
]
},
{
"name": "appointment_reminder_flow",
"name_ar": "تذكير المواعيد",
"trigger": "appointment_created",
"actions": [
{"type": "send_message", "template": "appointment_reminder", "delay_minutes": -1440},
{"type": "send_message", "template": "appointment_reminder", "delay_minutes": -120}
]
}
]
}

View File

@ -0,0 +1,139 @@
{
"industry": "real_estate",
"name": "Real Estate - Riyadh",
"name_ar": "العقارات - الرياض",
"pipeline_stages": [
{"key": "new_lead", "name_en": "New Lead", "name_ar": "عميل جديد", "order": 1, "probability": 10},
{"key": "contacted", "name_en": "Contacted", "name_ar": "تم التواصل", "order": 2, "probability": 20},
{"key": "property_tour", "name_en": "Property Tour", "name_ar": "جولة عقارية", "order": 3, "probability": 40},
{"key": "offer_made", "name_en": "Offer Made", "name_ar": "عرض سعر", "order": 4, "probability": 60},
{"key": "negotiation", "name_en": "Negotiation", "name_ar": "تفاوض", "order": 5, "probability": 75},
{"key": "closed_won", "name_en": "Closed Won", "name_ar": "تم البيع", "order": 6, "probability": 100},
{"key": "lost", "name_en": "Lost", "name_ar": "ملغي", "order": 7, "probability": 0}
],
"message_templates": [
{
"name": "welcome",
"name_ar": "رسالة ترحيب",
"channel": "whatsapp",
"trigger": "lead_created",
"content_ar": "أهلاً {name}! شكراً لتواصلك مع {company}. متخصصين في العقارات بالرياض. وش نوع العقار اللي تبحث عنه؟ 🏠",
"content_en": "Hello {name}! Thank you for contacting {company}. We specialize in Riyadh real estate. What type of property are you looking for?",
"delay_minutes": 0
},
{
"name": "tour_reminder",
"name_ar": "تذكير بالجولة",
"channel": "whatsapp",
"trigger": "scheduled",
"content_ar": "تذكير: عندك جولة عقارية يوم {date} الساعة {time} في {property_name} - {district}. نرسل لك الموقع قبل الموعد. 📍",
"content_en": "Reminder: Property tour on {date} at {time} for {property_name} - {district}. We'll send the location before the appointment.",
"delay_minutes": -1440
},
{
"name": "post_tour_followup",
"name_ar": "متابعة بعد الجولة",
"channel": "whatsapp",
"trigger": "tour_completed",
"content_ar": "مرحباً {name}! كيف شفت العقار اليوم؟ إذا عجبك نقدر نجهز لك عرض سعر. أو عندنا خيارات ثانية في نفس المنطقة.",
"content_en": "Hi {name}! How did you find the property today? If you liked it, we can prepare a quote. Or we have other options in the same area.",
"delay_minutes": 180
},
{
"name": "no_response",
"name_ar": "متابعة عدم الرد",
"channel": "whatsapp",
"trigger": "no_response",
"content_ar": "مرحباً {name}، عندنا عقارات جديدة في الرياض ممكن تناسبك. تبي نرسل لك كتالوج العقارات المتاحة حالياً؟ 🏡",
"content_en": "Hi {name}, we have new properties in Riyadh that might interest you. Would you like us to send you the current catalog?",
"delay_minutes": 4320
},
{
"name": "new_listing_match",
"name_ar": "عقار جديد مطابق",
"channel": "whatsapp",
"trigger": "new_listing",
"content_ar": "مرحباً {name}! عندنا عقار جديد في {district} ممكن يناسب طلبك: {property_title} - {bedrooms} غرف - {price} ريال. تبي تحجز جولة؟",
"content_en": "Hi {name}! We have a new property in {district} matching your request: {property_title} - {bedrooms} rooms - {price} SAR. Want to book a tour?",
"delay_minutes": 0
}
],
"proposal_templates": [
{
"name": "property_presentation",
"name_ar": "عرض عقار",
"sections": [
{"title_ar": "تفاصيل العقار", "title_en": "Property Details"},
{"title_ar": "الموقع والحي", "title_en": "Location & District"},
{"title_ar": "المميزات", "title_en": "Features"},
{"title_ar": "السعر وطريقة الدفع", "title_en": "Price & Payment Plan"},
{"title_ar": "الصور", "title_en": "Photos"}
]
},
{
"name": "payment_plan",
"name_ar": "خطة السداد",
"sections": [
{"title_ar": "ملخص العقار", "title_en": "Property Summary"},
{"title_ar": "السعر الإجمالي", "title_en": "Total Price"},
{"title_ar": "الدفعة الأولى", "title_en": "Down Payment"},
{"title_ar": "الأقساط الشهرية", "title_en": "Monthly Installments"},
{"title_ar": "الشروط والأحكام", "title_en": "Terms & Conditions"}
]
},
{
"name": "comparison_sheet",
"name_ar": "جدول مقارنة",
"sections": [
{"title_ar": "العقارات المقارنة", "title_en": "Properties Compared"},
{"title_ar": "المساحة والغرف", "title_en": "Area & Rooms"},
{"title_ar": "السعر", "title_en": "Price"},
{"title_ar": "الموقع", "title_en": "Location"},
{"title_ar": "التوصية", "title_en": "Recommendation"}
]
}
],
"workflow_templates": [
{
"name": "new_client_flow",
"name_ar": "تدفق عميل جديد",
"trigger": "lead_created",
"actions": [
{"type": "send_message", "template": "welcome", "delay_minutes": 0},
{"type": "create_task", "subject": "اتصل بالعميل واعرف متطلباته", "delay_minutes": 15},
{"type": "send_message", "template": "no_response", "delay_minutes": 4320, "condition": "no_response"}
]
},
{
"name": "tour_followup_flow",
"name_ar": "متابعة الجولات",
"trigger": "stage_change_property_tour",
"actions": [
{"type": "send_message", "template": "tour_reminder", "delay_minutes": -1440},
{"type": "send_message", "template": "post_tour_followup", "delay_minutes": 180}
]
}
],
"riyadh_districts": [
{"name_ar": "حي النرجس", "name_en": "Al Narjis"},
{"name_ar": "حي الياسمين", "name_en": "Al Yasmin"},
{"name_ar": "حي حطين", "name_en": "Hittin"},
{"name_ar": "حي العارض", "name_en": "Al Arid"},
{"name_ar": "حي الملقا", "name_en": "Al Malqa"},
{"name_ar": "حي الصحافة", "name_en": "Al Sahafa"},
{"name_ar": "حي العليا", "name_en": "Al Olaya"},
{"name_ar": "حي السليمانية", "name_en": "Al Sulaimaniyah"},
{"name_ar": "حي الروضة", "name_en": "Al Rawdah"},
{"name_ar": "حي المروج", "name_en": "Al Muruj"},
{"name_ar": "حي الازدهار", "name_en": "Al Izdihar"},
{"name_ar": "حي النخيل", "name_en": "Al Nakheel"},
{"name_ar": "حي الربيع", "name_en": "Al Rabi"},
{"name_ar": "حي الملك فهد", "name_en": "King Fahd"},
{"name_ar": "حي الغدير", "name_en": "Al Ghadir"},
{"name_ar": "حي القيروان", "name_en": "Al Qairawan"},
{"name_ar": "حي الرمال", "name_en": "Al Rimal"},
{"name_ar": "حي العقيق", "name_en": "Al Aqiq"},
{"name_ar": "حي الوادي", "name_en": "Al Wadi"},
{"name_ar": "حي المهدية", "name_en": "Al Mahdiyah"}
]
}