mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-18 15:29:36 +00:00
Merge pull request #1 from VoXc2/claude/fix-settings-table-a1bXv
This commit is contained in:
commit
f63beffbc6
42
salesflow-saas/.env.example
Normal file
42
salesflow-saas/.env.example
Normal 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
47
salesflow-saas/.gitignore
vendored
Normal 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
49
salesflow-saas/Makefile
Normal 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
|
||||||
16
salesflow-saas/backend/Dockerfile
Normal file
16
salesflow-saas/backend/Dockerfile
Normal 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"]
|
||||||
36
salesflow-saas/backend/alembic.ini
Normal file
36
salesflow-saas/backend/alembic.ini
Normal 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
|
||||||
0
salesflow-saas/backend/app/__init__.py
Normal file
0
salesflow-saas/backend/app/__init__.py
Normal file
0
salesflow-saas/backend/app/api/__init__.py
Normal file
0
salesflow-saas/backend/app/api/__init__.py
Normal file
50
salesflow-saas/backend/app/api/deps.py
Normal file
50
salesflow-saas/backend/app/api/deps.py
Normal 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
|
||||||
0
salesflow-saas/backend/app/api/v1/__init__.py
Normal file
0
salesflow-saas/backend/app/api/v1/__init__.py
Normal file
118
salesflow-saas/backend/app/api/v1/auth.py
Normal file
118
salesflow-saas/backend/app/api/v1/auth.py
Normal 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,
|
||||||
|
)
|
||||||
78
salesflow-saas/backend/app/api/v1/dashboard.py
Normal file
78
salesflow-saas/backend/app/api/v1/dashboard.py
Normal 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)
|
||||||
110
salesflow-saas/backend/app/api/v1/deals.py
Normal file
110
salesflow-saas/backend/app/api/v1/deals.py
Normal 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)
|
||||||
107
salesflow-saas/backend/app/api/v1/leads.py
Normal file
107
salesflow-saas/backend/app/api/v1/leads.py
Normal 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)
|
||||||
11
salesflow-saas/backend/app/api/v1/router.py
Normal file
11
salesflow-saas/backend/app/api/v1/router.py
Normal 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"])
|
||||||
60
salesflow-saas/backend/app/api/v1/tenants.py
Normal file
60
salesflow-saas/backend/app/api/v1/tenants.py
Normal 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)
|
||||||
116
salesflow-saas/backend/app/api/v1/users.py
Normal file
116
salesflow-saas/backend/app/api/v1/users.py
Normal 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()
|
||||||
55
salesflow-saas/backend/app/config.py
Normal file
55
salesflow-saas/backend/app/config.py
Normal 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()
|
||||||
31
salesflow-saas/backend/app/database.py
Normal file
31
salesflow-saas/backend/app/database.py
Normal 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()
|
||||||
0
salesflow-saas/backend/app/integrations/__init__.py
Normal file
0
salesflow-saas/backend/app/integrations/__init__.py
Normal file
29
salesflow-saas/backend/app/integrations/email_sender.py
Normal file
29
salesflow-saas/backend/app/integrations/email_sender.py
Normal 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)}
|
||||||
26
salesflow-saas/backend/app/integrations/sms.py
Normal file
26
salesflow-saas/backend/app/integrations/sms.py
Normal 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")}
|
||||||
55
salesflow-saas/backend/app/integrations/whatsapp.py
Normal file
55
salesflow-saas/backend/app/integrations/whatsapp.py
Normal 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()
|
||||||
34
salesflow-saas/backend/app/main.py
Normal file
34
salesflow-saas/backend/app/main.py
Normal 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",
|
||||||
|
}
|
||||||
20
salesflow-saas/backend/app/models/__init__.py
Normal file
20
salesflow-saas/backend/app/models/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
22
salesflow-saas/backend/app/models/activity.py
Normal file
22
salesflow-saas/backend/app/models/activity.py
Normal 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")
|
||||||
14
salesflow-saas/backend/app/models/audit_log.py
Normal file
14
salesflow-saas/backend/app/models/audit_log.py
Normal 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)
|
||||||
18
salesflow-saas/backend/app/models/base.py
Normal file
18
salesflow-saas/backend/app/models/base.py
Normal 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)
|
||||||
20
salesflow-saas/backend/app/models/customer.py
Normal file
20
salesflow-saas/backend/app/models/customer.py
Normal 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")
|
||||||
29
salesflow-saas/backend/app/models/deal.py
Normal file
29
salesflow-saas/backend/app/models/deal.py
Normal 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")
|
||||||
26
salesflow-saas/backend/app/models/lead.py
Normal file
26
salesflow-saas/backend/app/models/lead.py
Normal 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")
|
||||||
20
salesflow-saas/backend/app/models/message.py
Normal file
20
salesflow-saas/backend/app/models/message.py
Normal 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")
|
||||||
14
salesflow-saas/backend/app/models/notification.py
Normal file
14
salesflow-saas/backend/app/models/notification.py
Normal 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)
|
||||||
32
salesflow-saas/backend/app/models/property.py
Normal file
32
salesflow-saas/backend/app/models/property.py
Normal 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])
|
||||||
22
salesflow-saas/backend/app/models/proposal.py
Normal file
22
salesflow-saas/backend/app/models/proposal.py
Normal 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")
|
||||||
14
salesflow-saas/backend/app/models/subscription.py
Normal file
14
salesflow-saas/backend/app/models/subscription.py
Normal 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)
|
||||||
16
salesflow-saas/backend/app/models/template.py
Normal file
16
salesflow-saas/backend/app/models/template.py
Normal 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)
|
||||||
27
salesflow-saas/backend/app/models/tenant.py
Normal file
27
salesflow-saas/backend/app/models/tenant.py
Normal 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")
|
||||||
21
salesflow-saas/backend/app/models/user.py
Normal file
21
salesflow-saas/backend/app/models/user.py
Normal 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")
|
||||||
0
salesflow-saas/backend/app/schemas/__init__.py
Normal file
0
salesflow-saas/backend/app/schemas/__init__.py
Normal file
30
salesflow-saas/backend/app/schemas/auth.py
Normal file
30
salesflow-saas/backend/app/schemas/auth.py
Normal 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
|
||||||
32
salesflow-saas/backend/app/schemas/dashboard.py
Normal file
32
salesflow-saas/backend/app/schemas/dashboard.py
Normal 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
|
||||||
58
salesflow-saas/backend/app/schemas/deal.py
Normal file
58
salesflow-saas/backend/app/schemas/deal.py
Normal 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
|
||||||
50
salesflow-saas/backend/app/schemas/lead.py
Normal file
50
salesflow-saas/backend/app/schemas/lead.py
Normal 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
|
||||||
0
salesflow-saas/backend/app/services/__init__.py
Normal file
0
salesflow-saas/backend/app/services/__init__.py
Normal file
0
salesflow-saas/backend/app/utils/__init__.py
Normal file
0
salesflow-saas/backend/app/utils/__init__.py
Normal file
31
salesflow-saas/backend/app/utils/hijri.py
Normal file
31
salesflow-saas/backend/app/utils/hijri.py
Normal 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
|
||||||
34
salesflow-saas/backend/app/utils/localization.py
Normal file
34
salesflow-saas/backend/app/utils/localization.py
Normal 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)
|
||||||
38
salesflow-saas/backend/app/utils/security.py
Normal file
38
salesflow-saas/backend/app/utils/security.py
Normal 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
|
||||||
0
salesflow-saas/backend/app/workers/__init__.py
Normal file
0
salesflow-saas/backend/app/workers/__init__.py
Normal file
44
salesflow-saas/backend/app/workers/celery_app.py
Normal file
44
salesflow-saas/backend/app/workers/celery_app.py
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
19
salesflow-saas/backend/app/workers/follow_up_tasks.py
Normal file
19
salesflow-saas/backend/app/workers/follow_up_tasks.py
Normal 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
|
||||||
35
salesflow-saas/backend/app/workers/message_tasks.py
Normal file
35
salesflow-saas/backend/app/workers/message_tasks.py
Normal 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
|
||||||
18
salesflow-saas/backend/app/workers/notification_tasks.py
Normal file
18
salesflow-saas/backend/app/workers/notification_tasks.py
Normal 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
|
||||||
21
salesflow-saas/backend/requirements.txt
Normal file
21
salesflow-saas/backend/requirements.txt
Normal 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
|
||||||
10
salesflow-saas/backend/tests/conftest.py
Normal file
10
salesflow-saas/backend/tests/conftest.py
Normal 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
|
||||||
97
salesflow-saas/docker-compose.yml
Normal file
97
salesflow-saas/docker-compose.yml
Normal 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:
|
||||||
18
salesflow-saas/frontend/Dockerfile
Normal file
18
salesflow-saas/frontend/Dockerfile
Normal 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"]
|
||||||
6
salesflow-saas/frontend/next.config.js
Normal file
6
salesflow-saas/frontend/next.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
26
salesflow-saas/frontend/package.json
Normal file
26
salesflow-saas/frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
salesflow-saas/frontend/postcss.config.js
Normal file
6
salesflow-saas/frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
13
salesflow-saas/frontend/public/favicon.svg
Normal file
13
salesflow-saas/frontend/public/favicon.svg
Normal 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 |
20
salesflow-saas/frontend/public/logo.svg
Normal file
20
salesflow-saas/frontend/public/logo.svg
Normal 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 |
28
salesflow-saas/frontend/src/app/globals.css
Normal file
28
salesflow-saas/frontend/src/app/globals.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
salesflow-saas/frontend/src/app/layout.tsx
Normal file
23
salesflow-saas/frontend/src/app/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
393
salesflow-saas/frontend/src/app/page.tsx
Normal file
393
salesflow-saas/frontend/src/app/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
salesflow-saas/frontend/tailwind.config.js
Normal file
60
salesflow-saas/frontend/tailwind.config.js
Normal 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: [],
|
||||||
|
};
|
||||||
21
salesflow-saas/frontend/tsconfig.json
Normal file
21
salesflow-saas/frontend/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
53
salesflow-saas/nginx/nginx.conf
Normal file
53
salesflow-saas/nginx/nginx.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
salesflow-saas/seeds/healthcare_template.json
Normal file
95
salesflow-saas/seeds/healthcare_template.json
Normal 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}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
139
salesflow-saas/seeds/realestate_template.json
Normal file
139
salesflow-saas/seeds/realestate_template.json
Normal 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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user