From f1852c1121b19d5f6ed08d7860deb3a88cf246f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 03:06:53 +0000 Subject: [PATCH] Add SalesMatic AI Sales SaaS Platform - Complete Foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-stack AI-powered sales automation platform for Saudi SMEs: Backend (FastAPI + PostgreSQL): - Multi-tenant architecture with row-level isolation - JWT auth with RBAC (owner/manager/agent/admin) - Lead, Customer, Deal, Pipeline, Activity, Message, Proposal models - Dashboard analytics API (overview, pipeline, revenue) - WhatsApp Business API, Email (SMTP/SendGrid), SMS (Unifonic) integrations - Celery + Redis workers for automated follow-ups and scheduled messages - Property model for Real Estate module (Riyadh districts) - Hijri date utilities, Arabic/English localization Frontend (Next.js + Tailwind): - Professional Arabic RTL landing page with 10 sections - Brand identity: SalesMatic (سيلزماتك) with custom SVG logo - Color system: Trust Blue #0F4C81, Growth Teal #00BFA6, CTA Orange #FF6B35 - IBM Plex Sans Arabic + Inter typography - Responsive design, dark hero section, pricing table, FAQ Industry Templates: - Healthcare/Clinics: pipeline stages, WhatsApp message templates, auto-workflows - Real Estate Riyadh: 20 districts, property tours, payment plans, matching Infrastructure: - Docker Compose (PostgreSQL, Redis, Backend, Celery, Frontend, Nginx) - Nginx reverse proxy config - Makefile for common operations https://claude.ai/code/session_01LLR7jzpyNRwDA9kojtT3CW --- salesflow-saas/.env.example | 42 ++ salesflow-saas/.gitignore | 47 +++ salesflow-saas/Makefile | 49 +++ salesflow-saas/backend/Dockerfile | 16 + salesflow-saas/backend/alembic.ini | 36 ++ salesflow-saas/backend/app/__init__.py | 0 salesflow-saas/backend/app/api/__init__.py | 0 salesflow-saas/backend/app/api/deps.py | 50 +++ salesflow-saas/backend/app/api/v1/__init__.py | 0 salesflow-saas/backend/app/api/v1/auth.py | 118 ++++++ .../backend/app/api/v1/dashboard.py | 78 ++++ salesflow-saas/backend/app/api/v1/deals.py | 110 +++++ salesflow-saas/backend/app/api/v1/leads.py | 107 +++++ salesflow-saas/backend/app/api/v1/router.py | 11 + salesflow-saas/backend/app/api/v1/tenants.py | 60 +++ salesflow-saas/backend/app/api/v1/users.py | 116 ++++++ salesflow-saas/backend/app/config.py | 55 +++ salesflow-saas/backend/app/database.py | 31 ++ .../backend/app/integrations/__init__.py | 0 .../backend/app/integrations/email_sender.py | 29 ++ .../backend/app/integrations/sms.py | 26 ++ .../backend/app/integrations/whatsapp.py | 55 +++ salesflow-saas/backend/app/main.py | 34 ++ salesflow-saas/backend/app/models/__init__.py | 20 + salesflow-saas/backend/app/models/activity.py | 22 + .../backend/app/models/audit_log.py | 14 + salesflow-saas/backend/app/models/base.py | 18 + salesflow-saas/backend/app/models/customer.py | 20 + salesflow-saas/backend/app/models/deal.py | 29 ++ salesflow-saas/backend/app/models/lead.py | 26 ++ salesflow-saas/backend/app/models/message.py | 20 + .../backend/app/models/notification.py | 14 + salesflow-saas/backend/app/models/property.py | 32 ++ salesflow-saas/backend/app/models/proposal.py | 22 + .../backend/app/models/subscription.py | 14 + salesflow-saas/backend/app/models/template.py | 16 + salesflow-saas/backend/app/models/tenant.py | 27 ++ salesflow-saas/backend/app/models/user.py | 21 + .../backend/app/schemas/__init__.py | 0 salesflow-saas/backend/app/schemas/auth.py | 30 ++ .../backend/app/schemas/dashboard.py | 32 ++ salesflow-saas/backend/app/schemas/deal.py | 58 +++ salesflow-saas/backend/app/schemas/lead.py | 50 +++ .../backend/app/services/__init__.py | 0 salesflow-saas/backend/app/utils/__init__.py | 0 salesflow-saas/backend/app/utils/hijri.py | 31 ++ .../backend/app/utils/localization.py | 34 ++ salesflow-saas/backend/app/utils/security.py | 38 ++ .../backend/app/workers/__init__.py | 0 .../backend/app/workers/celery_app.py | 44 ++ .../backend/app/workers/follow_up_tasks.py | 19 + .../backend/app/workers/message_tasks.py | 35 ++ .../backend/app/workers/notification_tasks.py | 18 + salesflow-saas/backend/requirements.txt | 21 + salesflow-saas/backend/tests/conftest.py | 10 + salesflow-saas/docker-compose.yml | 97 +++++ salesflow-saas/frontend/Dockerfile | 18 + salesflow-saas/frontend/next.config.js | 6 + salesflow-saas/frontend/package.json | 26 ++ salesflow-saas/frontend/postcss.config.js | 6 + salesflow-saas/frontend/public/favicon.svg | 13 + salesflow-saas/frontend/public/logo.svg | 20 + salesflow-saas/frontend/src/app/globals.css | 28 ++ salesflow-saas/frontend/src/app/layout.tsx | 23 + salesflow-saas/frontend/src/app/page.tsx | 393 ++++++++++++++++++ salesflow-saas/frontend/tailwind.config.js | 60 +++ salesflow-saas/frontend/tsconfig.json | 21 + salesflow-saas/nginx/nginx.conf | 53 +++ salesflow-saas/seeds/healthcare_template.json | 95 +++++ salesflow-saas/seeds/realestate_template.json | 139 +++++++ 70 files changed, 2803 insertions(+) create mode 100644 salesflow-saas/.env.example create mode 100644 salesflow-saas/.gitignore create mode 100644 salesflow-saas/Makefile create mode 100644 salesflow-saas/backend/Dockerfile create mode 100644 salesflow-saas/backend/alembic.ini create mode 100644 salesflow-saas/backend/app/__init__.py create mode 100644 salesflow-saas/backend/app/api/__init__.py create mode 100644 salesflow-saas/backend/app/api/deps.py create mode 100644 salesflow-saas/backend/app/api/v1/__init__.py create mode 100644 salesflow-saas/backend/app/api/v1/auth.py create mode 100644 salesflow-saas/backend/app/api/v1/dashboard.py create mode 100644 salesflow-saas/backend/app/api/v1/deals.py create mode 100644 salesflow-saas/backend/app/api/v1/leads.py create mode 100644 salesflow-saas/backend/app/api/v1/router.py create mode 100644 salesflow-saas/backend/app/api/v1/tenants.py create mode 100644 salesflow-saas/backend/app/api/v1/users.py create mode 100644 salesflow-saas/backend/app/config.py create mode 100644 salesflow-saas/backend/app/database.py create mode 100644 salesflow-saas/backend/app/integrations/__init__.py create mode 100644 salesflow-saas/backend/app/integrations/email_sender.py create mode 100644 salesflow-saas/backend/app/integrations/sms.py create mode 100644 salesflow-saas/backend/app/integrations/whatsapp.py create mode 100644 salesflow-saas/backend/app/main.py create mode 100644 salesflow-saas/backend/app/models/__init__.py create mode 100644 salesflow-saas/backend/app/models/activity.py create mode 100644 salesflow-saas/backend/app/models/audit_log.py create mode 100644 salesflow-saas/backend/app/models/base.py create mode 100644 salesflow-saas/backend/app/models/customer.py create mode 100644 salesflow-saas/backend/app/models/deal.py create mode 100644 salesflow-saas/backend/app/models/lead.py create mode 100644 salesflow-saas/backend/app/models/message.py create mode 100644 salesflow-saas/backend/app/models/notification.py create mode 100644 salesflow-saas/backend/app/models/property.py create mode 100644 salesflow-saas/backend/app/models/proposal.py create mode 100644 salesflow-saas/backend/app/models/subscription.py create mode 100644 salesflow-saas/backend/app/models/template.py create mode 100644 salesflow-saas/backend/app/models/tenant.py create mode 100644 salesflow-saas/backend/app/models/user.py create mode 100644 salesflow-saas/backend/app/schemas/__init__.py create mode 100644 salesflow-saas/backend/app/schemas/auth.py create mode 100644 salesflow-saas/backend/app/schemas/dashboard.py create mode 100644 salesflow-saas/backend/app/schemas/deal.py create mode 100644 salesflow-saas/backend/app/schemas/lead.py create mode 100644 salesflow-saas/backend/app/services/__init__.py create mode 100644 salesflow-saas/backend/app/utils/__init__.py create mode 100644 salesflow-saas/backend/app/utils/hijri.py create mode 100644 salesflow-saas/backend/app/utils/localization.py create mode 100644 salesflow-saas/backend/app/utils/security.py create mode 100644 salesflow-saas/backend/app/workers/__init__.py create mode 100644 salesflow-saas/backend/app/workers/celery_app.py create mode 100644 salesflow-saas/backend/app/workers/follow_up_tasks.py create mode 100644 salesflow-saas/backend/app/workers/message_tasks.py create mode 100644 salesflow-saas/backend/app/workers/notification_tasks.py create mode 100644 salesflow-saas/backend/requirements.txt create mode 100644 salesflow-saas/backend/tests/conftest.py create mode 100644 salesflow-saas/docker-compose.yml create mode 100644 salesflow-saas/frontend/Dockerfile create mode 100644 salesflow-saas/frontend/next.config.js create mode 100644 salesflow-saas/frontend/package.json create mode 100644 salesflow-saas/frontend/postcss.config.js create mode 100644 salesflow-saas/frontend/public/favicon.svg create mode 100644 salesflow-saas/frontend/public/logo.svg create mode 100644 salesflow-saas/frontend/src/app/globals.css create mode 100644 salesflow-saas/frontend/src/app/layout.tsx create mode 100644 salesflow-saas/frontend/src/app/page.tsx create mode 100644 salesflow-saas/frontend/tailwind.config.js create mode 100644 salesflow-saas/frontend/tsconfig.json create mode 100644 salesflow-saas/nginx/nginx.conf create mode 100644 salesflow-saas/seeds/healthcare_template.json create mode 100644 salesflow-saas/seeds/realestate_template.json diff --git a/salesflow-saas/.env.example b/salesflow-saas/.env.example new file mode 100644 index 00000000..ceb3a706 --- /dev/null +++ b/salesflow-saas/.env.example @@ -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 diff --git a/salesflow-saas/.gitignore b/salesflow-saas/.gitignore new file mode 100644 index 00000000..384146bb --- /dev/null +++ b/salesflow-saas/.gitignore @@ -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/ diff --git a/salesflow-saas/Makefile b/salesflow-saas/Makefile new file mode 100644 index 00000000..aef1173c --- /dev/null +++ b/salesflow-saas/Makefile @@ -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 diff --git a/salesflow-saas/backend/Dockerfile b/salesflow-saas/backend/Dockerfile new file mode 100644 index 00000000..27510ee4 --- /dev/null +++ b/salesflow-saas/backend/Dockerfile @@ -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"] diff --git a/salesflow-saas/backend/alembic.ini b/salesflow-saas/backend/alembic.ini new file mode 100644 index 00000000..9d82afc3 --- /dev/null +++ b/salesflow-saas/backend/alembic.ini @@ -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 diff --git a/salesflow-saas/backend/app/__init__.py b/salesflow-saas/backend/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/salesflow-saas/backend/app/api/__init__.py b/salesflow-saas/backend/app/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/salesflow-saas/backend/app/api/deps.py b/salesflow-saas/backend/app/api/deps.py new file mode 100644 index 00000000..3a6a76ac --- /dev/null +++ b/salesflow-saas/backend/app/api/deps.py @@ -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 diff --git a/salesflow-saas/backend/app/api/v1/__init__.py b/salesflow-saas/backend/app/api/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/salesflow-saas/backend/app/api/v1/auth.py b/salesflow-saas/backend/app/api/v1/auth.py new file mode 100644 index 00000000..9ce99a31 --- /dev/null +++ b/salesflow-saas/backend/app/api/v1/auth.py @@ -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, + ) diff --git a/salesflow-saas/backend/app/api/v1/dashboard.py b/salesflow-saas/backend/app/api/v1/dashboard.py new file mode 100644 index 00000000..919b9ee9 --- /dev/null +++ b/salesflow-saas/backend/app/api/v1/dashboard.py @@ -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) diff --git a/salesflow-saas/backend/app/api/v1/deals.py b/salesflow-saas/backend/app/api/v1/deals.py new file mode 100644 index 00000000..18a1088c --- /dev/null +++ b/salesflow-saas/backend/app/api/v1/deals.py @@ -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) diff --git a/salesflow-saas/backend/app/api/v1/leads.py b/salesflow-saas/backend/app/api/v1/leads.py new file mode 100644 index 00000000..8a0990a9 --- /dev/null +++ b/salesflow-saas/backend/app/api/v1/leads.py @@ -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) diff --git a/salesflow-saas/backend/app/api/v1/router.py b/salesflow-saas/backend/app/api/v1/router.py new file mode 100644 index 00000000..ed9474a0 --- /dev/null +++ b/salesflow-saas/backend/app/api/v1/router.py @@ -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"]) diff --git a/salesflow-saas/backend/app/api/v1/tenants.py b/salesflow-saas/backend/app/api/v1/tenants.py new file mode 100644 index 00000000..57ae34e9 --- /dev/null +++ b/salesflow-saas/backend/app/api/v1/tenants.py @@ -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) diff --git a/salesflow-saas/backend/app/api/v1/users.py b/salesflow-saas/backend/app/api/v1/users.py new file mode 100644 index 00000000..cfa0435e --- /dev/null +++ b/salesflow-saas/backend/app/api/v1/users.py @@ -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() diff --git a/salesflow-saas/backend/app/config.py b/salesflow-saas/backend/app/config.py new file mode 100644 index 00000000..77f11519 --- /dev/null +++ b/salesflow-saas/backend/app/config.py @@ -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() diff --git a/salesflow-saas/backend/app/database.py b/salesflow-saas/backend/app/database.py new file mode 100644 index 00000000..5e89d4d7 --- /dev/null +++ b/salesflow-saas/backend/app/database.py @@ -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() diff --git a/salesflow-saas/backend/app/integrations/__init__.py b/salesflow-saas/backend/app/integrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/salesflow-saas/backend/app/integrations/email_sender.py b/salesflow-saas/backend/app/integrations/email_sender.py new file mode 100644 index 00000000..bca912e9 --- /dev/null +++ b/salesflow-saas/backend/app/integrations/email_sender.py @@ -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)} diff --git a/salesflow-saas/backend/app/integrations/sms.py b/salesflow-saas/backend/app/integrations/sms.py new file mode 100644 index 00000000..589de6c3 --- /dev/null +++ b/salesflow-saas/backend/app/integrations/sms.py @@ -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")} diff --git a/salesflow-saas/backend/app/integrations/whatsapp.py b/salesflow-saas/backend/app/integrations/whatsapp.py new file mode 100644 index 00000000..48a1b1d1 --- /dev/null +++ b/salesflow-saas/backend/app/integrations/whatsapp.py @@ -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() diff --git a/salesflow-saas/backend/app/main.py b/salesflow-saas/backend/app/main.py new file mode 100644 index 00000000..4ab0db0b --- /dev/null +++ b/salesflow-saas/backend/app/main.py @@ -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", + } diff --git a/salesflow-saas/backend/app/models/__init__.py b/salesflow-saas/backend/app/models/__init__.py new file mode 100644 index 00000000..2b47f755 --- /dev/null +++ b/salesflow-saas/backend/app/models/__init__.py @@ -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", +] diff --git a/salesflow-saas/backend/app/models/activity.py b/salesflow-saas/backend/app/models/activity.py new file mode 100644 index 00000000..fb07be60 --- /dev/null +++ b/salesflow-saas/backend/app/models/activity.py @@ -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") diff --git a/salesflow-saas/backend/app/models/audit_log.py b/salesflow-saas/backend/app/models/audit_log.py new file mode 100644 index 00000000..d2df23a9 --- /dev/null +++ b/salesflow-saas/backend/app/models/audit_log.py @@ -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) diff --git a/salesflow-saas/backend/app/models/base.py b/salesflow-saas/backend/app/models/base.py new file mode 100644 index 00000000..2e59bec1 --- /dev/null +++ b/salesflow-saas/backend/app/models/base.py @@ -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) diff --git a/salesflow-saas/backend/app/models/customer.py b/salesflow-saas/backend/app/models/customer.py new file mode 100644 index 00000000..85de87d6 --- /dev/null +++ b/salesflow-saas/backend/app/models/customer.py @@ -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") diff --git a/salesflow-saas/backend/app/models/deal.py b/salesflow-saas/backend/app/models/deal.py new file mode 100644 index 00000000..15cc32a2 --- /dev/null +++ b/salesflow-saas/backend/app/models/deal.py @@ -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") diff --git a/salesflow-saas/backend/app/models/lead.py b/salesflow-saas/backend/app/models/lead.py new file mode 100644 index 00000000..4c457b32 --- /dev/null +++ b/salesflow-saas/backend/app/models/lead.py @@ -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") diff --git a/salesflow-saas/backend/app/models/message.py b/salesflow-saas/backend/app/models/message.py new file mode 100644 index 00000000..31c67e24 --- /dev/null +++ b/salesflow-saas/backend/app/models/message.py @@ -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") diff --git a/salesflow-saas/backend/app/models/notification.py b/salesflow-saas/backend/app/models/notification.py new file mode 100644 index 00000000..b7bcc026 --- /dev/null +++ b/salesflow-saas/backend/app/models/notification.py @@ -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) diff --git a/salesflow-saas/backend/app/models/property.py b/salesflow-saas/backend/app/models/property.py new file mode 100644 index 00000000..4a597c2f --- /dev/null +++ b/salesflow-saas/backend/app/models/property.py @@ -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]) diff --git a/salesflow-saas/backend/app/models/proposal.py b/salesflow-saas/backend/app/models/proposal.py new file mode 100644 index 00000000..1fda88f4 --- /dev/null +++ b/salesflow-saas/backend/app/models/proposal.py @@ -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") diff --git a/salesflow-saas/backend/app/models/subscription.py b/salesflow-saas/backend/app/models/subscription.py new file mode 100644 index 00000000..c427ece5 --- /dev/null +++ b/salesflow-saas/backend/app/models/subscription.py @@ -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) diff --git a/salesflow-saas/backend/app/models/template.py b/salesflow-saas/backend/app/models/template.py new file mode 100644 index 00000000..d669eef2 --- /dev/null +++ b/salesflow-saas/backend/app/models/template.py @@ -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) diff --git a/salesflow-saas/backend/app/models/tenant.py b/salesflow-saas/backend/app/models/tenant.py new file mode 100644 index 00000000..42c6b642 --- /dev/null +++ b/salesflow-saas/backend/app/models/tenant.py @@ -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") diff --git a/salesflow-saas/backend/app/models/user.py b/salesflow-saas/backend/app/models/user.py new file mode 100644 index 00000000..687dc638 --- /dev/null +++ b/salesflow-saas/backend/app/models/user.py @@ -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") diff --git a/salesflow-saas/backend/app/schemas/__init__.py b/salesflow-saas/backend/app/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/salesflow-saas/backend/app/schemas/auth.py b/salesflow-saas/backend/app/schemas/auth.py new file mode 100644 index 00000000..d4d76784 --- /dev/null +++ b/salesflow-saas/backend/app/schemas/auth.py @@ -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 diff --git a/salesflow-saas/backend/app/schemas/dashboard.py b/salesflow-saas/backend/app/schemas/dashboard.py new file mode 100644 index 00000000..b580951d --- /dev/null +++ b/salesflow-saas/backend/app/schemas/dashboard.py @@ -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 diff --git a/salesflow-saas/backend/app/schemas/deal.py b/salesflow-saas/backend/app/schemas/deal.py new file mode 100644 index 00000000..d2de893a --- /dev/null +++ b/salesflow-saas/backend/app/schemas/deal.py @@ -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 diff --git a/salesflow-saas/backend/app/schemas/lead.py b/salesflow-saas/backend/app/schemas/lead.py new file mode 100644 index 00000000..9d660ff8 --- /dev/null +++ b/salesflow-saas/backend/app/schemas/lead.py @@ -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 diff --git a/salesflow-saas/backend/app/services/__init__.py b/salesflow-saas/backend/app/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/salesflow-saas/backend/app/utils/__init__.py b/salesflow-saas/backend/app/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/salesflow-saas/backend/app/utils/hijri.py b/salesflow-saas/backend/app/utils/hijri.py new file mode 100644 index 00000000..c0fb921e --- /dev/null +++ b/salesflow-saas/backend/app/utils/hijri.py @@ -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 diff --git a/salesflow-saas/backend/app/utils/localization.py b/salesflow-saas/backend/app/utils/localization.py new file mode 100644 index 00000000..6ac4c8b3 --- /dev/null +++ b/salesflow-saas/backend/app/utils/localization.py @@ -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) diff --git a/salesflow-saas/backend/app/utils/security.py b/salesflow-saas/backend/app/utils/security.py new file mode 100644 index 00000000..4b145b13 --- /dev/null +++ b/salesflow-saas/backend/app/utils/security.py @@ -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 diff --git a/salesflow-saas/backend/app/workers/__init__.py b/salesflow-saas/backend/app/workers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/salesflow-saas/backend/app/workers/celery_app.py b/salesflow-saas/backend/app/workers/celery_app.py new file mode 100644 index 00000000..ee614b0b --- /dev/null +++ b/salesflow-saas/backend/app/workers/celery_app.py @@ -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, + }, + }, +} diff --git a/salesflow-saas/backend/app/workers/follow_up_tasks.py b/salesflow-saas/backend/app/workers/follow_up_tasks.py new file mode 100644 index 00000000..b16d5bcb --- /dev/null +++ b/salesflow-saas/backend/app/workers/follow_up_tasks.py @@ -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 diff --git a/salesflow-saas/backend/app/workers/message_tasks.py b/salesflow-saas/backend/app/workers/message_tasks.py new file mode 100644 index 00000000..ec920857 --- /dev/null +++ b/salesflow-saas/backend/app/workers/message_tasks.py @@ -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 diff --git a/salesflow-saas/backend/app/workers/notification_tasks.py b/salesflow-saas/backend/app/workers/notification_tasks.py new file mode 100644 index 00000000..3b0c04a1 --- /dev/null +++ b/salesflow-saas/backend/app/workers/notification_tasks.py @@ -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 diff --git a/salesflow-saas/backend/requirements.txt b/salesflow-saas/backend/requirements.txt new file mode 100644 index 00000000..641fafbc --- /dev/null +++ b/salesflow-saas/backend/requirements.txt @@ -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 diff --git a/salesflow-saas/backend/tests/conftest.py b/salesflow-saas/backend/tests/conftest.py new file mode 100644 index 00000000..05ff8eca --- /dev/null +++ b/salesflow-saas/backend/tests/conftest.py @@ -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 diff --git a/salesflow-saas/docker-compose.yml b/salesflow-saas/docker-compose.yml new file mode 100644 index 00000000..723f207e --- /dev/null +++ b/salesflow-saas/docker-compose.yml @@ -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: diff --git a/salesflow-saas/frontend/Dockerfile b/salesflow-saas/frontend/Dockerfile new file mode 100644 index 00000000..5a09b008 --- /dev/null +++ b/salesflow-saas/frontend/Dockerfile @@ -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"] diff --git a/salesflow-saas/frontend/next.config.js b/salesflow-saas/frontend/next.config.js new file mode 100644 index 00000000..c10e07d9 --- /dev/null +++ b/salesflow-saas/frontend/next.config.js @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: "standalone", +}; + +module.exports = nextConfig; diff --git a/salesflow-saas/frontend/package.json b/salesflow-saas/frontend/package.json new file mode 100644 index 00000000..3aa4f790 --- /dev/null +++ b/salesflow-saas/frontend/package.json @@ -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" + } +} diff --git a/salesflow-saas/frontend/postcss.config.js b/salesflow-saas/frontend/postcss.config.js new file mode 100644 index 00000000..12a703d9 --- /dev/null +++ b/salesflow-saas/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/salesflow-saas/frontend/public/favicon.svg b/salesflow-saas/frontend/public/favicon.svg new file mode 100644 index 00000000..ae05621b --- /dev/null +++ b/salesflow-saas/frontend/public/favicon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/salesflow-saas/frontend/public/logo.svg b/salesflow-saas/frontend/public/logo.svg new file mode 100644 index 00000000..f532cca7 --- /dev/null +++ b/salesflow-saas/frontend/public/logo.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + diff --git a/salesflow-saas/frontend/src/app/globals.css b/salesflow-saas/frontend/src/app/globals.css new file mode 100644 index 00000000..f7edd2f1 --- /dev/null +++ b/salesflow-saas/frontend/src/app/globals.css @@ -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; + } +} diff --git a/salesflow-saas/frontend/src/app/layout.tsx b/salesflow-saas/frontend/src/app/layout.tsx new file mode 100644 index 00000000..429bf704 --- /dev/null +++ b/salesflow-saas/frontend/src/app/layout.tsx @@ -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 ( + + + + + {children} + + ); +} diff --git a/salesflow-saas/frontend/src/app/page.tsx b/salesflow-saas/frontend/src/app/page.tsx new file mode 100644 index 00000000..97b3d49b --- /dev/null +++ b/salesflow-saas/frontend/src/app/page.tsx @@ -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 ( +
+ {/* Navigation */} + + + {/* Hero Section */} +
+
+
+
+
+ + صنع في السعودية للسوق السعودي +
+

+ حوّل مبيعاتك إلى +
+ + ماكينة أرباح تعمل 24/7 + +

+

+ منصة ذكاء اصطناعي تدير عملاءك، تتابعهم تلقائياً، وتغلق الصفقات بدون تدخل. مصممة للشركات الصغيرة والمتوسطة. +

+ +

بدون بطاقة ائتمان • إلغاء أي وقت

+
+ + {/* Dashboard Mockup */} +
+
+
+
+
+
+
+
+ {[ + { 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) => ( +
+
{stat.value}
+
{stat.label}
+
+ ))} +
+
+ {["جديد", "تم التواصل", "موعد محجوز", "عرض سعر", "تم الإغلاق"].map((stage, i) => ( +
+
{stage}
+ {Array.from({ length: 3 - Math.floor(i * 0.5) }).map((_, j) => ( +
+ ))} +
+ ))} +
+
+
+
+
+ + {/* Pain Points */} +
+
+
+ {painPoints.map((p, i) => ( +
+
{p.emoji}
+

{p.text}

+

SalesMatic يحل هذي المشكلة

+
+ ))} +
+
+
+ + {/* Features */} +
+
+
+

كل اللي تحتاجه لزيادة مبيعاتك

+

منصة متكاملة تجمع كل أدوات المبيعات في مكان واحد

+
+
+ {features.map((f, i) => ( +
+
+ +
+

{f.title}

+

{f.titleEn}

+

{f.desc}

+
+ ))} +
+
+
+ + {/* How It Works */} +
+
+
+

ابدأ في 3 خطوات

+

من التسجيل إلى أول صفقة في دقائق

+
+
+ {steps.map((s, i) => ( +
+
+ +
+
الخطوة {s.num}
+

{s.title}

+

{s.desc}

+
+ ))} +
+
+
+ + {/* Industry Templates */} +
+
+
+

قوالب جاهزة لقطاعك

+

اختر قالب قطاعك وابدأ فوراً

+
+
+
+ +

العيادات والصحة

+

إدارة المرضى والمواعيد والمتابعة

+
متاح الآن
+
+
+ +

عقارات الرياض

+

عقارات، جولات، عروض، أحياء الرياض

+
متاح الآن
+
+
+ +

المقاولات

+

إدارة المشاريع والعملاء

+
قريباً
+
+
+ +

الصالونات

+

حجوزات ومتابعة العملاء

+
قريباً
+
+
+
+
+ + {/* Stats */} +
+
+
+ {stats.map((s, i) => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+
+
+ + {/* Pricing */} +
+
+
+

خطط تناسب حجم شركتك

+

ابدأ مجاناً 14 يوم • بدون بطاقة ائتمان

+
+
+ {plans.map((plan, i) => ( +
+ {plan.popular && ( +
+ الأكثر شعبية +
+ )} +
+

{plan.name}

+

{plan.nameEn}

+
+ {plan.price} + ر.س/شهر +
+
+
    + {plan.features.map((f, j) => ( +
  • + + {f} +
  • + ))} +
+ + ابدأ تجربة مجانية + +
+ ))} +
+
+
+ + {/* FAQ */} +
+
+
+

أسئلة شائعة

+
+
+ {faqs.map((faq, i) => ( +
+ + {faq.q} + + +
{faq.a}
+
+ ))} +
+
+
+ + {/* Final CTA */} +
+
+

جاهز تزيد مبيعاتك؟

+

انضم لمئات الشركات اللي زادت مبيعاتها مع SalesMatic

+
+ + ابدأ مجاناً الآن + + + + تواصل عبر واتساب + +
+
+
+ + {/* Footer */} + +
+ ); +} diff --git a/salesflow-saas/frontend/tailwind.config.js b/salesflow-saas/frontend/tailwind.config.js new file mode 100644 index 00000000..a96067f8 --- /dev/null +++ b/salesflow-saas/frontend/tailwind.config.js @@ -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: [], +}; diff --git a/salesflow-saas/frontend/tsconfig.json b/salesflow-saas/frontend/tsconfig.json new file mode 100644 index 00000000..fba2bf37 --- /dev/null +++ b/salesflow-saas/frontend/tsconfig.json @@ -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"] +} diff --git a/salesflow-saas/nginx/nginx.conf b/salesflow-saas/nginx/nginx.conf new file mode 100644 index 00000000..ead50fe7 --- /dev/null +++ b/salesflow-saas/nginx/nginx.conf @@ -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; + } + } +} diff --git a/salesflow-saas/seeds/healthcare_template.json b/salesflow-saas/seeds/healthcare_template.json new file mode 100644 index 00000000..ca662557 --- /dev/null +++ b/salesflow-saas/seeds/healthcare_template.json @@ -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} + ] + } + ] +} diff --git a/salesflow-saas/seeds/realestate_template.json b/salesflow-saas/seeds/realestate_template.json new file mode 100644 index 00000000..738ea38f --- /dev/null +++ b/salesflow-saas/seeds/realestate_template.json @@ -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"} + ] +}