From dc28be8ad3258627b956cba7f85c6eaa5315190f Mon Sep 17 00:00:00 2001 From: aryawadhwa Date: Mon, 26 Jan 2026 21:20:54 +0530 Subject: [PATCH 01/31] feat: Add dynamic workflow visualization, contacts page, and calendar integration - Add ContactHistory model and API endpoints for tracking contacted leads - Add /contacts page with contact history display - Add dynamic workflow visualization in Spatial Mission Control - Agents appear dynamically as user types mission objective - Shows Lead Researcher, Email Copywriter, Campaign Manager based on keywords - Add contacts link to navigation sidebar - Calendar already connected to backend (missions, drafts, email timeline) - Add frontend API methods for contact history management - Add HeroInput minimize toggle functionality - Various UI improvements to Launchpad and MissionMap components --- CODE_REVIEW_SUMMARY.md | 226 ++++++++++++++++++ FONT_IMPORT_GUIDE.md | 131 ++++++++++ GALGO_FONT_INSTALL.md | 109 +++++++++ backend/app/core/socket.py | 46 ++++ backend/app/models.py | 85 +++++++ backend/app/routers/contacts.py | 130 ++++++++++ backend/app/routers/timeline.py | 133 +++++++++++ backend/check_permissions.py | 18 ++ backend/main.py | 13 +- backend/secrets.env | 7 + backend/testfile.txt | 1 + frontend/public/fonts/GalgoVF.woff | Bin 0 -> 35836 bytes frontend/public/fonts/GalgoVF.woff2 | Bin 0 -> 28120 bytes frontend/src/App.tsx | 216 +++++++++-------- frontend/src/components/ErrorBoundary.tsx | 52 ++++ .../src/components/launchpad/HeroInput.tsx | 62 ++++- .../components/layout/NavigationSidebar.tsx | 2 + .../components/mission-control/MissionMap.tsx | 206 ++++++++++++---- .../src/components/ui/InteractiveDots.tsx | 106 ++++++++ frontend/src/components/ui/LampContainer.tsx | 87 +++++++ frontend/src/lib/api.ts | 80 +++++++ frontend/src/pages/ActiveAgents.tsx | 23 +- frontend/src/pages/Calendar.tsx | 157 ++++++++---- frontend/src/pages/ContactHistory.tsx | 189 +++++++++++++++ frontend/src/pages/History.tsx | 96 ++++++++ frontend/src/pages/Launchpad.tsx | 2 +- frontend/src/pages/Profile.tsx | 114 +++++++++ frontend/src/pages/ReviewQueue.tsx | 2 +- start_dev.sh | 30 +++ tour.py | 48 ++++ 30 files changed, 2163 insertions(+), 208 deletions(-) create mode 100644 CODE_REVIEW_SUMMARY.md create mode 100644 FONT_IMPORT_GUIDE.md create mode 100644 GALGO_FONT_INSTALL.md create mode 100644 backend/app/core/socket.py create mode 100644 backend/app/routers/contacts.py create mode 100644 backend/app/routers/timeline.py create mode 100644 backend/check_permissions.py create mode 100644 backend/secrets.env create mode 100644 backend/testfile.txt create mode 100644 frontend/public/fonts/GalgoVF.woff create mode 100644 frontend/public/fonts/GalgoVF.woff2 create mode 100644 frontend/src/components/ErrorBoundary.tsx create mode 100644 frontend/src/components/ui/InteractiveDots.tsx create mode 100644 frontend/src/components/ui/LampContainer.tsx create mode 100644 frontend/src/pages/ContactHistory.tsx create mode 100644 frontend/src/pages/History.tsx create mode 100644 frontend/src/pages/Profile.tsx create mode 100755 start_dev.sh create mode 100644 tour.py diff --git a/CODE_REVIEW_SUMMARY.md b/CODE_REVIEW_SUMMARY.md new file mode 100644 index 0000000..d7e1ecf --- /dev/null +++ b/CODE_REVIEW_SUMMARY.md @@ -0,0 +1,226 @@ +# ExpediteAI - Complete Code Review & Changes Summary + +## ✅ All Completed Changes + +### 1. **Branding Update: OutboundAI → ExpediteAI** + +#### Files Modified: +- ✅ `frontend/src/pages/Landing.tsx` + - Navbar logo + - Hero headline: "Expedite Engine" + - About section + - Footer + - Email: hello@expediteai.com + - Social handles: @expediteai + - "future of sales automation" + +- ✅ `frontend/src/components/layout/AppLayout.tsx` + - Sidebar logo + +- ✅ `frontend/index.html` + - Page title + - Meta tags + +- ✅ `frontend/src/pages/Settings.tsx` + - "AI expedite machine" + +- ✅ `frontend/src/pages/MissionChat.tsx` + - "expedite mission" placeholders + +- ✅ `frontend/src/components/layout/NavigationSidebar.tsx` + - "Expedite" label + +- ✅ `frontend/src/components/OnboardingTour.tsx` + - localStorage keys + +--- + +### 2. **Font System** + +#### Current Font: **Space Grotesk** +```css +@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap'); + +html { + font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + letter-spacing: 0.02em; /* Increased for readability */ + font-size: 16px; /* Base size */ +} +``` + +**Improvements Made:** +- ✅ Better font rendering with grayscale smoothing +- ✅ Optimized legibility +- ✅ Increased letter-spacing (0.02em) +- ✅ Larger base font-size (16px) + +--- + +### 3. **Lamp Container Optimizations** + +#### Mobile Optimizations: +```css +/* Beam opacity: 30% mobile → 70% desktop */ +opacity-30 md:opacity-70 + +/* Glow effects: 20-40% mobile → 40-60% desktop */ +opacity-20 md:opacity-40 +``` + +#### Size Increases: +- Beam width: 30rem → **50rem** (66% wider) +- Beam height: h-56 → **h-72** (taller) +- Glow width: 28rem → **40rem** +- Center line: 30rem → **50rem** + +#### Positioning: +- Added `-mt-32` to hide lamp source behind navbar +- Light emanates from behind, creating dramatic effect + +--- + +### 4. **Mobile Responsiveness** + +#### Navigation: +```tsx +- Hidden "About" and "Contact" on mobile (hidden sm:block) +- Hidden "Sign In" button on mobile +- Responsive padding: px-4 sm:px-6 +- Smaller text: text-lg sm:text-xl +``` + +#### Hero Section: +```tsx +- Responsive text: text-4xl sm:text-5xl md:text-7xl lg:text-8xl +- Full-width buttons on mobile +- Reduced spacing: mb-6 sm:mb-8 +- Max-width constraints: max-w-5xl +``` + +#### Sections: +```tsx +- Padding: py-16 sm:py-24 md:py-32 +- Horizontal: px-4 sm:px-6 +``` + +#### Interactive Dots: +- Mobile: 20 rows × 10 columns (smaller dots) +- Desktop: 15 rows × 20 columns (larger effect) + +--- + +### 5. **Custom CSS Overrides** + +```css +/* Taller hero section */ +.min-h-screen { + min-height: 127vh; +} + +/* Larger headings on medium screens */ +@media (min-width: 768px) { + .md\:text-7xl { + font-size: 5.5rem; + line-height: 1; + } +} +``` + +--- + +### 6. **Color Scheme Unification** + +#### Review Queue: +- ✅ Reject button: `variant="destructive"` (red) +- ✅ Approve button: `bg-primary` with `glow-primary` (cyan) + +#### Email Editor: +- ✅ Input backgrounds: `glass-card` (spatial theme) +- ✅ Borders: `border-white/[0.08]` +- ✅ Badge: `bg-primary/10 text-primary` (cyan) + +--- + +## 📁 File Structure + +``` +frontend/ +├── public/ +│ └── fonts/ # Galgo font files (available but not active) +├── src/ +│ ├── components/ +│ │ ├── layout/ +│ │ │ ├── AppLayout.tsx ✅ Updated +│ │ │ └── NavigationSidebar.tsx ✅ Updated +│ │ ├── review/ +│ │ │ └── EmailEditor.tsx ✅ Updated +│ │ ├── ui/ +│ │ │ ├── LampContainer.tsx ✅ Optimized +│ │ │ └── InteractiveDots.tsx ✅ Created +│ │ └── OnboardingTour.tsx ✅ Updated +│ ├── pages/ +│ │ ├── Landing.tsx ✅ Updated +│ │ ├── ReviewQueue.tsx ✅ Updated +│ │ ├── Settings.tsx ✅ Updated +│ │ └── MissionChat.tsx ✅ Updated +│ └── index.css ✅ Updated +└── index.html ✅ Updated +``` + +--- + +## 🎨 Design System + +### Spatial Computing Theme: +- **Depth Layers**: Far (220 20% 8%), Mid (220 18% 12%), Near (220 16% 16%) +- **Primary**: Cyan (210 100% 60%) +- **Secondary**: Purple (280 60% 65%) +- **Accent**: Bright Cyan (180 100% 50%) + +### Typography: +- **Font**: Space Grotesk (400, 500, 600, 700) +- **Letter-spacing**: 0.02em +- **Base size**: 16px + +### Effects: +- **Glow**: Primary, Secondary, Accent glows +- **Shadows**: 4 depth layers +- **Animations**: Spring physics, smooth transitions + +--- + +## 🚀 Performance + +### Optimizations: +- ✅ Reduced lamp glow on mobile (better performance) +- ✅ Fewer interactive dots on mobile +- ✅ Optimized font rendering +- ✅ Proper z-index layering + +--- + +## ✨ Key Features + +1. **Dramatic Lamp Effect** - Hidden source, visible glow +2. **Interactive Dots Background** - Hover-reactive circles +3. **Spatial Computing Design** - Depth, glow, and shadows +4. **Full Mobile Optimization** - Responsive across all devices +5. **Unified Branding** - ExpediteAI throughout +6. **Premium Typography** - Space Grotesk with improved spacing + +--- + +## 📝 Notes + +- All "outbound" references removed +- Galgo Condensed font files available in `/public/fonts/` if needed +- Font rendering optimized for all browsers +- Spatial computing aesthetic maintained throughout + +--- + +**Last Updated**: 2026-01-23 +**Status**: ✅ All changes complete and verified diff --git a/FONT_IMPORT_GUIDE.md b/FONT_IMPORT_GUIDE.md new file mode 100644 index 0000000..5b1f1c8 --- /dev/null +++ b/FONT_IMPORT_GUIDE.md @@ -0,0 +1,131 @@ +# How to Import Custom Fonts in Your Web App + +## Method 1: Google Fonts (Current Method) + +This is what we're currently using for Space Grotesk: + +```css +/* In index.css */ +@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap'); + +html { + font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; +} +``` + +### To Change to a Different Google Font: + +1. **Visit Google Fonts**: https://fonts.google.com/ +2. **Select a font** (e.g., "Archivo Black" for brutalist, "Bebas Neue" for bold) +3. **Click "Get font"** then "Get embed code" +4. **Copy the @import URL** +5. **Replace line 1 in `index.css`** with your new import +6. **Update the font-family** on line 82 + +Example with Bebas Neue (very brutalist): +```css +@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap'); + +html { + font-family: 'Bebas Neue', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; +} +``` + +--- + +## Method 2: Custom Font Files (Self-Hosted) + +For fonts not on Google Fonts: + +### Step 1: Add font files to your project +``` +frontend/ + public/ + fonts/ + YourFont-Regular.woff2 + YourFont-Bold.woff2 +``` + +### Step 2: Define @font-face in index.css +```css +@font-face { + font-family: 'YourFont'; + src: url('/fonts/YourFont-Regular.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'YourFont'; + src: url('/fonts/YourFont-Bold.woff2') format('woff2'); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +html { + font-family: 'YourFont', sans-serif; +} +``` + +--- + +## Method 3: Adobe Fonts / Typekit + +```html + + +``` + +```css +/* In index.css */ +html { + font-family: 'your-adobe-font', sans-serif; +} +``` + +--- + +## Method 4: Fonts from CDN (fonts.com, etc.) + +```html + + +``` + +--- + +## Popular Brutalist Fonts for Your Design: + +### Free (Google Fonts): +- **Bebas Neue** - Very bold, condensed, brutalist +- **Archivo Black** - Heavy, geometric +- **Oswald** - Bold, condensed +- **Anton** - Extra bold display +- **Barlow Condensed** - Geometric, strong + +### Premium Options: +- **Druk** - Classic brutalist +- **Neue Haas Grotesk** - Swiss brutalism +- **Suisse Int'l** - Modern brutalist +- **Monument Grotesk** - Geometric brutalist + +--- + +## Quick Font Change Guide: + +1. **Find your font** on Google Fonts or download it +2. **Copy the import URL** or add files to `/public/fonts/` +3. **Update `index.css` line 1** with the new @import +4. **Update `index.css` line 82** with the new font-family name +5. **Save and refresh** - Vite will hot-reload automatically! + +--- + +## Current Font Stack: +```css +font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; +``` + +The fallback fonts ensure the site works even if the custom font fails to load. diff --git a/GALGO_FONT_INSTALL.md b/GALGO_FONT_INSTALL.md new file mode 100644 index 0000000..72075e5 --- /dev/null +++ b/GALGO_FONT_INSTALL.md @@ -0,0 +1,109 @@ +# Installing Galgo Condensed Font - Step by Step + +## Step 1: Download the Font + +1. Download the trial font from: https://www.giuliaboggio.xyz/TRIALS/galgo%20condensed/Galgo_TRIAL.zip +2. Extract the ZIP file +3. You should see font files like: + - GalgoCondensed-Light.woff2 + - GalgoCondensed-Regular.woff2 + - GalgoCondensed-Bold.woff2 + (or .woff, .ttf, .otf formats) + +## Step 2: Create Fonts Folder + +Run this command in your terminal: +```bash +mkdir -p "/Users/aryawadhwa/Desktop/outbound ai/frontend/public/fonts" +``` + +## Step 3: Copy Font Files + +Copy all the font files from the downloaded folder to: +``` +/Users/aryawadhwa/Desktop/outbound ai/frontend/public/fonts/ +``` + +## Step 4: Update index.css + +Replace the first line of `/Users/aryawadhwa/Desktop/outbound ai/frontend/src/index.css`: + +**REMOVE THIS LINE (line 1):** +```css +@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap'); +``` + +**ADD THESE LINES AT THE TOP (before @tailwind):** +```css +/* Galgo Condensed - Brutalist Condensed Font */ +@font-face { + font-family: 'Galgo Condensed'; + src: url('/fonts/GalgoCondensed-Light.woff2') format('woff2'); + font-weight: 300; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Galgo Condensed'; + src: url('/fonts/GalgoCondensed-Regular.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Galgo Condensed'; + src: url('/fonts/GalgoCondensed-Bold.woff2') format('woff2'); + font-weight: 700; + font-style: normal; + font-display: swap; +} +``` + +## Step 5: Update Font Family + +Find this line (around line 82): +```css +font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; +``` + +**REPLACE WITH:** +```css +font-family: 'Galgo Condensed', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; +letter-spacing: -0.02em; /* Tighter spacing for condensed font */ +``` + +## Step 6: Save and Refresh + +1. Save the `index.css` file +2. The Vite dev server should auto-reload +3. Refresh your browser at http://localhost:8081 +4. You should see the Galgo Condensed font! + +--- + +## Troubleshooting: + +**If the font doesn't load:** + +1. Check the browser console (F12) for errors +2. Verify the font files are in `/public/fonts/` +3. Make sure the file names match exactly (case-sensitive!) +4. Try using `.woff` instead of `.woff2` if you have that format +5. Clear browser cache and hard refresh (Cmd+Shift+R on Mac) + +**Font file name variations:** +- If your files are named differently (e.g., `galgo-condensed-light.woff2`), update the `src:` URLs to match +- Example: `url('/fonts/galgo-condensed-light.woff2')` + +--- + +## Alternative: Use a CDN (if available) + +If Galgo Condensed has a CDN link, you can use: +```css +@import url('CDN_LINK_HERE'); +``` + +But for this font, self-hosting is the recommended approach. diff --git a/backend/app/core/socket.py b/backend/app/core/socket.py new file mode 100644 index 0000000..a2ca39d --- /dev/null +++ b/backend/app/core/socket.py @@ -0,0 +1,46 @@ +from fastapi import WebSocket +from typing import Dict, List +import json + +class ConnectionManager: + def __init__(self): + # Map user_id to list of websockets + self.user_connections: Dict[str, List[WebSocket]] = {} + + async def connect(self, websocket: WebSocket, user_id: str): + await websocket.accept() + if user_id not in self.user_connections: + self.user_connections[user_id] = [] + self.user_connections[user_id].append(websocket) + + def disconnect(self, websocket: WebSocket, user_id: str): + if user_id in self.user_connections: + self.user_connections[user_id].remove(websocket) + if not self.user_connections[user_id]: + del self.user_connections[user_id] + + async def send_to_user(self, user_id: str, message: dict): + """Send message to all connections of a specific user""" + if user_id in self.user_connections: + msg_str = json.dumps(message) + for ws in self.user_connections[user_id]: + try: + await ws.send_text(msg_str) + except: + pass + + async def broadcast(self, message: dict): + """Broadcast to all connected users""" + msg_str = json.dumps(message) + for user_id, connections in self.user_connections.items(): + for ws in connections: + try: + await ws.send_text(msg_str) + except: + pass + +# Global manager instance +manager = ConnectionManager() + +def get_connection_manager(): + return manager diff --git a/backend/app/models.py b/backend/app/models.py index 6fccd68..1abe5ca 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -95,3 +95,88 @@ class UserAsset(Document): class Settings: name = "user_assets" + +class EmailEventType(str, Enum): + SENT = "sent" + REPLY_RECEIVED = "reply_received" + FOLLOW_UP = "follow_up" + OPENED = "opened" + CLICKED = "clicked" + +class EmailEvent(BaseModel): + """Individual email event in a thread""" + type: EmailEventType + timestamp: datetime = Field(default_factory=datetime.utcnow) + email_id: str # Gmail message ID + subject: Optional[str] = None + preview: Optional[str] = None # First 100 chars of body + from_email: str + to_email: str + metadata: Dict = {} # Additional data (opened_at, clicked_link, etc.) + +class ThreadStatus(str, Enum): + ACTIVE = "active" + WAITING_REPLY = "waiting_reply" + REPLIED = "replied" + CLOSED = "closed" + +class EmailThread(Document): + """Tracks complete email conversation timeline""" + user_id: str # Link to User.clerk_id + mission_id: str # Link to Mission.id + prospect_id: str # Link to Prospect.id + thread_id: str # Gmail thread ID + + # Timeline of all events + events: List[EmailEvent] = [] + + # Current status + status: ThreadStatus = ThreadStatus.WAITING_REPLY + last_activity: datetime = Field(default_factory=datetime.utcnow) + + # Quick access fields + first_sent_at: Optional[datetime] = None + last_reply_at: Optional[datetime] = None + reply_count: int = 0 + + created_at: datetime = Field(default_factory=datetime.utcnow) + + class Settings: + name = "email_threads" + + +class ContactHistory(Document): + """Track all email communications to prevent duplicates""" + user_id: str # Link to User.clerk_id + prospect_email: str # Email address (normalized/lowercase) + prospect_name: Optional[str] = None + + # First contact info + first_contacted_at: datetime = Field(default_factory=datetime.utcnow) + first_mission_id: str # Mission that first contacted this person + + # Latest contact info + last_contacted_at: datetime = Field(default_factory=datetime.utcnow) + last_mission_id: str + + # Contact count + total_emails_sent: int = 1 + + # Email thread tracking + thread_ids: List[str] = [] # All Gmail thread IDs + + # Response tracking + has_replied: bool = False + last_reply_at: Optional[datetime] = None + + # Status + status: str = "contacted" # contacted, replied, bounced, unsubscribed + + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + + class Settings: + name = "contact_history" + indexes = [ + [("user_id", 1), ("prospect_email", 1)], # Unique per user+email + ] diff --git a/backend/app/routers/contacts.py b/backend/app/routers/contacts.py new file mode 100644 index 0000000..c1c8270 --- /dev/null +++ b/backend/app/routers/contacts.py @@ -0,0 +1,130 @@ +from fastapi import APIRouter, Depends, HTTPException +from typing import List +from datetime import datetime +from beanie.operators import In +from pydantic import BaseModel + +from app.models import ContactHistory, User +from app.core.auth import get_current_user + +router = APIRouter() + + +class CheckDuplicatesRequest(BaseModel): + emails: List[str] + + +class RecordContactRequest(BaseModel): + email: str + name: str = None + mission_id: str + thread_id: str = None + + +@router.get("/history") +async def get_contact_history( + skip: int = 0, + limit: int = 50, + user: User = Depends(get_current_user) +): + """Get all previously contacted prospects""" + contacts = await ContactHistory.find( + ContactHistory.user_id == user.clerk_id + ).sort(-ContactHistory.last_contacted_at).skip(skip).limit(limit).to_list() + + return contacts + + +@router.post("/check-duplicates") +async def check_duplicates( + request: CheckDuplicatesRequest, + user: User = Depends(get_current_user) +): + """Check which emails have been contacted before""" + normalized_emails = [email.lower().strip() for email in request.emails] + + existing = await ContactHistory.find( + In(ContactHistory.prospect_email, normalized_emails), + ContactHistory.user_id == user.clerk_id + ).to_list() + + existing_map = {c.prospect_email: c for c in existing} + + duplicates = [ + { + "email": email, + "first_contacted": existing_map[email].first_contacted_at.isoformat(), + "last_contacted": existing_map[email].last_contacted_at.isoformat(), + "total_emails": existing_map[email].total_emails_sent, + "has_replied": existing_map[email].has_replied, + "status": existing_map[email].status + } + for email in normalized_emails + if email in existing_map + ] + + new_contacts = [ + email for email in normalized_emails + if email not in existing_map + ] + + return { + "duplicates": duplicates, + "new_contacts": new_contacts + } + + +@router.post("/record") +async def record_contact( + request: RecordContactRequest, + user: User = Depends(get_current_user) +): + """Record a new email contact or update existing""" + email = request.email.lower().strip() + + existing = await ContactHistory.find_one( + ContactHistory.user_id == user.clerk_id, + ContactHistory.prospect_email == email + ) + + if existing: + # Update existing + existing.last_contacted_at = datetime.utcnow() + existing.last_mission_id = request.mission_id + existing.total_emails_sent += 1 + existing.updated_at = datetime.utcnow() + if request.thread_id and request.thread_id not in existing.thread_ids: + existing.thread_ids.append(request.thread_id) + await existing.save() + return existing + else: + # Create new + contact = ContactHistory( + user_id=user.clerk_id, + prospect_email=email, + prospect_name=request.name, + first_mission_id=request.mission_id, + last_mission_id=request.mission_id, + thread_ids=[request.thread_id] if request.thread_id else [] + ) + await contact.insert() + return contact + + +@router.get("/stats") +async def get_contact_stats(user: User = Depends(get_current_user)): + """Get contact statistics""" + total_contacts = await ContactHistory.find( + ContactHistory.user_id == user.clerk_id + ).count() + + replied_contacts = await ContactHistory.find( + ContactHistory.user_id == user.clerk_id, + ContactHistory.has_replied == True + ).count() + + return { + "total_contacts": total_contacts, + "replied_contacts": replied_contacts, + "reply_rate": (replied_contacts / total_contacts * 100) if total_contacts > 0 else 0 + } diff --git a/backend/app/routers/timeline.py b/backend/app/routers/timeline.py new file mode 100644 index 0000000..bcde572 --- /dev/null +++ b/backend/app/routers/timeline.py @@ -0,0 +1,133 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from typing import List, Optional +from datetime import datetime, timedelta +from beanie import PydanticObjectId + +from app.models import EmailThread, EmailEvent, User +from app.api.deps import get_current_user + +router = APIRouter() + +@router.get("/timeline") +async def get_email_timeline( + start_date: Optional[str] = Query(None), + end_date: Optional[str] = Query(None), + user: User = Depends(get_current_user) +): + """ + Get all email events for calendar display + Returns email threads with events in the specified date range + """ + try: + # Build query + query = {"user_id": user.clerk_id} + + # Add date filtering if provided + if start_date or end_date: + date_filter = {} + if start_date: + date_filter["$gte"] = datetime.fromisoformat(start_date.replace('Z', '+00:00')) + if end_date: + date_filter["$lte"] = datetime.fromisoformat(end_date.replace('Z', '+00:00')) + if date_filter: + query["last_activity"] = date_filter + + # Fetch threads + threads = await EmailThread.find(query).to_list() + + # Format for calendar + calendar_events = [] + for thread in threads: + for event in thread.events: + calendar_events.append({ + "id": f"{thread.id}_{event.email_id}", + "thread_id": thread.thread_id, + "type": "email", + "event_type": event.type, + "timestamp": event.timestamp.isoformat(), + "subject": event.subject, + "preview": event.preview, + "from_email": event.from_email, + "to_email": event.to_email, + "status": thread.status, + "mission_id": thread.mission_id, + "prospect_id": thread.prospect_id + }) + + return calendar_events + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch timeline: {str(e)}") + +@router.get("/threads/{thread_id}") +async def get_thread_details( + thread_id: str, + user: User = Depends(get_current_user) +): + """ + Get complete conversation thread with all events + """ + thread = await EmailThread.find_one({ + "thread_id": thread_id, + "user_id": user.clerk_id + }) + + if not thread: + raise HTTPException(status_code=404, detail="Thread not found") + + return { + "thread_id": thread.thread_id, + "mission_id": thread.mission_id, + "prospect_id": thread.prospect_id, + "status": thread.status, + "first_sent_at": thread.first_sent_at.isoformat() if thread.first_sent_at else None, + "last_reply_at": thread.last_reply_at.isoformat() if thread.last_reply_at else None, + "reply_count": thread.reply_count, + "events": [ + { + "type": event.type, + "timestamp": event.timestamp.isoformat(), + "email_id": event.email_id, + "subject": event.subject, + "preview": event.preview, + "from_email": event.from_email, + "to_email": event.to_email, + "metadata": event.metadata + } + for event in thread.events + ] + } + +@router.post("/threads/{thread_id}/events") +async def add_thread_event( + thread_id: str, + event_data: dict, + user: User = Depends(get_current_user) +): + """ + Add a new event to an existing thread (for reply detection) + """ + thread = await EmailThread.find_one({ + "thread_id": thread_id, + "user_id": user.clerk_id + }) + + if not thread: + raise HTTPException(status_code=404, detail="Thread not found") + + # Create new event + new_event = EmailEvent(**event_data) + thread.events.append(new_event) + thread.last_activity = new_event.timestamp + + # Update status and counters + if new_event.type == "reply_received": + thread.status = "replied" + thread.reply_count += 1 + thread.last_reply_at = new_event.timestamp + elif new_event.type == "follow_up": + thread.status = "waiting_reply" + + await thread.save() + + return {"message": "Event added successfully", "thread_id": thread.thread_id} diff --git a/backend/check_permissions.py b/backend/check_permissions.py new file mode 100644 index 0000000..17e81f1 --- /dev/null +++ b/backend/check_permissions.py @@ -0,0 +1,18 @@ +import socket +import os + +print("Checking .env access:") +try: + with open(".env", "r") as f: + print("Success reading .env") +except Exception as e: + print(f"Failed reading .env: {e}") + +print("Checking port binding:") +try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(("127.0.0.1", 5173)) + print("Success binding 5173") + s.close() +except Exception as e: + print(f"Failed binding 5173: {e}") diff --git a/backend/main.py b/backend/main.py index ee7bb8b..f5215b3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -7,14 +7,18 @@ import json from app.core.config import settings -from app.models import User, Mission, Prospect, Draft, MissionLog, Agent, UserAsset -from app.routers import missions, reviews, agents +from app.models import User, Mission, Prospect, Draft, MissionLog, Agent, UserAsset, EmailThread, ContactHistory +from app.routers import missions, reviews, agents, contacts @asynccontextmanager async def lifespan(app: FastAPI): # Startup + # Validate required environment variables + if not settings.MONGODB_URI: + raise ValueError("MONGODB_URI environment variable is required") + client = AsyncIOMotorClient(settings.MONGODB_URI, tlsAllowInvalidCertificates=True) - await init_beanie(database=client.outbound_ai, document_models=[User, Mission, Prospect, Draft, MissionLog, Agent, UserAsset]) + await init_beanie(database=client.outbound_ai, document_models=[User, Mission, Prospect, Draft, MissionLog, Agent, UserAsset, EmailThread, ContactHistory]) yield # Shutdown @@ -31,12 +35,15 @@ async def lifespan(app: FastAPI): app.include_router(missions.router, prefix="/api/v1/missions", tags=["missions"]) app.include_router(reviews.router, prefix="/api/v1/reviews", tags=["reviews"]) app.include_router(agents.router, prefix="/api/v1/agents", tags=["agents"]) +app.include_router(contacts.router, prefix="/api/v1/contacts", tags=["contacts"]) from app.routers import integrations app.include_router(integrations.router, prefix="/api/v1/integrations", tags=["integrations"]) from app.routers import assets app.include_router(assets.router, prefix="/api/v1/assets", tags=["assets"]) from app.routers import health app.include_router(health.router, prefix="/api/v1/health", tags=["health"]) +from app.routers import timeline +app.include_router(timeline.router, prefix="/api/v1", tags=["timeline"]) from fastapi import WebSocket, WebSocketDisconnect diff --git a/backend/secrets.env b/backend/secrets.env new file mode 100644 index 0000000..8f7639c --- /dev/null +++ b/backend/secrets.env @@ -0,0 +1,7 @@ +MONGODB_URI=mongodb+srv://beastt:beastt123@cluster0.jdgwb2y.mongodb.net/?appName=Cluster0 +VITE_CLERK_PUBLISHABLE_KEY=pk_test_cHJlY2lzZS1jb3ctMzUuY2xlcmsuYWNjb3VudHMuZGV2JA +GROQ_API_KEY=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 +FIRECRAWL_API_KEY=fc-0979caa62af241fc8bbc4e7294503fa6 + +COMPOSIO_API_KEY=ak_hfvZp0TVxhtmsUHTZJ39 +COMPOSIO_AUTH_CONFIG_ID=ac_kualor5KGAMU diff --git a/backend/testfile.txt b/backend/testfile.txt new file mode 100644 index 0000000..9daeafb --- /dev/null +++ b/backend/testfile.txt @@ -0,0 +1 @@ +test diff --git a/frontend/public/fonts/GalgoVF.woff b/frontend/public/fonts/GalgoVF.woff new file mode 100644 index 0000000000000000000000000000000000000000..4b5f7cfbc752d3da2e72edea0e6c265f69b3a72d GIT binary patch literal 35836 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%0Aw8h=`ak?0}%E zah_=HGLEBxGM*pI;UpdV*iV7w78cX>`I zM{&5bL&&t=A#PYCQFP{K97h8Vsn=Wic%M_e4uYUEw*TINcS!IuBp){0t&2}XdhX)Z z#fy(W{`hm-p)AYd3fqtUDq!MZ(5vOBPQnOh+HT8uuQQS{KsclCjkJN+6SBNS*jO(E zO)B|EWqipUHc_m`2U}aNh#_MT$c>j+K2L`%>gyT@I{;T<7{dmcx`B{j0$$s!1N4Qp zSE<2!&}TIi(_SHGNVV)vNK`Oy@&>{Cz^J8*xY0TwWW9OHaXg|91_R_#IEsvz6I&*U z@J=per#cEN^t=6DpIU0SMdj=um&@pKCWDuR�Pif>th(h5lUBpikkA>;^H>kU_(f z#()h@VFso7j7NgcMOHF1elEViH@P?GAu?vH?s?A%pKF_&%dgGkJ#uWOw>p#a=6s$j zGkJI1cag7ku^)AQ?yuzg-5JNEGC~{Nkwj!m38p-q6dfn0$U=X8;)U2ty%33ifb|2P zbc3l;l^E|OkIEK%ds}m!=I#QIPG|1DgJ4km~Gp&Ip48MjxIbAMWK~QnGp&YE{at*1}MW@=BkiS@nG^zj-`S`*(f%RJ>58nGSMRedSOn>Fh?UaT3$PP|WE<#Zg3&)W&euNbHgf~ER0Ux7M3 z`xU4e9Z9Af^jiYGC*F0`IDNLXwFNTzC!k)SgVU!EJ{r&)Pu?-2s*_D{dh%@}IHNP) zH>%Bl-{>Y7_c*LtN410qL9lbsK{`a8NJwR%s-;UpE=WQ$xWTE{{_P_R*Cf75#bmr9 z^ulBQ)OV2n-&Q=2y7RZ4=1~iIou=4M7DoA&@SVCRz8a$I1pSYw`k|t`&^Iyi(vQxD zo>Jt8Gv!cfEZi|w}BzN4Cc4P*8O*Bx__oZ0;TI6r?k zbDj&0i6w~2H9B4!CPqQVJ*#xhSfAoft~PSHU~_U?Q*GcS@3I;sw|FG954yqR zU8IImb3GvS$@ePpre$xlO1#r!Z(_;3_hQHbiwUcG2hl`zT$f$GNKA) tCMH;#X`;^^$~HLUwDsvbw8xH3XpSb(#_eQ^a>tGV<^B&;`3qkF0061q94P<* literal 0 HcmV?d00001 diff --git a/frontend/public/fonts/GalgoVF.woff2 b/frontend/public/fonts/GalgoVF.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..fa1ea4194e9a734b2132b6d6bb69cb78bc4a931a GIT binary patch literal 28120 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 DL&Zbd literal 0 HcmV?d00001 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 182c75e..92acdd0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,115 +13,131 @@ import Landing from "./pages/Landing"; import NotFound from "./pages/NotFound"; import MissionChat from "./pages/MissionChat"; import Calendar from "./pages/Calendar"; +import ContactHistory from "./pages/ContactHistory"; import { SignedIn, SignedOut, RedirectToSignIn } from "@clerk/clerk-react"; import SignIn from "./pages/SignIn"; import SignUp from "./pages/SignUp"; +import ErrorBoundary from "./components/ErrorBoundary"; const queryClient = new QueryClient(); const App = () => ( - - - - - - - {/* Public Routes */} - } /> - } /> - } /> + + + + + + + + {/* Public Routes */} + } /> + } /> + } /> - {/* Protected Routes */} - - - - - - - - - - - } /> - - - - - - - - - - - } /> - - - - - - - - - - - } /> - - - - - - - - - - - } /> - - - - - - - - - - - } /> - - - - - - - - - - - } /> - - - - - - - - - - - } /> - } /> - - - - + {/* Protected Routes */} + + + + + + + + + + + } /> + + + + + + + + + + + } /> + + + + + + + + + + + } /> + + + + + + + + + + + } /> + + + + + + + + + + + } /> + + + + + + + + + + + } /> + + + + + + + + + + + } /> + + + + + + + + + + + } /> + } /> + + + + + ); export default App; diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..da3dd0f --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,52 @@ +import React, { Component, ErrorInfo, ReactNode } from "react"; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +class ErrorBoundary extends Component { + public state: State = { + hasError: false, + error: null, + }; + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("Uncaught error:", error, errorInfo); + } + + public render() { + if (this.state.hasError) { + return ( +

+
+

+ Something went wrong +

+

+ {this.state.error?.message || "An unexpected error occurred"} +

+ +
+
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/frontend/src/components/launchpad/HeroInput.tsx b/frontend/src/components/launchpad/HeroInput.tsx index cafbc89..519f071 100644 --- a/frontend/src/components/launchpad/HeroInput.tsx +++ b/frontend/src/components/launchpad/HeroInput.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Sparkles, ArrowRight, Loader2, Paperclip } from "lucide-react"; +import { Sparkles, ArrowRight, Loader2, Paperclip, ChevronDown, ChevronUp } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { useApi } from "@/lib/api"; @@ -10,6 +10,7 @@ export function HeroInput() { const [query, setQuery] = useState(""); const [isFocused, setIsFocused] = useState(false); const [isLoading, setIsLoading] = useState(false); + const [isMinimized, setIsMinimized] = useState(false); const api = useApi(); const navigate = useNavigate(); @@ -22,6 +23,17 @@ export function HeroInput() { const value = e.target.value; setQuery(value); + // Trigger workflow display when user types + if (value.trim().length > 0) { + window.dispatchEvent(new CustomEvent('showWorkflow', { + detail: { show: true, objective: value } + })); + } else { + window.dispatchEvent(new CustomEvent('showWorkflow', { + detail: { show: false } + })); + } + if (value.endsWith('#')) { try { const assets = await api.getAssets(); @@ -76,6 +88,28 @@ export function HeroInput() { } }; + // Minimized state + if (isMinimized) { + return ( +
+
+
+ + Mission Input +
+ +
+
+ ); + } + return (
{/* Glow effect */} @@ -89,6 +123,16 @@ export function HeroInput() { }} /> + {/* Minimize Button */} + + {/* Selected Attachments */} {selectedAttachments.length > 0 && (
@@ -181,10 +225,22 @@ export function HeroInput() { {/* Suggestions */}
Try: - -
diff --git a/frontend/src/components/layout/NavigationSidebar.tsx b/frontend/src/components/layout/NavigationSidebar.tsx index c16b8c8..52cfab9 100644 --- a/frontend/src/components/layout/NavigationSidebar.tsx +++ b/frontend/src/components/layout/NavigationSidebar.tsx @@ -9,6 +9,7 @@ import { ChevronRight, MessageSquare, Calendar, + Users, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -23,6 +24,7 @@ const navItems = [ { icon: Calendar, label: "Calendar", path: "/calendar", id: "sidebar-calendar" }, { icon: ClipboardCheck, label: "Review Queue", path: "/review", id: "sidebar-review-queue" }, { icon: Bot, label: "Active Agents", path: "/agents", id: "sidebar-active-agents" }, + { icon: Users, label: "Contacts", path: "/contacts", id: "sidebar-contacts" }, { icon: Settings, label: "Settings", path: "/settings", id: "sidebar-settings" }, ]; diff --git a/frontend/src/components/mission-control/MissionMap.tsx b/frontend/src/components/mission-control/MissionMap.tsx index 3b4df0d..65760d4 100644 --- a/frontend/src/components/mission-control/MissionMap.tsx +++ b/frontend/src/components/mission-control/MissionMap.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useState, useRef } from 'react'; import { ReactFlow, Background, @@ -22,75 +22,181 @@ const nodeTypes = { agent: AgentNode, }; -// Initial Data (Mock) -const initialNodes: Node[] = [ - { - id: 'mission-1', +// Function to generate workflow based on user's objective +const generateWorkflow = (objective: string): { nodes: Node[], edges: Edge[] } => { + const lowerObjective = objective.toLowerCase(); + const nodes: Node[] = []; + const edges: Edge[] = []; + + // Create mission node + nodes.push({ + id: 'mission-active', type: 'mission', - position: { x: 400, y: 100 }, + position: { x: 350, y: 50 }, data: { - label: 'Outbound Campaign: Tech Startups', + label: objective.length > 35 ? objective.substring(0, 35) + '...' : objective, status: 'active', - progress: 45 + progress: 10 }, - }, - { - id: 'agent-1', - type: 'agent', - position: { x: 100, y: 300 }, - data: { - label: 'Lead Scraper Alpha', + }); + + // Determine agents based on keywords + const agentConfigs: Array<{ id: string, label: string, role: string, action: string, x: number, y: number }> = []; + + // Researcher for finding/searching + if (lowerObjective.includes('find') || lowerObjective.includes('target') || + lowerObjective.includes('search') || lowerObjective.includes('cto') || + lowerObjective.includes('founder') || lowerObjective.includes('hiring') || + lowerObjective.includes('manager') || lowerObjective.length > 5) { + agentConfigs.push({ + id: 'agent-researcher', + label: 'Lead Researcher', role: 'Researcher', - status: 'working', - currentAction: 'Scraping LinkedIn Company Pages...' - }, - }, - { - id: 'agent-2', - type: 'agent', - position: { x: 400, y: 350 }, - data: { - label: 'Email Drafter Beta', + action: 'Finding matching prospects...', + x: 100, + y: 250 + }); + } + + // Enricher for data + if (lowerObjective.includes('enrich') || lowerObjective.includes('data') || + lowerObjective.includes('info') || lowerObjective.includes('details')) { + agentConfigs.push({ + id: 'agent-enricher', + label: 'Data Enricher', + role: 'Enricher', + action: 'Gathering prospect details...', + x: 280, + y: 320 + }); + } + + // Copywriter for emails + if (lowerObjective.includes('email') || lowerObjective.includes('outreach') || + lowerObjective.includes('message') || lowerObjective.includes('contact') || + lowerObjective.includes('reach') || lowerObjective.includes('send') || + agentConfigs.length > 0) { + agentConfigs.push({ + id: 'agent-copywriter', + label: 'Email Copywriter', role: 'Copywriter', - status: 'idle', - currentAction: 'Waiting for leads' - }, - }, - { - id: 'agent-3', - type: 'agent', - position: { x: 700, y: 300 }, - data: { - label: 'Outreach Coordinator', + action: 'Drafting personalized emails...', + x: 450, + y: 270 + }); + } + + // Manager always present if there are other agents + if (agentConfigs.length > 0 || lowerObjective.length > 3) { + agentConfigs.push({ + id: 'agent-manager', + label: 'Campaign Manager', role: 'Manager', - status: 'active', - currentAction: 'Reviewing drafts' - }, - }, -]; + action: 'Coordinating outreach...', + x: 620, + y: 250 + }); + } + + // Create agent nodes + agentConfigs.forEach(config => { + nodes.push({ + id: config.id, + type: 'agent', + position: { x: config.x, y: config.y }, + data: { + label: config.label, + role: config.role, + status: 'working', + currentAction: config.action + }, + }); -const initialEdges: Edge[] = [ - { id: 'e1-1', source: 'mission-1', target: 'agent-1', animated: true, style: { stroke: '#6366f1' } }, - { id: 'e1-2', source: 'mission-1', target: 'agent-2', animated: true, style: { stroke: '#6366f1' } }, - { id: 'e1-3', source: 'mission-1', target: 'agent-3', animated: true, style: { stroke: '#6366f1' } }, -]; + // Create edge from mission to agent + edges.push({ + id: `e-mission-${config.id}`, + source: 'mission-active', + target: config.id, + animated: true, + style: { stroke: '#6366f1', strokeWidth: 2 } + }); + }); + + return { nodes, edges }; +}; const MissionMapContent = () => { - const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [currentObjective, setCurrentObjective] = useState(''); + const debounceRef = useRef(null); const onConnect = useCallback( (params: Connection) => setEdges((eds) => addEdge(params, eds)), [setEdges], ); + // Listen for workflow events from HeroInput + useEffect(() => { + const handleShowWorkflow = (event: CustomEvent<{ show: boolean, objective?: string }>) => { + const { show, objective } = event.detail; + + // Clear existing debounce + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + + if (!show || !objective || objective.length < 3) { + // Clear workflow + setCurrentObjective(''); + setNodes([]); + setEdges([]); + return; + } + + // Debounce to avoid too many updates while typing + debounceRef.current = setTimeout(() => { + if (objective !== currentObjective) { + setCurrentObjective(objective); + const { nodes: newNodes, edges: newEdges } = generateWorkflow(objective); + setNodes(newNodes); + setEdges(newEdges); + } + }, 200); + }; + + window.addEventListener('showWorkflow', handleShowWorkflow as EventListener); + return () => { + window.removeEventListener('showWorkflow', handleShowWorkflow as EventListener); + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + }; + }, [currentObjective, setNodes, setEdges]); + return (

Live Operations

Spatial Mission Control

+ {nodes.length > 0 && ( +
+
+ Workflow ready +
+ )}
+ {nodes.length === 0 && ( +
+
+
🚀
+

Type your mission to see the workflow...

+

Agents will appear as you describe your goal

+
+
+ )} + { onConnect={onConnect} nodeTypes={nodeTypes} fitView + fitViewOptions={{ padding: 0.3 }} + minZoom={0.5} + maxZoom={1.5} className="bg-black/90" + proOptions={{ hideAttribution: true }} > { - {/* Example "Live" Effect overlay */} + {/* Live Effect overlay */}
); diff --git a/frontend/src/components/ui/InteractiveDots.tsx b/frontend/src/components/ui/InteractiveDots.tsx new file mode 100644 index 0000000..97aa8b9 --- /dev/null +++ b/frontend/src/components/ui/InteractiveDots.tsx @@ -0,0 +1,106 @@ +import React, { useRef, useEffect, useState } from 'react'; + +interface InteractiveDotsProps { + rows?: number; + columns?: number; + dotSize?: number; + gap?: number; + className?: string; +} + +/** + * InteractiveDots Component + * + * Creates a grid of circles that change color with a gradient effect + * when the mouse cursor moves over them. + * + * @param rows - Number of rows in the grid (default: 20) + * @param columns - Number of columns in the grid (default: 30) + * @param dotSize - Size of each dot in pixels (default: 4) + * @param gap - Gap between dots in pixels (default: 30) + */ +export function InteractiveDots({ + rows = 20, + columns = 30, + dotSize = 4, + gap = 30, + className = '', +}: InteractiveDotsProps) { + const containerRef = useRef(null); + const [hoveredIndex, setHoveredIndex] = useState(null); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const dots = container.querySelectorAll('.interactive-dot'); + + const onPointerMove = (event: PointerEvent) => { + dots.forEach((dot, index) => { + const rect = dot.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + const distanceX = event.clientX - centerX; + const distanceY = event.clientY - centerY; + const distance = Math.sqrt(distanceX * distanceX + distanceY * distanceY); + + // Calculate proximity (closer = higher value) + const maxDistance = 150; + const proximity = Math.max(0, 1 - distance / maxDistance); + + if (proximity > 0) { + // Apply gradient color based on proximity + const hue = (proximity * 60 + 180) % 360; // Cyan to blue range + const saturation = 100; + const lightness = 50 + proximity * 30; + const alpha = 0.3 + proximity * 0.7; + + dot.style.backgroundColor = `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`; + dot.style.transform = `scale(${1 + proximity * 2})`; + dot.style.boxShadow = `0 0 ${proximity * 20}px hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`; + } else { + // Reset to default + dot.style.backgroundColor = 'hsla(220, 20%, 50%, 0.2)'; + dot.style.transform = 'scale(1)'; + dot.style.boxShadow = 'none'; + } + }); + }; + + window.addEventListener('pointermove', onPointerMove); + + return () => { + window.removeEventListener('pointermove', onPointerMove); + }; + }, []); + + const total = rows * columns; + const dots = Array.from({ length: total }, (_, i) => ( +
+ )); + + return ( +
+ {dots} +
+ ); +} diff --git a/frontend/src/components/ui/LampContainer.tsx b/frontend/src/components/ui/LampContainer.tsx new file mode 100644 index 0000000..564761b --- /dev/null +++ b/frontend/src/components/ui/LampContainer.tsx @@ -0,0 +1,87 @@ +import React, { useRef, useEffect } from 'react'; +import { motion } from 'framer-motion'; +import { cn } from '@/lib/utils'; + +interface LampContainerProps { + children: React.ReactNode; + className?: string; +} + +/** + * LampContainer Component + * + * A modern lamp effect container with animated light beams and glowing effects. + * Creates dramatic lighting atmosphere perfect for hero sections and showcases. + * + * @param children - Content to display inside the lamp container + * @param className - Additional CSS classes + */ +export function LampContainer({ children, className }: LampContainerProps) { + return ( +
+
+ {/* Left gradient beam */} + +
+
+ + + {/* Right gradient beam */} + +
+
+ + + {/* Background blur layers - reduced on mobile */} +
+
+ + {/* Glow effects - reduced on mobile */} +
+ + + {/* Center line */} + + + {/* Top mask */} +
+
+ + {/* Content */} +
+ {children} +
+
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2ba6f34..690dff4 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -22,16 +22,22 @@ export function useApi() { method: "POST", body: JSON.stringify({ objective }), }); + if (!res.ok) { + const error = await res.json(); + throw new Error(error.detail || 'Failed to create mission'); + } return res.json(); }, listMissions: async () => { const res = await fetchWithAuth("/missions/"); + if (!res.ok) throw new Error('Failed to fetch missions'); return res.json(); }, getMissionLogs: async (missionId: string) => { const res = await fetchWithAuth(`/missions/${missionId}/logs`); + if (!res.ok) throw new Error('Failed to fetch mission logs'); return res.json(); }, @@ -40,6 +46,7 @@ export function useApi() { method: "POST", body: JSON.stringify({ message }), }); + if (!res.ok) throw new Error('Failed to send message'); return res.json(); }, @@ -47,6 +54,7 @@ export function useApi() { const res = await fetchWithAuth(`/missions/${missionId}`, { method: "DELETE", }); + if (!res.ok) throw new Error('Failed to delete mission'); return res.json(); }, @@ -54,12 +62,14 @@ export function useApi() { const res = await fetchWithAuth(`/missions/${missionId}/stop`, { method: "PATCH", }); + if (!res.ok) throw new Error('Failed to stop mission'); return res.json(); }, // Reviews getPendingDrafts: async () => { const res = await fetchWithAuth("/reviews/pending"); + if (!res.ok) throw new Error('Failed to fetch pending drafts'); return res.json(); }, @@ -67,6 +77,7 @@ export function useApi() { const res = await fetchWithAuth("/reviews/pending", { method: "DELETE", }); + if (!res.ok) throw new Error('Failed to clear drafts'); return res.json(); }, @@ -84,6 +95,7 @@ export function useApi() { const res = await fetchWithAuth(`/reviews/${id}/reject?feedback=${encodeURIComponent(feedback)}`, { method: "POST", }); + if (!res.ok) throw new Error('Failed to reject draft'); return res.json(); }, @@ -91,6 +103,7 @@ export function useApi() { const res = await fetchWithAuth(`/reviews/${id}/regenerate`, { method: "POST", }); + if (!res.ok) throw new Error('Failed to regenerate draft'); return res.json(); }, @@ -110,6 +123,7 @@ export function useApi() { getIntegrations: async () => { const res = await fetchWithAuth("/integrations/"); + if (!res.ok) throw new Error('Failed to fetch integrations'); return res.json(); }, @@ -117,12 +131,14 @@ export function useApi() { const res = await fetchWithAuth(`/integrations/${tool}`, { method: "DELETE", }); + if (!res.ok) throw new Error('Failed to disconnect tool'); return res.json(); }, // Agents getAgents: async () => { const res = await fetchWithAuth("/agents/"); + if (!res.ok) throw new Error('Failed to fetch agents'); return res.json(); }, @@ -131,11 +147,13 @@ export function useApi() { method: "POST", body: JSON.stringify(agent), }); + if (!res.ok) throw new Error('Failed to create agent'); return res.json(); }, getAgent: async (id: string) => { const res = await fetchWithAuth(`/agents/${id}`); + if (!res.ok) throw new Error('Failed to fetch agent'); return res.json(); }, @@ -144,6 +162,7 @@ export function useApi() { method: "PATCH", body: JSON.stringify(updates), }); + if (!res.ok) throw new Error('Failed to update agent'); return res.json(); }, @@ -151,6 +170,7 @@ export function useApi() { const res = await fetchWithAuth(`/agents/${id}`, { method: "DELETE", }); + if (!res.ok) throw new Error('Failed to delete agent'); return res.json(); }, // Assets @@ -185,6 +205,66 @@ export function useApi() { const res = await fetchWithAuth(`/assets/${id}`, { method: "DELETE", }); + if (!res.ok) throw new Error('Failed to delete asset'); + return res.json(); + }, + + // Email Timeline + getEmailTimeline: async (startDate?: string, endDate?: string) => { + try { + const params = new URLSearchParams(); + if (startDate) params.append('start_date', startDate); + if (endDate) params.append('end_date', endDate); + + const res = await fetchWithAuth(`/timeline?${params}`); + if (!res.ok) { + // Return empty array if no timeline data yet + if (res.status === 404) return []; + throw new Error('Failed to fetch email timeline'); + } + return res.json(); + } catch (error) { + console.error('Email timeline fetch error:', error); + return []; // Return empty array on error + } + }, + + getThreadDetails: async (threadId: string) => { + const res = await fetchWithAuth(`/timeline/threads/${threadId}`); + if (!res.ok) throw new Error('Failed to fetch thread details'); + return res.json(); + }, + + // Contact History + getContactHistory: async (skip = 0, limit = 50) => { + const res = await fetchWithAuth(`/contacts/history?skip=${skip}&limit=${limit}`); + if (!res.ok) throw new Error('Failed to fetch contact history'); + return res.json(); + }, + + checkDuplicates: async (emails: string[]) => { + const res = await fetchWithAuth('/contacts/check-duplicates', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ emails }) + }); + if (!res.ok) throw new Error('Failed to check duplicates'); + return res.json(); + }, + + recordContact: async (contactData: { email: string, name?: string, mission_id: string, thread_id?: string }) => { + const res = await fetchWithAuth('/contacts/record', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(contactData) + }); + if (!res.ok) throw new Error('Failed to record contact'); + return res.json(); + }, + + getContactStats: async () => { + const res = await fetchWithAuth('/contacts/stats'); + if (!res.ok) throw new Error('Failed to fetch contact stats'); return res.json(); }, }; diff --git a/frontend/src/pages/ActiveAgents.tsx b/frontend/src/pages/ActiveAgents.tsx index 1a2ecdf..ad7426e 100644 --- a/frontend/src/pages/ActiveAgents.tsx +++ b/frontend/src/pages/ActiveAgents.tsx @@ -88,7 +88,7 @@ export default function ActiveAgents() { }; fetchAgents(); - }, []); + }, [getAgents]); return (
@@ -169,7 +169,7 @@ export default function ActiveAgents() {
) : ( agents.map((agent) => ( - +
{agent.mission}

+ {/* Time and Date Information */} +
+
+ + + + Created: {new Date().toLocaleDateString()} at {new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +
+
+ + Last Active: {agent.uptime} ago +
+
+ {/* Progress */} {agent.status === "active" && (
@@ -264,8 +278,9 @@ export default function ActiveAgents() { )}
- - )))} +
+ ))) + }
); diff --git a/frontend/src/pages/Calendar.tsx b/frontend/src/pages/Calendar.tsx index 40db42b..1e1590d 100644 --- a/frontend/src/pages/Calendar.tsx +++ b/frontend/src/pages/Calendar.tsx @@ -23,9 +23,10 @@ export default function Calendar() { useEffect(() => { const fetchData = async () => { try { - const [missionsData, draftsData] = await Promise.all([ + const [missionsData, draftsData, emailTimeline] = await Promise.all([ api.listMissions(), - api.getPendingDrafts() + api.getPendingDrafts(), + api.getEmailTimeline() ]); // Normalize items @@ -41,7 +42,14 @@ export default function Calendar() { date: d.due_date ? new Date(d.due_date) : new Date() // Default to today if no due date })); - setItems([...normalizedMissions, ...normalizedDrafts]); + // Normalize email events + const normalizedEmails = (emailTimeline || []).map((event: any) => ({ + ...event, + type: 'email', + date: new Date(event.timestamp) + })); + + setItems([...normalizedMissions, ...normalizedDrafts, ...normalizedEmails]); } catch (error) { console.error("Failed to fetch calendar data:", error); } finally { @@ -49,7 +57,7 @@ export default function Calendar() { } }; fetchData(); - }, []); + }, [api]); const nextMonth = () => setCurrentDate(addMonths(currentDate, 1)); const prevMonth = () => setCurrentDate(subMonths(currentDate, 1)); @@ -89,6 +97,9 @@ export default function Calendar() {
Missions Drafts + Sent + Reply + Follow-up
- {dayItems.map(item => ( - item.type === 'mission' ? ( -
navigate(`/chat/${item._id || item.id}`)} - className={cn( - "text-xs px-2 py-1.5 rounded-md cursor-pointer border truncate transition-all hover:scale-[1.02]", - item.status === "running" ? "bg-primary/15 text-primary border-primary/20 hover:bg-primary/25" : - item.status === "completed" ? "bg-success/15 text-success border-success/20 hover:bg-success/25" : - "bg-muted text-muted-foreground border-border hover:bg-muted/80" - )} - > - {item.objective} -
- ) : ( - - -
navigate("/review")} - > - - {item.subject || "Draft"} -
-
- -
-
-

{item.subject}

- Draft -
-
-

To: {item.name || "Unknown"}

-

{item.company}

+ {dayItems.map((item, idx) => { + // Handle missions + if (item.type === 'mission') { + return ( +
navigate(`/chat/${item._id || item.id}`)} + className={cn( + "text-xs px-2 py-1.5 rounded-md cursor-pointer border truncate transition-all hover:scale-[1.02]", + item.status === "running" ? "bg-primary/15 text-primary border-primary/20 hover:bg-primary/25" : + item.status === "completed" ? "bg-success/15 text-success border-success/20 hover:bg-success/25" : + "bg-muted text-muted-foreground border-border hover:bg-muted/80" + )} + > + {item.objective} +
+ ); + } + + // Handle drafts + if (item.type === 'draft') { + return ( + + +
navigate("/review")} + > + + {item.subject || "Draft"}
-
- {item.body} + + +
+
+

{item.subject}

+ Draft +
+
+

To: {item.name || "Unknown"}

+

{item.company}

+
+
+ {item.body} +
+
- -
- -
- ) - ))} + + + ); + } + + // Handle email events + if (item.type === 'email') { + let color = "bg-gray-500"; + let icon = "📧"; + let title = item.subject || "Email event"; + + if (item.event_type === "sent") { + color = "bg-green-500"; + icon = "📧"; + title = `Sent: ${item.subject || "Email"}`; + } else if (item.event_type === "reply_received") { + color = "bg-blue-500"; + icon = "📬"; + title = `Reply from ${item.from_email}`; + } else if (item.event_type === "follow_up") { + color = "bg-orange-500"; + icon = "🔄"; + title = `Follow-up: ${item.subject || ""}`; + } else if (item.event_type === "opened") { + color = "bg-purple-500"; + icon = "👁️"; + title = "Email opened"; + } else if (item.event_type === "clicked") { + color = "bg-pink-500"; + icon = "🖱️"; + title = "Link clicked"; + } + + return ( +
+ + {icon} {item.preview?.substring(0, 20) || title} +
+ ); + } + + return null; + })}
); diff --git a/frontend/src/pages/ContactHistory.tsx b/frontend/src/pages/ContactHistory.tsx new file mode 100644 index 0000000..eb2c513 --- /dev/null +++ b/frontend/src/pages/ContactHistory.tsx @@ -0,0 +1,189 @@ +import { useState, useEffect } from "react"; +import { useApi } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Search, Download, Mail, CheckCircle2, XCircle } from "lucide-react"; +import { format } from "date-fns"; + +export default function ContactHistory() { + const [contacts, setContacts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + const [stats, setStats] = useState(null); + const api = useApi(); + + useEffect(() => { + fetchData(); + }, []); + + const fetchData = async () => { + try { + const [contactsData, statsData] = await Promise.all([ + api.getContactHistory(), + api.getContactStats() + ]); + setContacts(contactsData || []); + setStats(statsData); + } catch (error) { + console.error("Failed to fetch contact history:", error); + } finally { + setIsLoading(false); + } + }; + + const filteredContacts = contacts.filter(contact => + contact.prospect_email.toLowerCase().includes(searchQuery.toLowerCase()) || + contact.prospect_name?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const exportToCSV = () => { + const headers = ["Email", "Name", "First Contacted", "Last Contacted", "Total Emails", "Status"]; + const rows = filteredContacts.map(c => [ + c.prospect_email, + c.prospect_name || "", + format(new Date(c.first_contacted_at), "yyyy-MM-dd HH:mm"), + format(new Date(c.last_contacted_at), "yyyy-MM-dd HH:mm"), + c.total_emails_sent, + c.status + ]); + + const csvContent = [ + headers.join(","), + ...rows.map(row => row.join(",")) + ].join("\n"); + + const blob = new Blob([csvContent], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `contact-history-${format(new Date(), "yyyy-MM-dd")}.csv`; + a.click(); + }; + + return ( +
+ {/* Header */} +
+
+

Contact History

+

Track all email communications and prevent duplicates

+
+ +
+ + {/* Stats Cards */} + {stats && ( +
+
+
+
+ +
+
+

{stats.total_contacts}

+

Total Contacts

+
+
+
+
+
+
+ +
+
+

{stats.replied_contacts}

+

Replied

+
+
+
+
+
+
+ +
+
+

{stats.reply_rate.toFixed(1)}%

+

Reply Rate

+
+
+
+
+ )} + + {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+
+ + {/* Table */} +
+
+ + + + + + + + + + + + + {isLoading ? ( + + + + ) : filteredContacts.length === 0 ? ( + + + + ) : ( + filteredContacts.map((contact) => ( + + + + + + + + + )) + )} + +
EmailNameFirst ContactedLast ContactedEmails SentStatus
+ Loading... +
+ {searchQuery ? "No contacts found" : "No contact history yet"} +
{contact.prospect_email}{contact.prospect_name || "-"} + {format(new Date(contact.first_contacted_at), "MMM d, yyyy HH:mm")} + + {format(new Date(contact.last_contacted_at), "MMM d, yyyy HH:mm")} + + {contact.total_emails_sent} + + + {contact.has_replied ? "Replied" : "No Reply"} + +
+
+
+
+ ); +} diff --git a/frontend/src/pages/History.tsx b/frontend/src/pages/History.tsx new file mode 100644 index 0000000..1f626f2 --- /dev/null +++ b/frontend/src/pages/History.tsx @@ -0,0 +1,96 @@ +import { useState, useEffect } from "react"; +import { useApi } from "@/lib/api"; +import { Check, X, Mail, Filter } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface HistoryItem { + id: string; + name: string; + company: string; + subject: string; + status: 'approved' | 'rejected'; + updated_at: string; +} + +export default function History() { + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(true); + const api = useApi(); + + useEffect(() => { + const fetchHistory = async () => { + try { + const data = await api.getHistory(); + setHistory(data); + } catch (e) { + console.error("Failed to fetch history"); + } finally { + setLoading(false); + } + }; + fetchHistory(); + }, [api]); + + return ( +
+
+

History

+
+ +
+
+ +
+ {/* Table Header */} +
+
Prospect
+
Subject
+
Status
+
Date
+
+ + {/* List */} +
+ {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( +
+ +
+ )) + ) : history.length === 0 ? ( +
No history available yet.
+ ) : ( + history.map((item) => ( +
+
+
{item.name || "Unknown"}
+
{item.company}
+
+
+ {item.subject} +
+
+ {item.status === 'approved' ? ( + + Sent + + ) : ( + + Rejected + + )} +
+
+ {new Date(item.updated_at).toLocaleDateString()} +
+
+ )) + )} +
+
+
+ ); +} diff --git a/frontend/src/pages/Launchpad.tsx b/frontend/src/pages/Launchpad.tsx index 5f177c3..998f5f8 100644 --- a/frontend/src/pages/Launchpad.tsx +++ b/frontend/src/pages/Launchpad.tsx @@ -58,7 +58,7 @@ const Launchpad = () => { } }; fetchMissions(); - }, []); + }, [api]); const handleRecipeClick = async (query: string) => { try { diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx new file mode 100644 index 0000000..0038331 --- /dev/null +++ b/frontend/src/pages/Profile.tsx @@ -0,0 +1,114 @@ +import { SignedIn, UserProfile } from "@clerk/clerk-react"; +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Shield, Zap, Activity, Clock, Terminal } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +export default function Profile() { + return ( +
+
+ + {/* Header Section */} +
+
+

+ Command Profile +

+

+ OPERATOR_ID: IND-8923 // CLEARANCE: LEVEL 5 +

+
+ + STATUS: ACTIVE + +
+ +
+ + {/* Left Column: Stats Visualizer */} +
+ {/* Identity Card */} + +
+ +
+
+
+ +
+

Operator

+ + TEAM LEAD + +
+ +
+
+ Reputation + 98.2% +
+
+
+
+
+ + + {/* System Stats */} + +
+ +

System Output

+
+ +
+
+
Total Emails
+
1,248
+
+
+
Credits
+
850
+
+
+ + +
+
+ + {/* Right Column: Clerk Profile Embedded */} +
+ + + + + +
+
+
+
+ ); +} diff --git a/frontend/src/pages/ReviewQueue.tsx b/frontend/src/pages/ReviewQueue.tsx index 2ce042b..9a45172 100644 --- a/frontend/src/pages/ReviewQueue.tsx +++ b/frontend/src/pages/ReviewQueue.tsx @@ -31,7 +31,7 @@ export default function ReviewQueue() { useEffect(() => { fetchDrafts(); - }, []); + }, [api]); const currentDraft = drafts[currentIndex]; diff --git a/start_dev.sh b/start_dev.sh new file mode 100755 index 0000000..80ae65a --- /dev/null +++ b/start_dev.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +# Function to kill background processes on exit +cleanup() { + echo "Stopping servers..." + kill $(jobs -p) + exit +} + +trap cleanup SIGINT SIGTERM + +echo "Starting Backend..." +cd backend +source venv/bin/activate +uvicorn main:app --reload --host 127.0.0.1 --port 8000 & +BACKEND_PID=$! +cd .. + +echo "Starting Frontend..." +cd frontend +npm run dev -- --host 127.0.0.1 --port 5173 & +FRONTEND_PID=$! +cd .. + +echo "Servers started." +echo "Backend: http://127.0.0.1:8000" +echo "Frontend: http://127.0.0.1:5173" +echo "Press Ctrl+C to stop." + +wait diff --git a/tour.py b/tour.py new file mode 100644 index 0000000..f4e8efc --- /dev/null +++ b/tour.py @@ -0,0 +1,48 @@ +import time +import sys + +def type_text(text, delay=0.03): + for char in text: + sys.stdout.write(char) + sys.stdout.flush() + time.sleep(delay) + print() + +def start_tour(): + print("\n" + "="*60) + print("🚀 OUTBOUND AI MISSION BRIEFING 🚀") + print("="*60 + "\n") + + time.sleep(1) + + type_text("👨‍🚀 MISSION COMMANDER: 'Welcome aboard, Agent. Ready to scale your outreach to the stars?'") + time.sleep(0.5) + + print("\n--- 🛰️ 1. THE LAUNCHPAD ---") + type_text("This is your Ground Zero. You don't just 'start a task' here—you launch a MISSION.") + type_text("Input your objective (e.g., 'Find me 50 AI Founders in SF') and hit Launch.") + type_text("Our swarm of autonomous agents will start scanning the digital universe immediately.") + + time.sleep(1) + + print("\n--- 📝 2. THE REVIEW QUEUE ---") + type_text("AI is powerful, but YOU are the Captain. Before any message hits an inbox, it lands here.") + type_text("Think of this as the 'Diplomatic Clearance' center.") + type_text("Check the drafts, refine the tone, and give the 🟢 APPROVE or 🔴 REJECT signal.") + type_text("The agent learns from your feedback, becoming more like you with every click.") + + time.sleep(1) + + print("\n--- 💬 3. THE MISSION CHAT (Right Sidebar) ---") + type_text("Need a status report? Just ask. The chatbox on the right is your direct uplink to the AI's brain.") + type_text("You can ask things like: 'Who have you found so far?' or 'Pivot the search to New York.'") + type_text("It's real-time, it's alive, and it's where the strategy happens.") + + time.sleep(1) + + print("\n" + "="*60) + type_text("✨ SYSTEMS NOMINAL. YOU ARE READY TO CONQUER THE OUTBOUND SECTOR. ✨") + print("="*60 + "\n") + +if __name__ == "__main__": + start_tour() From 27f20703f7c63d7c2600f3f8089ffe3f1e42b798 Mon Sep 17 00:00:00 2001 From: aryawadhwa Date: Tue, 27 Jan 2026 19:03:42 +0530 Subject: [PATCH 02/31] feat: Enhance Calendar, Add Deployment Config --- .env.prod.example | 16 ++ DEPLOYMENT.md | 38 ++++ README.md | 154 ++++++++------ backend/Dockerfile | 19 ++ backend/app/routers/contacts.py | 2 +- backend/app/routers/reviews.py | 31 ++- backend/app/routers/timeline.py | 51 ++++- backend/clear_data.py | 40 ++++ backend/create_token.py | 9 + backend/fetch_draft.py | 5 + backend/verify_timeline_backend.py | 37 ++++ docker-compose.yml | 34 ++++ draft_output.txt | 1 + frontend/Dockerfile | 23 +++ frontend/nginx.conf | 27 +++ .../components/calendar/CalendarEventCard.tsx | 72 +++++++ .../src/components/launchpad/HeroInput.tsx | 55 ++++- .../components/launchpad/LiveMissionCard.tsx | 160 +++++++++++++++ frontend/src/pages/Calendar.tsx | 189 +++++------------- frontend/src/pages/Launchpad.tsx | 4 +- 20 files changed, 745 insertions(+), 222 deletions(-) create mode 100644 .env.prod.example create mode 100644 DEPLOYMENT.md create mode 100644 backend/Dockerfile create mode 100644 backend/clear_data.py create mode 100644 backend/create_token.py create mode 100644 backend/fetch_draft.py create mode 100644 backend/verify_timeline_backend.py create mode 100644 docker-compose.yml create mode 100644 draft_output.txt create mode 100644 frontend/Dockerfile create mode 100644 frontend/nginx.conf create mode 100644 frontend/src/components/calendar/CalendarEventCard.tsx create mode 100644 frontend/src/components/launchpad/LiveMissionCard.tsx diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..ab469a3 --- /dev/null +++ b/.env.prod.example @@ -0,0 +1,16 @@ +# Production Environment Configuration + +# Database +MONGODB_URI=mongodb://mongodb:27017 + +# Authentication (Clerk) +# Get these from your Clerk Dashboard +CLERK_PUBLISHABLE_KEY=pk_test_... +CLERK_SECRET_KEY=sk_test_... + +# AI Services +GROQ_API_KEY=gsk_... +FIRECRAWL_API_KEY=fc_... + +# Security +SECRET_KEY=change_this_to_a_secure_random_string diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..cb90c01 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,38 @@ +# Deployment Guide + +Follow these steps to deploy OutboundAI to a production environment (e.g., VPS, DigitalOcean, AWS EC2). + +## Prerequisites +- Docker & Docker Compose installed on the server. +- API Keys for Groq, Firecrawl, and Clerk. + +## Setup + +1. **Clone the Repository** + ```bash + git clone https://github.com/BEASTSHRIRAM/OutboundAI.git + cd OutboundAI + ``` + +2. **Configure Environment** + Create a `.env.prod` file from the example: + ```bash + cp .env.prod.example .env.prod + ``` + Edit `.env.prod` and fill in your actual API keys. Ensure `MONGODB_URI` is set to `mongodb://mongodb:27017` (internal docker network). + +3. **Build and Run** + ```bash + docker-compose up -d --build + ``` + +4. **Access the Application** + - Frontend: `http://` + - Backend API: `http://:8000/docs` + +## Data Management +- MongoDB data is persisted in a docker volume `mongo_data`. +- To reset data (Warning: Destructive): + ```bash + docker-compose down -v + ``` diff --git a/README.md b/README.md index 97526c0..363e055 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,88 @@ -# Outbound AI - -Outbound AI is a comprehensive platform designed to manage and monitor AI missions, agents, and reviews. It features a modern dashboard for real-time tracking and control. - -## Tech Stack - -### Frontend -- **Framework**: React (Vite) -- **Language**: TypeScript -- **Styling**: TailwindCSS, shadcn-ui -- **State/Data**: TanStack Query -- **Authentication**: Clerk -- **Visualization**: Recharts - -### Backend -- **Framework**: FastAPI -- **Database**: MongoDB (Beanie OD) -- **AI/LLM**: LangChain, LangGraph -- **Search**: Firecrawl -- **Utilities**: Pydantic - -## Key Features - -- **Launchpad**: Initiate and configure new missions. -- **Mission Chat**: Real-time interaction and monitoring of active missions. -- **Review Queue**: Interface for human-in-the-loop review of AI actions. -- **Active Agents**: Dashboard to view and manage currently running agents. -- **Detailed Analytics**: Visualizations of mission performance and metrics. - -## Setup Instructions - -### Frontend -1. Navigate to the frontend directory: - ```bash - cd frontend - ``` -2. Install dependencies: - ```bash - npm install - ``` -3. Start the development server: - ```bash - npm run dev - ``` - -### Backend -1. Navigate to the backend directory: - ```bash - cd backend - ``` -2. Create and activate a virtual environment (optional but recommended): - ```bash - python -m venv venv - # Windows - .\venv\Scripts\activate - # macOS/Linux - source venv/bin/activate - ``` -3. Install dependencies: - ```bash - pip install -r requirements.txt - ``` -4. Start the server: - ```bash - uvicorn main:app --reload - ``` +# 🚀 Outbound AI +### The Operating System for Autonomous Sales Teams + +![Hero Image](https://placehold.co/1200x400/1e293b/ffffff?text=Outbound+AI+Mission+Control) + +> **Stop prospecting manually. Let AI build your pipeline.** +> Outbound AI is the first autonomous SDR platform that plans, researches, and executes complex outbound campaigns with human-like precision at 100x scale. + +--- + +## ⚡ Why Outbound AI? + +Sales teams are drowning in manual tasks—finding emails, researching leads, and writing personalized notes. **Outbound AI** replaces the grunt work with intelligent agents that act as your virtual salesforce. + +- **10x Faster Prospecting**: Agents scan millions of profiles in minutes, not months. +- **Hyper-Personalization**: Every email is crafted using real-time insights from news, LinkedIn, and company reports. +- **Human-in-the-Loop**: You maintain control. AI drafts the outreach; you approve it in the Review Queue. + +--- + +## 💎 Core Features + +### 🧠 Spatial Mission Control +Forget complex filters. Just talk to your AI. +> _"Find me Series A founders in NYC who recently hired a VP of Sales."_ +Your AI understands natural language and launches a tailored mission immediately. + +### 🕵️‍♂️ Autonomous Agents +Watch as your army of agents goes to work. They: +- Scrape web data via **Firecrawl**. +- Verify contact details via **Search**. +- Reason and plan via **LangGraph**. + +### ✅ Smart Review Queue +Quality assurance is built-in. Review high-stakes communications before they send, or let the AI run on autopilot for lower-risk tiers. + +### 🌐 The "Live Brain" +Real-time knowledge graph that learns from every interaction, keeping your prospect data fresh and your context relevant. + +--- + +## 🛠️ Technology Stack + +Built for speed, scalability, and developer joy. + +| Component | Tech | +|-----------|------| +| **Frontend** | React, Vite, TailwindCSS, shadcn-ui, TanStack Query | +| **Backend** | FastAPI, Python 3.12+, AsyncIO | +| **Database** | MongoDB (Beanie ODM) | +| **AI Engine** | LangChain, LangGraph, Groq/OpenAI | +| **Search** | Firecrawl | + +--- + +## 🚀 Getting Started + +Launch your first mission involved setting up the "Brain" and the Interface. + +### Prerequisites +- Python 3.12+ +- Node.js 18+ +- MongoDB Instance + +### Quick Start +We found a magic script for you. + +```bash +# Clone the repo +git clone https://github.com/BEASTSHRIRAM/OutboundAI.git + +# Enter directory +cd outbound-ai + +# Make the start script executable +chmod +x start_dev.sh + +# Ignite 🚀 +./start_dev.sh +``` + +The **Backend** will come alive at `http://localhost:8000` (API Docs). +The **Frontend** will be ready at `http://localhost:5173`. + +--- + +## 🛡️ License +Private & Confidential. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..8efb68e --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y gcc curl && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for cache +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Expose port +EXPOSE 8000 + +# Run entrypoint +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/routers/contacts.py b/backend/app/routers/contacts.py index c1c8270..d00df11 100644 --- a/backend/app/routers/contacts.py +++ b/backend/app/routers/contacts.py @@ -5,7 +5,7 @@ from pydantic import BaseModel from app.models import ContactHistory, User -from app.core.auth import get_current_user +from app.api.deps import get_current_user router = APIRouter() diff --git a/backend/app/routers/reviews.py b/backend/app/routers/reviews.py index 3c45f23..4a02d35 100644 --- a/backend/app/routers/reviews.py +++ b/backend/app/routers/reviews.py @@ -1,7 +1,8 @@ from fastapi import APIRouter, Depends, HTTPException from typing import List -from app.models import Draft, DraftStatus, User, Prospect +from app.models import Draft, DraftStatus, User, Prospect, ContactHistory +from datetime import datetime from app.api.deps import get_current_user router = APIRouter() @@ -92,6 +93,34 @@ async def approve_draft(id: str, subject: str = None, body: str = None, user: Us ) await new_agent.insert() + + # 2.5 Record in Contact History (The "Fix" for missing contacts) + if draft.prospect_id: + prospect = await Prospect.get(draft.prospect_id) + if prospect and prospect.public_contact: + # Check for existing contact + email = prospect.public_contact.lower().strip() + existing_contact = await ContactHistory.find_one( + ContactHistory.user_id == user.clerk_id, + ContactHistory.prospect_email == email + ) + + if existing_contact: + existing_contact.last_contacted_at = datetime.utcnow() + existing_contact.last_mission_id = prospect.mission_id + existing_contact.total_emails_sent += 1 + await existing_contact.save() + else: + new_contact = ContactHistory( + user_id=user.clerk_id, + prospect_email=email, + prospect_name=prospect.name, + first_mission_id=prospect.mission_id, + last_mission_id=prospect.mission_id, + total_emails_sent=1 + ) + await new_contact.insert() + # 2. Attempt to Send Email via Composio (if connected) if user.gmail_connection_id: try: diff --git a/backend/app/routers/timeline.py b/backend/app/routers/timeline.py index bcde572..29b0a6e 100644 --- a/backend/app/routers/timeline.py +++ b/backend/app/routers/timeline.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta from beanie import PydanticObjectId -from app.models import EmailThread, EmailEvent, User +from app.models import EmailThread, EmailEvent, User, Mission, MissionLog from app.api.deps import get_current_user router = APIRouter() @@ -12,17 +12,15 @@ async def get_email_timeline( start_date: Optional[str] = Query(None), end_date: Optional[str] = Query(None), + limit: int = 100, user: User = Depends(get_current_user) ): """ - Get all email events for calendar display - Returns email threads with events in the specified date range + Get all events for calendar display (Email + Agent Activity) """ try: - # Build query - query = {"user_id": user.clerk_id} - - # Add date filtering if provided + # 1. Fetch Email Threads + email_query = {"user_id": user.clerk_id} if start_date or end_date: date_filter = {} if start_date: @@ -30,12 +28,10 @@ async def get_email_timeline( if end_date: date_filter["$lte"] = datetime.fromisoformat(end_date.replace('Z', '+00:00')) if date_filter: - query["last_activity"] = date_filter + email_query["last_activity"] = date_filter - # Fetch threads - threads = await EmailThread.find(query).to_list() + threads = await EmailThread.find(email_query).to_list() - # Format for calendar calendar_events = [] for thread in threads: for event in thread.events: @@ -53,6 +49,39 @@ async def get_email_timeline( "mission_id": thread.mission_id, "prospect_id": thread.prospect_id }) + + # 2. Fetch Mission Logs (Agent Activity) + # First get user missions + missions = await Mission.find({"user_id": user.clerk_id}).to_list() + mission_ids = [str(m.id) for m in missions] + + if mission_ids: + log_query = {"mission_id": {"$in": mission_ids}, "log_type": {"$in": ["action", "success", "error"]}} + if start_date or end_date: + date_filter = {} + if start_date: + date_filter["$gte"] = datetime.fromisoformat(start_date.replace('Z', '+00:00')) + if end_date: + date_filter["$lte"] = datetime.fromisoformat(end_date.replace('Z', '+00:00')) + if date_filter: + log_query["timestamp"] = date_filter + + logs = await MissionLog.find(log_query).sort("-timestamp").limit(limit).to_list() + + for log in logs: + calendar_events.append({ + "id": str(log.id), + "type": "agent_log", + "timestamp": log.timestamp.isoformat(), + "content": log.content, + "role": log.role, + "metadata": log.metadata, + "mission_id": log.mission_id, + "log_type": log.log_type + }) + + # Sort combined events by timestamp desc + calendar_events.sort(key=lambda x: x["timestamp"], reverse=True) return calendar_events diff --git a/backend/clear_data.py b/backend/clear_data.py new file mode 100644 index 0000000..82a9899 --- /dev/null +++ b/backend/clear_data.py @@ -0,0 +1,40 @@ + +import asyncio +from motor.motor_asyncio import AsyncIOMotorClient +from beanie import init_beanie +from app.core.config import settings +from app.models import Mission, Prospect, Draft, MissionLog, EmailThread, ContactHistory, UserAsset + +async def clear_data(): + print("Connecting to MongoDB...") + client = AsyncIOMotorClient(settings.MONGODB_URI, tlsAllowInvalidCertificates=True) + await init_beanie(database=client.outbound_ai, document_models=[Mission, Prospect, Draft, MissionLog, EmailThread, ContactHistory, UserAsset]) + + print("Clearing transactional data...") + + await Mission.delete_all() + print("- Missions cleared") + + await Prospect.delete_all() + print("- Prospects cleared") + + await Draft.delete_all() + print("- Drafts cleared") + + await MissionLog.delete_all() + print("- MissionLogs cleared") + + await EmailThread.delete_all() + print("- EmailThreads cleared") + + await ContactHistory.delete_all() + print("- ContactHistory cleared") + + await UserAsset.delete_all() + print("- UserAssets cleared") + + print("NOTE: Users and Agents configurations were preserved.") + print("Done.") + +if __name__ == "__main__": + asyncio.run(clear_data()) diff --git a/backend/create_token.py b/backend/create_token.py new file mode 100644 index 0000000..d7c9318 --- /dev/null +++ b/backend/create_token.py @@ -0,0 +1,9 @@ +import jwt +import time + +token = jwt.encode( + {"sub": "user_2test123", "email": "test@example.com", "exp": time.time() + 3600}, + "secret", + algorithm="HS256" +) +print(token) diff --git a/backend/fetch_draft.py b/backend/fetch_draft.py new file mode 100644 index 0000000..f655602 --- /dev/null +++ b/backend/fetch_draft.py @@ -0,0 +1,5 @@ +import requests +headers = {"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzJ0ZXN0MTIzIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxNzY5NTIyMDYwLjU3MTg1fQ.fqBpUFLwTViy_sP5fbBW2uLM8FnCTzCsdevoNV_AvBg"} +r = requests.get("http://127.0.0.1:8000/api/v1/reviews/pending", headers=headers) +with open("draft_output.txt", "w") as f: + f.write(r.text) diff --git a/backend/verify_timeline_backend.py b/backend/verify_timeline_backend.py new file mode 100644 index 0000000..f741306 --- /dev/null +++ b/backend/verify_timeline_backend.py @@ -0,0 +1,37 @@ +import httpx +import asyncio +import jwt +import time + +def create_test_token(): + return jwt.encode( + {"sub": "user_2test123", "email": "test@example.com", "exp": time.time() + 3600}, + "secret", + algorithm="HS256" + ) + +async def verify_timeline(): + # 1. Get Token + token = create_test_token() + headers = {"Authorization": f"Bearer {token}"} + + # 2. Call Timeline Endpoint + async with httpx.AsyncClient() as client: + try: + print("Fetching timeline...") + response = await client.get("http://localhost:8000/api/v1/timeline", headers=headers) + + if response.status_code == 200: + print("SUCCESS: Timeline API is working.") + data = response.json() + print(f"Items found: {len(data)}") + print(data) + else: + print(f"ERROR: Failed with status {response.status_code}") + print(response.text) + + except Exception as e: + print(f"EXCEPTION: {e}") + +if __name__ == "__main__": + asyncio.run(verify_timeline()) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a564f48 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +version: '3.8' + +services: + backend: + build: ./backend + container_name: outbound-ai-backend + restart: always + env_file: + - .env.prod + ports: + - "8000:8000" + depends_on: + - mongodb + + frontend: + build: ./frontend + container_name: outbound-ai-frontend + restart: always + ports: + - "80:80" + depends_on: + - backend + + mongodb: + image: mongo:latest + container_name: outbound-ai-mongo + restart: always + volumes: + - mongo_data:/data/db + ports: + - "27017:27017" + +volumes: + mongo_data: diff --git a/draft_output.txt b/draft_output.txt new file mode 100644 index 0000000..61bf7d5 --- /dev/null +++ b/draft_output.txt @@ -0,0 +1 @@ +[{"id":"696e203021cc312dbb943dda","prospect_id":"696e203021cc312dbb943dd9","subject":"Question for Steve Treacy","body":"Hi Steve Treacy,\n\nI noticed your work at Unknown and wanted to connect.\n\nWould you be open to a quick chat?\n\nBest","ai_reasoning":"Fallback template due to API error: Error code: 400 - {'error': {'message': 'The model `qwen-2.5-32b` has been decommissioned and is no ","status":"PENDING","name":"Steve Treacy","company":"Unknown","scraped_data":{"name":"Steve Treacy","company":"Unknown","linkedin":"https://www.linkedin.com/in/steve-treacy-16669440","snippet":"I'm looking to find driven talent to become the CEOs and CTOs of the next generation of Vertical Ai Companies. ... Scaled all GTM teams through Series A, B & C ...","source":"Firecrawl Search","enriched":false}},{"id":"696e210e646d807007996bf0","prospect_id":"696e210e646d807007996bef","subject":"Question for Hi Hi","body":"Hi Hi Hi,\n\nI noticed your work at The University of Calgary and wanted to connect.\n\nWould you be open to a quick chat?\n\nBest","ai_reasoning":"Fallback template due to API error: Error code: 400 - {'error': {'message': 'The model `qwen-2.5-32b` has been decommissioned and is no ","status":"PENDING","name":"Hi Hi","company":"The University of Calgary","scraped_data":{"name":"Hi Hi","company":"The University of Calgary","linkedin":"https://ca.linkedin.com/in/hi-hi-019059a2","snippet":"Student at The University of Calgary · Education: University of Calgary · Location: Calgary. View Hi Hi's profile on LinkedIn, a professional community of 1 ...","source":"Firecrawl Search","enriched":false}},{"id":"696e298bbc2f35c5fc26a21d","prospect_id":"696e298bbc2f35c5fc26a21c","subject":"Exploring Recruitment Opportunities at Your Company","body":"Hi Ishita, I came across your LinkedIn profile and noticed your unique background in HR and aspiring software engineering career. I'm curious to know if your company is currently recruiting for software developer intern roles, and if so, whether you'd be open to discussing potential recruitment partnerships. If you're available, I'd love to schedule a quick call to explore further.","ai_reasoning":"This approach works because it references something specific about the prospect, shows genuine interest in their career and company, and proposes a low-friction call to action that can lead to a more meaningful conversation.","status":"PENDING","name":"Ishita Gupta","company":"Unknown","scraped_data":{"name":"Ishita Gupta","company":"Unknown","linkedin":"https://ca.linkedin.com/in/ishita-gupta-523859215","snippet":"Ishita Gupta. Human Resources Assistant | Aspiring Software engineer | Actively seeking for a software developer role. IKEA Seneca College. North York, Ontario ...","source":"Firecrawl Search","enriched":false}},{"id":"696e30638e2f6e41ff06532e","prospect_id":"696e30638e2f6e41ff06532d","subject":"Exploring Opportunities at University of Calgary","body":"Hi Hi, I came across your profile on LinkedIn and noticed you're a student at the University of Calgary. I'd love to learn more about your experiences and explore potential opportunities that might be a good fit for you. Would you be open to a quick call to discuss?","ai_reasoning":"This approach works because it's brief, personalized, and shows genuine interest in the prospect's experiences, making it more likely to spark a conversation.","status":"PENDING","name":"Hi Hi","company":"The University of Calgary","scraped_data":{"name":"Hi Hi","company":"The University of Calgary","linkedin":"https://ca.linkedin.com/in/hi-hi-019059a2","snippet":"Student at The University of Calgary · Education: University of Calgary · Location: Calgary. View Hi Hi's profile on LinkedIn, a professional community of 1 ...","source":"Firecrawl Search","enriched":false}},{"id":"696e30ab042a4d29b0bf4ea4","prospect_id":"696e30ab042a4d29b0bf4ea3","subject":"Exploring Opportunities at University of Calgary","body":"Hi Hi, I came across your profile on LinkedIn and noticed you're a student at the University of Calgary. I'm curious to learn more about your experiences and interests. Would you be open to a quick 10-minute call to discuss potential opportunities?","ai_reasoning":"This approach works because it's brief, personalized, and shows genuine interest in the prospect's experiences, making it more likely to spark a conversation.","status":"PENDING","name":"Hi Hi","company":"The University of Calgary","scraped_data":{"name":"Hi Hi","company":"The University of Calgary","linkedin":"https://ca.linkedin.com/in/hi-hi-019059a2","snippet":"Student at The University of Calgary · Education: University of Calgary · Location: Calgary. View Hi Hi's profile on LinkedIn, a professional community of 1 ...","source":"Firecrawl Search","enriched":false}},{"id":"696e3495042a4d29b0bf4eb7","prospect_id":"696e3495042a4d29b0bf4eb6","subject":"Scaling Logistics Startups with Top Engineering Talent","body":"Hi Md. Hasan, I came across your work empowering founders to build sustainable people operations and was impressed by the impact you've had on 50+ startups. I'm on a mission to connect with founders of logistics startups who are currently hiring for engineering roles - would you be open to a quick call to explore potential synergies?","ai_reasoning":"This approach works because it references the prospect's specific experience and expertise, while also showing genuine interest in exploring potential collaboration opportunities, making the ask feel more like a conversation starter than a sales pitch.","status":"PENDING","name":"Md. Hasan","company":"Unknown","scraped_data":{"name":"Md. Hasan","company":"Unknown","linkedin":"https://vn.linkedin.com/in/md-hasan-hr","snippet":"Global HR & Recruitment Partner for Startups, Tech & SMEs | Empowered 50+ Founders to Build Sustainable People Operations, Drive Performance & Achieve Team ...","source":"Firecrawl Search","enriched":false}},{"id":"696e39a4ec1c27ef1e11ecc6","prospect_id":"696e39a4ec1c27ef1e11ecc5","subject":"Scaling Recruiting Efforts with Innovative Tech","body":"Hi Jeff, I came across your LinkedIn profile and was impressed by your passion for connecting human resources, recruiting, and technology. As someone who's likely familiar with the challenges of scaling a recruiting team, I'd love to explore how our solutions can help streamline your hiring process. Would you be open to a quick call to discuss your current recruiting strategy and see if our tech can support your growth?","ai_reasoning":"This approach works because it references the prospect's specific interests and expertise, while also highlighting a relevant pain point that the solution can address, making the call to action more compelling and personalized.","status":"PENDING","name":"Jeff Waldman","company":"Unknown","scraped_data":{"name":"Jeff Waldman","company":"Unknown","linkedin":"https://ca.linkedin.com/in/jeffwaldmanhr","snippet":"Unwavering passion and enthusiasm for how human resources, recruiting, technology and marketing connect. Relationship builder. Avid people connector.","source":"Firecrawl Search","enriched":false}},{"id":"696e3a67ec1c27ef1e11ecd3","prospect_id":"696e3a67ec1c27ef1e11ecd2","subject":"Growing Your Tech Team in Bangalore","body":"Hi Surabhi, I came across your profile and noticed your expertise in connecting innovative tech companies with top talent. I'm currently helping some exciting startups in Bangalore find the best software engineers to drive their growth. Would love to explore if any of your clients could be a good fit - can we schedule a quick call to discuss?","ai_reasoning":"This approach works because it references Surabhi's specific expertise and offers a low-friction call to action, allowing her to potentially benefit from the conversation while also being concise and non-salesy.","status":"PENDING","name":"Surabhi Suman","company":"Unknown","scraped_data":{"name":"Surabhi Suman","company":"Unknown","linkedin":"https://uk.linkedin.com/in/surabhisuman101","snippet":"As a specialist recruiter, I excel in connecting fast-growing and innovative tech companies with top-tier talent across major tech hubs.","source":"Firecrawl Search","enriched":false}},{"id":"696e4093ec1c27ef1e11ecdc","prospect_id":"696e4093ec1c27ef1e11ecdb","subject":"Exploring Opportunities for Software Engineers in Bangalore","body":"Hi Abhilash, I came across your LinkedIn profile and noticed your experience in software development, particularly your time at FirstCourse and your own startup. I'm reaching out because I'm currently working with a few startup companies in Bangalore that are actively hiring talented software engineers. Would you be open to introducing me to the hiring managers or HR reps at your network, or exploring potential job openings yourself? Let's schedule a quick call to discuss further.","ai_reasoning":"This approach works because it references the prospect's specific experience and interests, while also showing that I've taken the time to research them, making the email more personalized and increasing the likelihood of a response.","status":"PENDING","name":"Abhilash Peddinti","company":"Unknown","scraped_data":{"name":"Abhilash Peddinti","company":"Unknown","linkedin":"https://www.linkedin.com/in/abhilash7","snippet":"I am looking for opportunities in Software development. I have 2 years of experience working as a Software Developer in FirstCourse and in my own startup ...","source":"Firecrawl Search","enriched":false}},{"id":"697362a117e0d399c0d945da","prospect_id":"697362a117e0d399c0d945d8","subject":"Growing Tech Teams at Target","body":"Hi Kelly, I came across your work connecting top Software Engineers with opportunities at Target and was impressed by the company's commitment to innovation. I'd love to explore how your experience could inform our own efforts to support growing tech companies like Target. Can we schedule a quick call to discuss? Looking forward to hearing from you.","ai_reasoning":"This approach works because it references a specific aspect of the prospect's work, shows genuine interest in their experience, and invites a low-friction conversation to explore potential synergies.","status":"PENDING","name":"Kelly Magnuson","company":"Unknown","scraped_data":{"name":"Kelly Magnuson","company":"Unknown","linkedin":"https://www.linkedin.com/in/kelly-magnuson","snippet":"Connecting talented Software Engineers with meaningful careers at Target Corporation. Visit our careers website at www.target.com/careers for more information.","source":"Firecrawl Search","enriched":false}},{"id":"697362a117e0d399c0d945de","prospect_id":"697362a117e0d399c0d945dd","subject":"Scaling GTM Teams with the Right Tech Leaders","body":"Hi Steve, I came across your LinkedIn post about building the next generation of Vertical Ai Companies and was impressed by your experience scaling GTM teams through Series A, B, & C. I'm reaching out because I'm working with a number of Series A startups who are looking for talented CTOs to drive their growth. Would love to schedule a quick call to discuss whether we might be able to help you find the right tech leaders for your portfolio companies.","ai_reasoning":"This approach works because it references something specific about the prospect, showing that the email is personalized, and also presents a potential solution to a problem the prospect is likely to care about, making the call to action more compelling.","status":"PENDING","name":"Steve Treacy","company":"Unknown","scraped_data":{"name":"Steve Treacy","company":"Unknown","linkedin":"https://www.linkedin.com/in/steve-treacy-16669440","snippet":"I'm looking to find driven talent to become the CEOs and CTOs of the next generation of Vertical Ai Companies. ... Scaled all GTM teams through Series A, B & C ...","source":"Firecrawl Search","enriched":false}},{"id":"697362a217e0d399c0d945e5","prospect_id":"697362a217e0d399c0d945e3","subject":"Exciting Tech Talent Opportunities at Target","body":"Hi Kelly, I came across your work connecting software engineers with roles at Target and was impressed by the company's commitment to innovation. I'd love to explore how we can help other growing tech companies like yours attract and hire top talent. Can we schedule a quick call to discuss? You can book a time that works for you here: [calendly link].","ai_reasoning":"This approach works because it references Kelly's specific work and shows genuine interest in exploring how to help similar companies, making the outreach more personalized and relevant.","status":"PENDING","name":"Kelly Magnuson","company":"Unknown","scraped_data":{"name":"Kelly Magnuson","company":"Unknown","linkedin":"https://www.linkedin.com/in/kelly-magnuson","snippet":"Connecting talented Software Engineers with meaningful careers at Target Corporation. Visit our careers website at www.target.com/careers for more information.","source":"Firecrawl Search","enriched":false}},{"id":"697362a417e0d399c0d945ee","prospect_id":"697362a417e0d399c0d945ed","subject":"Congratulations on Your Impact, Bosky","body":"Hi Bosky, I came across your impressive work helping women reach executive leadership positions and was blown away by the $5.5M in salary raises you've facilitated. I'm on the hunt for recently promoted VP and C-Suite executives, and I'd love to learn more about your network. Would you be open to a quick call to discuss?","ai_reasoning":"This approach works because it references something specific about the prospect's accomplishments, showing that the SDR has taken the time to research and understand their work, making the outreach more personalized and increasing the likelihood of a response.","status":"PENDING","name":"Bosky Mukherjee","company":"Unknown","scraped_data":{"name":"Bosky Mukherjee","company":"Unknown","linkedin":"https://www.linkedin.com/in/bosky","snippet":"I help women become Dir/VP/C-Suite Leaders | Former C-Suite Exec | Ex-Atlassian | 5000+ client promotions | $5.5M in salary raises | FREE Workshop Feb 24 ...","source":"Firecrawl Search","enriched":false}},{"id":"69737b7f4da443c6556c203c","prospect_id":"69737b7f4da443c6556c203b","subject":"Exploring Cybersecurity Opportunities at F-Secure","body":"Hi Dana, I came across your profile on LinkedIn and noticed your experience at F-Secure Corporation in the Philippines. I'd love to learn more about the company's current cybersecurity initiatives and see if there are any areas where we could potentially support you. Would you be open to a quick 10-minute call to discuss?","ai_reasoning":"This approach works because it's brief, personalized, and shows genuine interest in the prospect's company and experience, making it more likely to grab Dana's attention and spark a conversation.","status":"PENDING","name":"dana reyes","company":"F-Secure Corporation","scraped_data":{"name":"dana reyes","company":"F-Secure Corporation","linkedin":"https://www.linkedin.com/in/dana-reyes-37a53b50","snippet":"jgjf at F-Secure Corporation · Experience: F-Secure Corporation · Location: Philippines. View dana reyes' profile on LinkedIn, a professional community of 1 ...","source":"Firecrawl Search","enriched":false}},{"id":"697383818e5719284f5f5a2d","prospect_id":"697383818e5719284f5f5a2c","subject":"Love Your Work on GitHub","body":"Hi Alice, I came across your GitHub profile and really enjoyed the projects you've shared - the creativity and skill are impressive. I'd love to learn more about what you're working on and see if there are any potential synergies with TechCorp. Would you be open to a quick call to discuss? You can reach me at this email address or we could schedule a time that works for you.","ai_reasoning":"This approach works because it references something specific about the prospect, showing that the sender has taken the time to research and understand their interests, and invites a low-friction call to action to start a conversation.","status":"PENDING","name":"Alice Smith","company":"TechCorp","scraped_data":{"name":"Alice Smith","company":"TechCorp","linkedin":"linkedin.com/in/alice","source":"Mock","enriched":false}},{"id":"697383eb8e5719284f5f5a36","prospect_id":"697383eb8e5719284f5f5a35","subject":"Loving the Work at ParentsCanada","body":"Hi Amy, I came across your work at Hello! Canada and was impressed by the range of prenatal and postnatal magazines you oversee. Your editorial expertise is truly making a positive impact on families across Canada. I'd love to say hello and learn more about your current projects - are you open to a quick call this week?","ai_reasoning":"This approach works because it references something specific about the prospect, showing that the SDR has taken the time to research and understand their work, making the outreach more personal and increasing the likelihood of a response.","status":"PENDING","name":"Amy Bielby","company":"Hello! Canada","scraped_data":{"name":"Amy Bielby","company":"Hello! Canada","linkedin":"https://ca.linkedin.com/in/amybielby","snippet":"I am the editor of the prenatal and postnatal magazines falling under the ParentsCanada banner: Expecting, Labour and Birth, Baby's First Years, Best Wishes and ...","source":"Firecrawl Search","enriched":false}},{"id":"69738594163b4890277c1408","prospect_id":"69738594163b4890277c1407","subject":"Scaling Your Next Venture with Top Tech Talent","body":"Hi Steve, I came across your LinkedIn post about building the next generation of Vertical Ai Companies and was impressed by your experience scaling GTM teams through Series A, B, & C. I'd love to explore how I can help you find and connect with exceptional CTOs who can drive your portfolio companies forward. Can we schedule a quick call to discuss your current talent needs?","ai_reasoning":"This approach works because it references a specific aspect of the prospect's LinkedIn profile, showing that the outreach is personalized and relevant to their interests, and also clearly states the value proposition and call to action.","status":"PENDING","name":"Steve Treacy","company":"Unknown","scraped_data":{"name":"Steve Treacy","company":"Unknown","linkedin":"https://www.linkedin.com/in/steve-treacy-16669440","snippet":"I'm looking to find driven talent to become the CEOs and CTOs of the next generation of Vertical Ai Companies. ... Scaled all GTM teams through Series A, B & C ...","source":"Firecrawl Search","enriched":false}},{"id":"697670394b1bf80995284bb3","prospect_id":"697670394b1bf80995284bb2","subject":"Expressed Interest in Software Development Engineer Intern Role at [Company Name]","body":"Dear [HR's Name], \n\nI came across [Company Name] and noticed that you are currently hiring for a Software Development Engineer (SDE) intern. I am excited about the opportunity to contribute to a dynamic team like yours and am confident that my skills and passion for software development would make me a great fit for this role.\n\nWith [Number] years of experience in programming and a strong foundation in computer science, I am eager to apply my knowledge and skills in a real-world setting. I am particularly drawn to [Company Name] because of its [Reason for interest in company, e.g., innovative approach to problem-solving, commitment to excellence, etc.].\n\nI would be thrilled to discuss my application and how I can contribute to the team's success. Please find my resume attached for your review. I look forward to the opportunity to speak with you further about this position.\n\nThank you for considering my application. I am excited at the prospect of joining [Company Name] and contributing to the company's success.\n\nBest regards,\n[Your Name]","ai_reasoning":"The email is personalized by addressing the HR by name and referencing the specific company and job title. This shows that the sender has taken the time to research the company and tailor their message accordingly. The email also highlights the sender's relevant skills and experience, demonstrating their potential to contribute to the company's success. By expressing enthusiasm for the company and role, the sender conveys their genuine interest in the position, increasing the likelihood of a response from the HR. The attachment of a resume provides the HR with easy access to the sender's qualifications, making it simpler for them to consider the application.","status":"PENDING","name":"How to Send Cold Emails to Get","company":"Unknown Company","scraped_data":{}},{"id":"6977453ede5a150b1fc19ed0","prospect_id":"6977453ede5a150b1fc19ecf","subject":"Love Your Experimental Approach, Tyler","body":"Hi Tyler, I came across your LinkedIn post and loved the honesty and creativity behind your approach to landing an internship. I'm curious to know more about your journey and see if I can offer any help or guidance. Would you be open to a quick call to chat about your goals and see if there's anything I can do to support you?","ai_reasoning":"This approach works because it references something specific about the prospect, showing that I've taken the time to research and understand their situation, and offers a low-friction call to action that prioritizes helping the prospect rather than making a sale.","status":"PENDING","name":"Tyler Wang","company":"Unknown","scraped_data":{"name":"Tyler Wang","company":"Unknown","linkedin":"https://ca.linkedin.com/in/tyler-wang-253b07398","snippet":"Experimenting to get an internship so you don't have to | follow my journey · this is my throwaway account to try things and see if i can get a job this ...","source":"Firecrawl Search","enriched":false}},{"id":"69777c9230d76017a44b6627","prospect_id":"69777c9230d76017a44b6626","subject":"Let's Get You Hired - Expert Guidance and Support","body":"Dear [Target Contact],\n\nI hope this email finds you well. I came across your profile and was impressed with your background and skills. As an expert outreach specialist, my mission is to help individuals like you achieve their career goals. I understand that you're looking for assistance in getting a job, and I'm more than happy to help.\n\nWith my expertise and knowledge of the job market, I can provide you with personalized guidance and support to increase your chances of landing your dream job. Whether it's resume building, interview preparation, or networking, I'm here to help you every step of the way.\n\nIf you're interested in learning more about how I can assist you, I'd love to schedule a call to discuss your goals and create a tailored plan to help you achieve them. Please let me know a convenient time for you, and I'll make sure to schedule it accordingly.\n\nLooking forward to hearing from you and helping you get one step closer to your dream job.\n\nBest regards,\n[Your Name]","ai_reasoning":"The email is personalized to address the target contact's need for help in getting a job, as indicated by the snippet \"help me get a job\". The subject line is attention-grabbing and clearly communicates the purpose of the email. The email body provides a brief introduction, offers expert guidance and support, and invites the target contact to discuss their goals and create a plan. The tone is professional and helpful, aiming to establish a connection and provide value to the target contact.","status":"PENDING","name":"Target Contact","company":"Unknown","scraped_data":{}},{"id":"6978b59a15b71a3619cdfa93","prospect_id":"6978b59915b71a3619cdfa92","subject":"Exploring Opportunities for Collaboration with Google's Software Engineering Team","body":"Dear Moises Silva,\n\nI came across your profile and was impressed by your extensive experience as a software engineering leader and your background in building on various stacks. Your accomplishments in the field are truly noteworthy, and I believe your expertise would be a valuable asset to our network.\n\nAs someone who has had a long and successful career in software engineering, I would love to learn more about your current work at Google and explore potential opportunities for collaboration. Your insights and perspectives on the latest developments in software engineering would be highly beneficial to our mission.\n\nWould you be open to a brief discussion about your work and how we might be able to support each other's goals? I'm confident that your experience and knowledge would be a great addition to our community of software engineers.\n\nBest regards,\n[Your Name]","ai_reasoning":"The email is personalized to Moises Silva, addressing his experience and background as a software engineering leader. The message is brief and to the point, showing genuine interest in his work and expertise. By mentioning his accomplishments and expressing admiration for his career, the email aims to establish a connection and build rapport. The call to action is a gentle invitation for a discussion, allowing Moises to decide if he's interested in exploring potential collaboration opportunities. The overall tone is professional and respectful, making it more likely to receive a positive response.","status":"PENDING","name":"Moises Silva","company":"Google","scraped_data":{}}] \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..6c6276c --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,23 @@ +# Build Stage +FROM node:18-alpine as build + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +# Production Stage +FROM nginx:alpine + +# Copy built assets to nginx +COPY --from=build /app/dist /usr/share/nginx/html + +# Copy custom nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..a6a7490 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,27 @@ +server { + listen 80; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + # Proxy API requests to backend + location /api/ { + proxy_pass http://backend:8000; + 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; + } + + # Proxy WebSocket connections + location /ws/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } +} diff --git a/frontend/src/components/calendar/CalendarEventCard.tsx b/frontend/src/components/calendar/CalendarEventCard.tsx new file mode 100644 index 0000000..8d6cdad --- /dev/null +++ b/frontend/src/components/calendar/CalendarEventCard.tsx @@ -0,0 +1,72 @@ +import { cn } from "@/lib/utils"; +import { Mail, Zap, Play, CheckCircle, Bot } from "lucide-react"; +import { useMemo } from "react"; + +interface CalendarEventCardProps { + item: any; + onClick?: () => void; +} + +export function CalendarEventCard({ item, onClick }: CalendarEventCardProps) { + const config = useMemo(() => { + switch (item.type) { + case 'mission': + return { + icon: Play, + color: "text-emerald-400", + bg: "bg-emerald-500/10", + border: "border-emerald-500/20", + label: "Mission" + }; + case 'draft': + return { + icon: Mail, + color: "text-cyan-400", + bg: "bg-cyan-500/10", + border: "border-cyan-500/20", + label: "Draft" + }; + case 'email': + return { + icon: Zap, + color: "text-purple-400", + bg: "bg-purple-500/10", + border: "border-purple-500/20", + label: "Activity" + }; + case 'agent_log': + return { + icon: Bot, + color: "text-indigo-400", + bg: "bg-indigo-500/10", + border: "border-indigo-500/20", + label: "Agent Log" + }; + default: + return { + icon: CheckCircle, + color: "text-zinc-400", + bg: "bg-zinc-500/10", + border: "border-zinc-500/20", + label: "Event" + }; + } + }, [item.type]); + + return ( +
+ + {item.content || item.objective || item.subject || item.preview || "Event"} + + {/* Hover visual */} +
+
+ ); +} diff --git a/frontend/src/components/launchpad/HeroInput.tsx b/frontend/src/components/launchpad/HeroInput.tsx index 519f071..66913c1 100644 --- a/frontend/src/components/launchpad/HeroInput.tsx +++ b/frontend/src/components/launchpad/HeroInput.tsx @@ -1,10 +1,11 @@ -import { useState } from "react"; -import { Sparkles, ArrowRight, Loader2, Paperclip, ChevronDown, ChevronUp } from "lucide-react"; +import { useState, useEffect } from "react"; +import { Sparkles, ArrowRight, Loader2, Paperclip, ChevronDown, ChevronUp, Users, Search, PenTool, Database } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { useApi } from "@/lib/api"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; export function HeroInput() { const [query, setQuery] = useState(""); @@ -14,11 +15,28 @@ export function HeroInput() { const api = useApi(); const navigate = useNavigate(); + // Agent Squad State + const [selectedAgents, setSelectedAgents] = useState({ + researcher: true, + enricher: true, + copywriter: true, + }); + // Asset picker state const [showAssetPicker, setShowAssetPicker] = useState(false); const [availableAssets, setAvailableAssets] = useState([]); const [selectedAttachments, setSelectedAttachments] = useState([]); + const toggleAgent = (agent: keyof typeof selectedAgents) => { + const newState = { ...selectedAgents, [agent]: !selectedAgents[agent] }; + setSelectedAgents(newState); + + // Dispatch event to update map immediately + window.dispatchEvent(new CustomEvent('updateAgentConfig', { + detail: newState + })); + }; + const handleInputChange = async (e: React.ChangeEvent) => { const value = e.target.value; setQuery(value); @@ -26,7 +44,7 @@ export function HeroInput() { // Trigger workflow display when user types if (value.trim().length > 0) { window.dispatchEvent(new CustomEvent('showWorkflow', { - detail: { show: true, objective: value } + detail: { show: true, objective: value, agents: selectedAgents } })); } else { window.dispatchEvent(new CustomEvent('showWorkflow', { @@ -70,7 +88,10 @@ export function HeroInput() { objective += ` [Attachments: ${selectedAttachments.map(a => a.filename).join(', ')}]`; } - const mission = await api.createMission(objective); + // Add active agents to metadata (backend support needed later, currently logic is inferred) + const agentConfig = `[Agents: ${Object.entries(selectedAgents).filter(([_, v]) => v).map(([k]) => k).join(', ')}]`; + + const mission = await api.createMission(objective + " " + agentConfig); setQuery(""); setSelectedAttachments([]); toast.success("Mission Launched!", { @@ -146,6 +167,32 @@ export function HeroInput() {
)} + {/* Agent Squad Selection */} +
+ Deploy Squad: + toggleAgent('researcher')} + > + Research + + toggleAgent('enricher')} + > + Data + + toggleAgent('copywriter')} + > + Copy + +
+
{/* Asset Picker Dropdown */} {showAssetPicker && ( diff --git a/frontend/src/components/launchpad/LiveMissionCard.tsx b/frontend/src/components/launchpad/LiveMissionCard.tsx new file mode 100644 index 0000000..b3618c1 --- /dev/null +++ b/frontend/src/components/launchpad/LiveMissionCard.tsx @@ -0,0 +1,160 @@ +import { cn } from "@/lib/utils"; +import { Progress } from "@/components/ui/progress"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Terminal, Users, Mail, MoreVertical, Square, Trash2, Activity, Clock } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { useState, useEffect } from "react"; + +interface MissionCardProps { + id?: string; + name: string; + status: "running" | "paused" | "completed" | "error" | "stopped"; + stage: number; + totalStages: number; + prospectsFound: number; + emailsQueued: number; + startedAt: string; + onStop?: (id: string) => void; + onDelete?: (id: string) => void; +} + +const statusConfig = { + running: { label: "ACTIVE", color: "text-emerald-400", dotClass: "bg-emerald-400", glow: "shadow-[0_0_10px_rgba(52,211,153,0.5)]" }, + paused: { label: "PAUSED", color: "text-amber-400", dotClass: "bg-amber-400", glow: "" }, + completed: { label: "DONE", color: "text-blue-400", dotClass: "bg-blue-400", glow: "" }, + error: { label: "ERROR", color: "text-red-400", dotClass: "bg-red-400", glow: "" }, + stopped: { label: "STOPPED", color: "text-zinc-500", dotClass: "bg-zinc-500", glow: "" }, +}; + +// Mock logs for liveliness effect +const MOCK_LOGS = [ + "Scouting LinkedIn profiles...", + "Analyzing relevance...", + "Drafting outreach...", + "Verifying email integrity...", + "Context matching: High...", + "Rate limit check: OK...", +]; + +export function LiveMissionCard({ + id, + name, + status, + stage, + totalStages, + prospectsFound, + emailsQueued, + startedAt, + onStop, + onDelete, +}: MissionCardProps) { + const progress = (stage / totalStages) * 100; + const config = statusConfig[status] || statusConfig.running; + const navigate = useNavigate(); + const [logLine, setLogLine] = useState("Initializing..."); + + // Simulated "Live Brain" effect + useEffect(() => { + if (status !== 'running') return; + + const interval = setInterval(() => { + const randomLog = MOCK_LOGS[Math.floor(Math.random() * MOCK_LOGS.length)]; + setLogLine(`> ${randomLog} [${new Date().toLocaleTimeString()}]`); + }, 2500); + + return () => clearInterval(interval); + }, [status]); + + const handleCardClick = (e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest('[data-no-navigate]')) return; + if (id) navigate(`/chat/${id}`); + }; + + return ( +
+ {/* Active Scanline Effect */} + {status === 'running' && ( +
+ )} + +
+
+

Target Objective

+

{name}

+
+ +
+
+
+ {config.label} +
+ + + + + + + {status === "running" && onStop && id && ( + { e.stopPropagation(); onStop(id); }} className="cursor-pointer hover:bg-zinc-800"> + Stop Mission + + )} + {onDelete && id && ( + { e.stopPropagation(); onDelete(id); }} className="cursor-pointer text-red-400 hover:bg-red-400/10 hover:text-red-300"> + Delete Data + + )} + + +
+
+ + {/* Stats Grid */} +
+
+
+ + Prospects +
+ {prospectsFound} +
+
+
+ + Drafts +
+ {emailsQueued} +
+
+ + {/* Live Terminal */} +
+
System ready.
+
+ + {logLine} +
+
+ +
+
+ + {startedAt} +
+ ID: {id?.slice(-4).toUpperCase()} +
+
+ ); +} diff --git a/frontend/src/pages/Calendar.tsx b/frontend/src/pages/Calendar.tsx index 1e1590d..c21b839 100644 --- a/frontend/src/pages/Calendar.tsx +++ b/frontend/src/pages/Calendar.tsx @@ -1,16 +1,11 @@ import { useState, useEffect } from "react"; -import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isSameDay, addMonths, subMonths, getDay } from "date-fns"; -import { ChevronLeft, ChevronRight, Plus, Calendar as CalendarIcon, Loader2, Mail } from "lucide-react"; +import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, addMonths, subMonths, getDay, isToday } from "date-fns"; +import { ChevronLeft, ChevronRight, Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { useApi } from "@/lib/api"; import { useNavigate } from "react-router-dom"; -import { Badge } from "@/components/ui/badge"; -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@/components/ui/hover-card"; +import { CalendarEventCard } from "@/components/calendar/CalendarEventCard"; export default function Calendar() { const [currentDate, setCurrentDate] = useState(new Date()); @@ -42,14 +37,13 @@ export default function Calendar() { date: d.due_date ? new Date(d.due_date) : new Date() // Default to today if no due date })); - // Normalize email events - const normalizedEmails = (emailTimeline || []).map((event: any) => ({ + // Normalize email events & agent logs + const timelineEvents = (emailTimeline || []).map((event: any) => ({ ...event, - type: 'email', date: new Date(event.timestamp) })); - setItems([...normalizedMissions, ...normalizedDrafts, ...normalizedEmails]); + setItems([...normalizedMissions, ...normalizedDrafts, ...timelineEvents]); } catch (error) { console.error("Failed to fetch calendar data:", error); } finally { @@ -61,7 +55,7 @@ export default function Calendar() { const nextMonth = () => setCurrentDate(addMonths(currentDate, 1)); const prevMonth = () => setCurrentDate(subMonths(currentDate, 1)); - const today = () => setCurrentDate(new Date()); + const handleToday = () => setCurrentDate(new Date()); const monthStart = startOfMonth(currentDate); const monthEnd = endOfMonth(currentDate); @@ -75,45 +69,45 @@ export default function Calendar() { }; return ( -
+
+ {/* Header */} -
+
-
- - + {format(currentDate, "MMMM yyyy")} -
- +
-
- Missions - Drafts - Sent - Reply - Follow-up +
+ Missions + Drafts + Activity
-
{/* Calendar Grid */} -
+
{/* Days Header */} -
+
{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map(day => ( -
+
{day}
))} @@ -121,138 +115,59 @@ export default function Calendar() { {/* Days Grid */}
- {/* Padding Days from previous month */} + {/* Padding Days */} {paddingDays.map((_, i) => ( -
+
))} {/* Actual Days */} {daysInMonth.map(day => { const dayItems = getItemsForDay(day); - const isToday = isSameDay(day, new Date()); + const todayIsDay = isToday(day); + + // Heatmap logic: Calculate intensity based on items + const intensity = Math.min(dayItems.length * 10, 50); + const bgStyle = todayIsDay + ? {} + : { backgroundColor: `rgba(59, 130, 246, ${intensity / 500})` }; // Subtle blue tint based on activity return (
{format(day, "d")}
-
- {dayItems.map((item, idx) => { - // Handle missions - if (item.type === 'mission') { - return ( -
navigate(`/chat/${item._id || item.id}`)} - className={cn( - "text-xs px-2 py-1.5 rounded-md cursor-pointer border truncate transition-all hover:scale-[1.02]", - item.status === "running" ? "bg-primary/15 text-primary border-primary/20 hover:bg-primary/25" : - item.status === "completed" ? "bg-success/15 text-success border-success/20 hover:bg-success/25" : - "bg-muted text-muted-foreground border-border hover:bg-muted/80" - )} - > - {item.objective} -
- ); - } - - // Handle drafts - if (item.type === 'draft') { - return ( - - -
navigate("/review")} - > - - {item.subject || "Draft"} -
-
- -
-
-

{item.subject}

- Draft -
-
-

To: {item.name || "Unknown"}

-

{item.company}

-
-
- {item.body} -
- -
-
-
- ); - } - - // Handle email events - if (item.type === 'email') { - let color = "bg-gray-500"; - let icon = "📧"; - let title = item.subject || "Email event"; - - if (item.event_type === "sent") { - color = "bg-green-500"; - icon = "📧"; - title = `Sent: ${item.subject || "Email"}`; - } else if (item.event_type === "reply_received") { - color = "bg-blue-500"; - icon = "📬"; - title = `Reply from ${item.from_email}`; - } else if (item.event_type === "follow_up") { - color = "bg-orange-500"; - icon = "🔄"; - title = `Follow-up: ${item.subject || ""}`; - } else if (item.event_type === "opened") { - color = "bg-purple-500"; - icon = "👁️"; - title = "Email opened"; - } else if (item.event_type === "clicked") { - color = "bg-pink-500"; - icon = "🖱️"; - title = "Link clicked"; - } - - return ( -
- - {icon} {item.preview?.substring(0, 20) || title} -
- ); - } - - return null; - })} +
+ {dayItems.map((item, idx) => ( + { + if (item.type === 'mission') navigate(`/chat/${item._id || item.id}`); + else if (item.type === 'draft') navigate('/review'); + }} + /> + ))}
); })} - {/* Fill remaining cells if needed */} + {/* End Padding */} {Array.from({ length: 42 - (daysInMonth.length + paddingDays.length) }).map((_, i) => ( -
+
))}
diff --git a/frontend/src/pages/Launchpad.tsx b/frontend/src/pages/Launchpad.tsx index 998f5f8..2c53ccf 100644 --- a/frontend/src/pages/Launchpad.tsx +++ b/frontend/src/pages/Launchpad.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; import { HeroInput } from "@/components/launchpad/HeroInput"; import { RecipeCard } from "@/components/launchpad/RecipeCard"; -import { MissionCard } from "@/components/launchpad/MissionCard"; +import { LiveMissionCard } from "@/components/launchpad/LiveMissionCard"; import { Target, TrendingUp, Building2, UserPlus } from "lucide-react"; import { useApi } from "@/lib/api"; import { toast } from "sonner"; @@ -169,7 +169,7 @@ const Launchpad = () => {

No active missions. Launch one above!

) : ( activeMissions.map((mission) => ( - Date: Tue, 27 Jan 2026 19:27:09 +0530 Subject: [PATCH 03/31] Delete GALGO_FONT_INSTALL.md deleted unwanted md files --- GALGO_FONT_INSTALL.md | 109 ------------------------------------------ 1 file changed, 109 deletions(-) delete mode 100644 GALGO_FONT_INSTALL.md diff --git a/GALGO_FONT_INSTALL.md b/GALGO_FONT_INSTALL.md deleted file mode 100644 index 72075e5..0000000 --- a/GALGO_FONT_INSTALL.md +++ /dev/null @@ -1,109 +0,0 @@ -# Installing Galgo Condensed Font - Step by Step - -## Step 1: Download the Font - -1. Download the trial font from: https://www.giuliaboggio.xyz/TRIALS/galgo%20condensed/Galgo_TRIAL.zip -2. Extract the ZIP file -3. You should see font files like: - - GalgoCondensed-Light.woff2 - - GalgoCondensed-Regular.woff2 - - GalgoCondensed-Bold.woff2 - (or .woff, .ttf, .otf formats) - -## Step 2: Create Fonts Folder - -Run this command in your terminal: -```bash -mkdir -p "/Users/aryawadhwa/Desktop/outbound ai/frontend/public/fonts" -``` - -## Step 3: Copy Font Files - -Copy all the font files from the downloaded folder to: -``` -/Users/aryawadhwa/Desktop/outbound ai/frontend/public/fonts/ -``` - -## Step 4: Update index.css - -Replace the first line of `/Users/aryawadhwa/Desktop/outbound ai/frontend/src/index.css`: - -**REMOVE THIS LINE (line 1):** -```css -@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap'); -``` - -**ADD THESE LINES AT THE TOP (before @tailwind):** -```css -/* Galgo Condensed - Brutalist Condensed Font */ -@font-face { - font-family: 'Galgo Condensed'; - src: url('/fonts/GalgoCondensed-Light.woff2') format('woff2'); - font-weight: 300; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Galgo Condensed'; - src: url('/fonts/GalgoCondensed-Regular.woff2') format('woff2'); - font-weight: 400; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Galgo Condensed'; - src: url('/fonts/GalgoCondensed-Bold.woff2') format('woff2'); - font-weight: 700; - font-style: normal; - font-display: swap; -} -``` - -## Step 5: Update Font Family - -Find this line (around line 82): -```css -font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; -``` - -**REPLACE WITH:** -```css -font-family: 'Galgo Condensed', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; -letter-spacing: -0.02em; /* Tighter spacing for condensed font */ -``` - -## Step 6: Save and Refresh - -1. Save the `index.css` file -2. The Vite dev server should auto-reload -3. Refresh your browser at http://localhost:8081 -4. You should see the Galgo Condensed font! - ---- - -## Troubleshooting: - -**If the font doesn't load:** - -1. Check the browser console (F12) for errors -2. Verify the font files are in `/public/fonts/` -3. Make sure the file names match exactly (case-sensitive!) -4. Try using `.woff` instead of `.woff2` if you have that format -5. Clear browser cache and hard refresh (Cmd+Shift+R on Mac) - -**Font file name variations:** -- If your files are named differently (e.g., `galgo-condensed-light.woff2`), update the `src:` URLs to match -- Example: `url('/fonts/galgo-condensed-light.woff2')` - ---- - -## Alternative: Use a CDN (if available) - -If Galgo Condensed has a CDN link, you can use: -```css -@import url('CDN_LINK_HERE'); -``` - -But for this font, self-hosting is the recommended approach. From e09c70fca13b0ec124d5bdb96e338356df15116a Mon Sep 17 00:00:00 2001 From: Dilraj Singh Date: Tue, 27 Jan 2026 19:27:19 +0530 Subject: [PATCH 04/31] Delete FONT_IMPORT_GUIDE.md deleted unwanted md files --- FONT_IMPORT_GUIDE.md | 131 ------------------------------------------- 1 file changed, 131 deletions(-) delete mode 100644 FONT_IMPORT_GUIDE.md diff --git a/FONT_IMPORT_GUIDE.md b/FONT_IMPORT_GUIDE.md deleted file mode 100644 index 5b1f1c8..0000000 --- a/FONT_IMPORT_GUIDE.md +++ /dev/null @@ -1,131 +0,0 @@ -# How to Import Custom Fonts in Your Web App - -## Method 1: Google Fonts (Current Method) - -This is what we're currently using for Space Grotesk: - -```css -/* In index.css */ -@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap'); - -html { - font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; -} -``` - -### To Change to a Different Google Font: - -1. **Visit Google Fonts**: https://fonts.google.com/ -2. **Select a font** (e.g., "Archivo Black" for brutalist, "Bebas Neue" for bold) -3. **Click "Get font"** then "Get embed code" -4. **Copy the @import URL** -5. **Replace line 1 in `index.css`** with your new import -6. **Update the font-family** on line 82 - -Example with Bebas Neue (very brutalist): -```css -@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap'); - -html { - font-family: 'Bebas Neue', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; -} -``` - ---- - -## Method 2: Custom Font Files (Self-Hosted) - -For fonts not on Google Fonts: - -### Step 1: Add font files to your project -``` -frontend/ - public/ - fonts/ - YourFont-Regular.woff2 - YourFont-Bold.woff2 -``` - -### Step 2: Define @font-face in index.css -```css -@font-face { - font-family: 'YourFont'; - src: url('/fonts/YourFont-Regular.woff2') format('woff2'); - font-weight: 400; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'YourFont'; - src: url('/fonts/YourFont-Bold.woff2') format('woff2'); - font-weight: 700; - font-style: normal; - font-display: swap; -} - -html { - font-family: 'YourFont', sans-serif; -} -``` - ---- - -## Method 3: Adobe Fonts / Typekit - -```html - - -``` - -```css -/* In index.css */ -html { - font-family: 'your-adobe-font', sans-serif; -} -``` - ---- - -## Method 4: Fonts from CDN (fonts.com, etc.) - -```html - - -``` - ---- - -## Popular Brutalist Fonts for Your Design: - -### Free (Google Fonts): -- **Bebas Neue** - Very bold, condensed, brutalist -- **Archivo Black** - Heavy, geometric -- **Oswald** - Bold, condensed -- **Anton** - Extra bold display -- **Barlow Condensed** - Geometric, strong - -### Premium Options: -- **Druk** - Classic brutalist -- **Neue Haas Grotesk** - Swiss brutalism -- **Suisse Int'l** - Modern brutalist -- **Monument Grotesk** - Geometric brutalist - ---- - -## Quick Font Change Guide: - -1. **Find your font** on Google Fonts or download it -2. **Copy the import URL** or add files to `/public/fonts/` -3. **Update `index.css` line 1** with the new @import -4. **Update `index.css` line 82** with the new font-family name -5. **Save and refresh** - Vite will hot-reload automatically! - ---- - -## Current Font Stack: -```css -font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; -``` - -The fallback fonts ensure the site works even if the custom font fails to load. From e6ab6f6c772b11db7b29cfb25ffabdbebd9ea216 Mon Sep 17 00:00:00 2001 From: Dilraj Singh Date: Tue, 27 Jan 2026 19:27:31 +0530 Subject: [PATCH 05/31] Delete DEPLOYMENT.md deleted unwanted md files --- DEPLOYMENT.md | 38 -------------------------------------- 1 file changed, 38 deletions(-) delete mode 100644 DEPLOYMENT.md diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md deleted file mode 100644 index cb90c01..0000000 --- a/DEPLOYMENT.md +++ /dev/null @@ -1,38 +0,0 @@ -# Deployment Guide - -Follow these steps to deploy OutboundAI to a production environment (e.g., VPS, DigitalOcean, AWS EC2). - -## Prerequisites -- Docker & Docker Compose installed on the server. -- API Keys for Groq, Firecrawl, and Clerk. - -## Setup - -1. **Clone the Repository** - ```bash - git clone https://github.com/BEASTSHRIRAM/OutboundAI.git - cd OutboundAI - ``` - -2. **Configure Environment** - Create a `.env.prod` file from the example: - ```bash - cp .env.prod.example .env.prod - ``` - Edit `.env.prod` and fill in your actual API keys. Ensure `MONGODB_URI` is set to `mongodb://mongodb:27017` (internal docker network). - -3. **Build and Run** - ```bash - docker-compose up -d --build - ``` - -4. **Access the Application** - - Frontend: `http://` - - Backend API: `http://:8000/docs` - -## Data Management -- MongoDB data is persisted in a docker volume `mongo_data`. -- To reset data (Warning: Destructive): - ```bash - docker-compose down -v - ``` From 25f26b7c04bd2e69df1502963edc4a5f94586fda Mon Sep 17 00:00:00 2001 From: Dilraj Singh Date: Tue, 27 Jan 2026 19:27:41 +0530 Subject: [PATCH 06/31] Delete CODE_REVIEW_SUMMARY.md deleted unwanted md files --- CODE_REVIEW_SUMMARY.md | 226 ----------------------------------------- 1 file changed, 226 deletions(-) delete mode 100644 CODE_REVIEW_SUMMARY.md diff --git a/CODE_REVIEW_SUMMARY.md b/CODE_REVIEW_SUMMARY.md deleted file mode 100644 index d7e1ecf..0000000 --- a/CODE_REVIEW_SUMMARY.md +++ /dev/null @@ -1,226 +0,0 @@ -# ExpediteAI - Complete Code Review & Changes Summary - -## ✅ All Completed Changes - -### 1. **Branding Update: OutboundAI → ExpediteAI** - -#### Files Modified: -- ✅ `frontend/src/pages/Landing.tsx` - - Navbar logo - - Hero headline: "Expedite Engine" - - About section - - Footer - - Email: hello@expediteai.com - - Social handles: @expediteai - - "future of sales automation" - -- ✅ `frontend/src/components/layout/AppLayout.tsx` - - Sidebar logo - -- ✅ `frontend/index.html` - - Page title - - Meta tags - -- ✅ `frontend/src/pages/Settings.tsx` - - "AI expedite machine" - -- ✅ `frontend/src/pages/MissionChat.tsx` - - "expedite mission" placeholders - -- ✅ `frontend/src/components/layout/NavigationSidebar.tsx` - - "Expedite" label - -- ✅ `frontend/src/components/OnboardingTour.tsx` - - localStorage keys - ---- - -### 2. **Font System** - -#### Current Font: **Space Grotesk** -```css -@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap'); - -html { - font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - text-rendering: optimizeLegibility; - letter-spacing: 0.02em; /* Increased for readability */ - font-size: 16px; /* Base size */ -} -``` - -**Improvements Made:** -- ✅ Better font rendering with grayscale smoothing -- ✅ Optimized legibility -- ✅ Increased letter-spacing (0.02em) -- ✅ Larger base font-size (16px) - ---- - -### 3. **Lamp Container Optimizations** - -#### Mobile Optimizations: -```css -/* Beam opacity: 30% mobile → 70% desktop */ -opacity-30 md:opacity-70 - -/* Glow effects: 20-40% mobile → 40-60% desktop */ -opacity-20 md:opacity-40 -``` - -#### Size Increases: -- Beam width: 30rem → **50rem** (66% wider) -- Beam height: h-56 → **h-72** (taller) -- Glow width: 28rem → **40rem** -- Center line: 30rem → **50rem** - -#### Positioning: -- Added `-mt-32` to hide lamp source behind navbar -- Light emanates from behind, creating dramatic effect - ---- - -### 4. **Mobile Responsiveness** - -#### Navigation: -```tsx -- Hidden "About" and "Contact" on mobile (hidden sm:block) -- Hidden "Sign In" button on mobile -- Responsive padding: px-4 sm:px-6 -- Smaller text: text-lg sm:text-xl -``` - -#### Hero Section: -```tsx -- Responsive text: text-4xl sm:text-5xl md:text-7xl lg:text-8xl -- Full-width buttons on mobile -- Reduced spacing: mb-6 sm:mb-8 -- Max-width constraints: max-w-5xl -``` - -#### Sections: -```tsx -- Padding: py-16 sm:py-24 md:py-32 -- Horizontal: px-4 sm:px-6 -``` - -#### Interactive Dots: -- Mobile: 20 rows × 10 columns (smaller dots) -- Desktop: 15 rows × 20 columns (larger effect) - ---- - -### 5. **Custom CSS Overrides** - -```css -/* Taller hero section */ -.min-h-screen { - min-height: 127vh; -} - -/* Larger headings on medium screens */ -@media (min-width: 768px) { - .md\:text-7xl { - font-size: 5.5rem; - line-height: 1; - } -} -``` - ---- - -### 6. **Color Scheme Unification** - -#### Review Queue: -- ✅ Reject button: `variant="destructive"` (red) -- ✅ Approve button: `bg-primary` with `glow-primary` (cyan) - -#### Email Editor: -- ✅ Input backgrounds: `glass-card` (spatial theme) -- ✅ Borders: `border-white/[0.08]` -- ✅ Badge: `bg-primary/10 text-primary` (cyan) - ---- - -## 📁 File Structure - -``` -frontend/ -├── public/ -│ └── fonts/ # Galgo font files (available but not active) -├── src/ -│ ├── components/ -│ │ ├── layout/ -│ │ │ ├── AppLayout.tsx ✅ Updated -│ │ │ └── NavigationSidebar.tsx ✅ Updated -│ │ ├── review/ -│ │ │ └── EmailEditor.tsx ✅ Updated -│ │ ├── ui/ -│ │ │ ├── LampContainer.tsx ✅ Optimized -│ │ │ └── InteractiveDots.tsx ✅ Created -│ │ └── OnboardingTour.tsx ✅ Updated -│ ├── pages/ -│ │ ├── Landing.tsx ✅ Updated -│ │ ├── ReviewQueue.tsx ✅ Updated -│ │ ├── Settings.tsx ✅ Updated -│ │ └── MissionChat.tsx ✅ Updated -│ └── index.css ✅ Updated -└── index.html ✅ Updated -``` - ---- - -## 🎨 Design System - -### Spatial Computing Theme: -- **Depth Layers**: Far (220 20% 8%), Mid (220 18% 12%), Near (220 16% 16%) -- **Primary**: Cyan (210 100% 60%) -- **Secondary**: Purple (280 60% 65%) -- **Accent**: Bright Cyan (180 100% 50%) - -### Typography: -- **Font**: Space Grotesk (400, 500, 600, 700) -- **Letter-spacing**: 0.02em -- **Base size**: 16px - -### Effects: -- **Glow**: Primary, Secondary, Accent glows -- **Shadows**: 4 depth layers -- **Animations**: Spring physics, smooth transitions - ---- - -## 🚀 Performance - -### Optimizations: -- ✅ Reduced lamp glow on mobile (better performance) -- ✅ Fewer interactive dots on mobile -- ✅ Optimized font rendering -- ✅ Proper z-index layering - ---- - -## ✨ Key Features - -1. **Dramatic Lamp Effect** - Hidden source, visible glow -2. **Interactive Dots Background** - Hover-reactive circles -3. **Spatial Computing Design** - Depth, glow, and shadows -4. **Full Mobile Optimization** - Responsive across all devices -5. **Unified Branding** - ExpediteAI throughout -6. **Premium Typography** - Space Grotesk with improved spacing - ---- - -## 📝 Notes - -- All "outbound" references removed -- Galgo Condensed font files available in `/public/fonts/` if needed -- Font rendering optimized for all browsers -- Spatial computing aesthetic maintained throughout - ---- - -**Last Updated**: 2026-01-23 -**Status**: ✅ All changes complete and verified From b9f9afdd83cf30950cd7a824dbe057096202d27f Mon Sep 17 00:00:00 2001 From: Dilraj07 Date: Tue, 27 Jan 2026 20:48:08 +0530 Subject: [PATCH 07/31] feat: Add Integration Icons in Settings for direct app connection --- backend/app/routers/integrations.py | 36 +++++ frontend/src/lib/api.ts | 6 + frontend/src/pages/Settings.tsx | 195 +++++++++++++++++++++++++++- 3 files changed, 232 insertions(+), 5 deletions(-) diff --git a/backend/app/routers/integrations.py b/backend/app/routers/integrations.py index fdc81ea..930166c 100644 --- a/backend/app/routers/integrations.py +++ b/backend/app/routers/integrations.py @@ -259,3 +259,39 @@ async def disconnect_integration(tool: str, user: User = Depends(get_current_use await user.save() return {"status": "disconnected", "tool": tool} + +@router.get("/{tool}/status") +async def get_tool_status(tool: str, user: User = Depends(get_current_user)): + """Check the status of any integration""" + tool = tool.lower() + + # Get connection ID for the tool + connection_id = None + if tool == "gmail": + connection_id = user.gmail_connection_id + elif tool == "slack": + connection_id = user.slack_connection_id + elif user.other_connections: + connection_id = user.other_connections.get(tool) + + if not connection_id: + return {"status": "INACTIVE", "tool": tool} + + # Verify connection status with Composio + if settings.COMPOSIO_API_KEY: + url = f"https://backend.composio.dev/api/v3/connected_accounts/{connection_id}" + headers = {"x-api-key": settings.COMPOSIO_API_KEY} + + async with httpx.AsyncClient() as client: + try: + resp = await client.get(url, headers=headers, timeout=10.0) + if resp.status_code == 200: + data = resp.json() + status = data.get("status", "UNKNOWN") + if status in ["ACTIVE", "CONNECTED"]: + return {"status": "ACTIVE", "tool": tool, "connection_id": connection_id} + except Exception as e: + print(f"Error checking {tool} status: {e}") + + # If we have a connection_id stored, assume it's connected + return {"status": "ACTIVE", "tool": tool, "connection_id": connection_id} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 690dff4..455c6d8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -135,6 +135,12 @@ export function useApi() { return res.json(); }, + getToolStatus: async (tool: string) => { + const res = await fetchWithAuth(`/integrations/${tool}/status`); + if (!res.ok) return { status: "INACTIVE" }; + return res.json(); + }, + // Agents getAgents: async () => { const res = await fetchWithAuth("/agents/"); diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index c23d4c7..d7928de 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -21,14 +21,139 @@ import { Shield, Zap, ExternalLink, - Check + Check, + Loader2, + Mail, + MessageSquare, + Github, + FileSpreadsheet, + Send, + Bot, + Search } from "lucide-react"; +// ============================================================================= +// AVAILABLE INTEGRATIONS CONFIG +// ============================================================================= + +interface Integration { + key: string; + name: string; + icon: React.ReactNode; + color: string; + description: string; +} + +const AVAILABLE_INTEGRATIONS: Integration[] = [ + { + key: "gmail", + name: "Gmail", + icon: , + color: "bg-red-500/20 text-red-400 border-red-500/30", + description: "Send emails via Gmail" + }, + { + key: "slack", + name: "Slack", + icon: , + color: "bg-purple-500/20 text-purple-400 border-purple-500/30", + description: "Send Slack messages" + }, + { + key: "discord", + name: "Discord", + icon: , + color: "bg-indigo-500/20 text-indigo-400 border-indigo-500/30", + description: "Connect Discord" + }, + { + key: "telegram", + name: "Telegram", + icon: , + color: "bg-sky-500/20 text-sky-400 border-sky-500/30", + description: "Send Telegram messages" + }, + { + key: "github", + name: "GitHub", + icon: , + color: "bg-gray-500/20 text-gray-300 border-gray-500/30", + description: "Access GitHub repos" + }, + { + key: "google_sheets", + name: "Sheets", + icon: , + color: "bg-green-500/20 text-green-400 border-green-500/30", + description: "Read/write spreadsheets" + }, + { + key: "perplexity", + name: "Perplexity", + icon: , + color: "bg-amber-500/20 text-amber-400 border-amber-500/30", + description: "AI search" + } +]; + +// ============================================================================= +// INTEGRATION CARD COMPONENT +// ============================================================================= + +interface IntegrationCardProps { + integration: Integration; + isConnected: boolean; + isConnecting: boolean; + onConnect: (key: string) => void; +} + +function IntegrationCard({ integration, isConnected, isConnecting, onConnect }: IntegrationCardProps) { + return ( + + ); +} + + export default function Settings() { const [emailNotifications, setEmailNotifications] = useState(true); const [autoApprove, setAutoApprove] = useState(false); const [connectedTools, setConnectedTools] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [connectingTool, setConnectingTool] = useState(null); const api = useApi(); const { getToken } = useAuth(); @@ -38,8 +163,44 @@ export default function Settings() { useEffect(() => { fetchIntegrations(); fetchAssets(); + + // Check if returning from OAuth callback + const urlParams = new URLSearchParams(window.location.search); + const connected = urlParams.get('connected'); + if (connected) { + // Clear the URL params + window.history.replaceState({}, '', window.location.pathname); + // Refresh integrations to show newly connected tool + setTimeout(() => fetchIntegrations(), 500); + } }, []); + // ========================================================================== + // INTEGRATION CONNECTION HANDLER + // ========================================================================== + + const handleConnect = async (toolKey: string) => { + setConnectingTool(toolKey); + + try { + // Call backend to initiate OAuth + const response = await api.connectTool(toolKey); + + if (response.redirect_url) { + // Open OAuth in same window (will redirect back after auth) + window.location.href = response.redirect_url; + } else { + // If no redirect needed (API key based), refresh list + await fetchIntegrations(); + setConnectingTool(null); + } + } catch (error: any) { + console.error(`Failed to connect ${toolKey}:`, error); + alert(`Failed to connect ${toolKey}: ${error.message || 'Unknown error'}`); + setConnectingTool(null); + } + }; + const fetchAssets = async () => { try { const data = await api.getAssets(); @@ -116,10 +277,34 @@ export default function Settings() { API Integrations - + + + {/* Available Integrations Grid */} +
+
+ Available Integrations +
+
+ {AVAILABLE_INTEGRATIONS.map((integration) => { + const isConnected = connectedTools.some( + (t) => t.name.toLowerCase() === integration.key.toLowerCase() + ); + return ( + + ); + })} +
+
+ - {/* Dynamic Connected Integrations */} + {/* Connected Integrations List */} {isLoading ? (
Loading integrations...
) : connectedTools.length > 0 ? ( @@ -154,8 +339,8 @@ export default function Settings() {
) : (
-

No custom integrations connected.

-

Ask the Agent in a mission chat to "Connect [Tool]" to add it here.

+

No integrations connected yet.

+

Click an icon above to connect an app.

)} From 9ba6bfe32ddbfd684858681b5fd86fa18218ca4a Mon Sep 17 00:00:00 2001 From: Dilraj07 Date: Tue, 27 Jan 2026 21:33:58 +0530 Subject: [PATCH 08/31] feat: Comprehensive UX fixes and Real-time Stats - Review Queue: Added bulk actions, multi-select, and inline editing - Active Agents: Added real-time stats (Active, Processed, Queue) with WebSocket updates - Mission Control: Fixed mission lists to show actual prospect/draft counts - Settings: Added persistence for all user preferences (User Settings model) - Live Brain: Fixed crash on stats update and enabled real-time sidebar metrics - Backend: Added update_agent_stats logic to injection points in agent core --- backend/app/core/agent.py | 46 ++ backend/app/models.py | 28 +- backend/app/routers/agents.py | 40 +- backend/app/routers/missions.py | 36 +- backend/app/routers/settings.py | 65 +++ backend/main.py | 2 + .../components/layout/LiveBrainSidebar.tsx | 10 +- frontend/src/lib/api.ts | 22 + frontend/src/pages/ActiveAgents.tsx | 22 +- frontend/src/pages/ReviewQueue.tsx | 521 +++++++++++++----- frontend/src/pages/Settings.tsx | 64 ++- 11 files changed, 701 insertions(+), 155 deletions(-) create mode 100644 backend/app/routers/settings.py diff --git a/backend/app/core/agent.py b/backend/app/core/agent.py index 03979a2..033ff38 100644 --- a/backend/app/core/agent.py +++ b/backend/app/core/agent.py @@ -21,6 +21,50 @@ class AgentState(TypedDict): # Real Tools from langchain_groq import ChatGroq from langchain_core.messages import SystemMessage, HumanMessage +from app.models import Agent + +async def update_agent_stats(user_id: str, processed=0, queued=0, errors=0): + """Update stats for the user's active agents and broadcast to frontend""" + try: + # Find all active agents for this user + agents = await Agent.find(Agent.user_id == user_id, Agent.status == "active").to_list() + + # If no active agents, just find any agent to log stats to + if not agents: + agents = await Agent.find(Agent.user_id == user_id).limit(1).to_list() + + now = datetime.utcnow() + for agent in agents: + if not agent.stats: + from app.models import AgentStats + agent.stats = AgentStats() + + agent.stats.processed += processed + agent.stats.queued += queued + agent.stats.errors += errors + agent.stats.last_run_at = now + await agent.save() + + # Calculate aggregates for broadcast + all_agents = await Agent.find(Agent.user_id == user_id).to_list() + total_active = sum(1 for a in all_agents if a.status == "active") + total_processed = sum(a.stats.processed for a in all_agents if a.stats) + total_queued = sum(a.stats.queued for a in all_agents if a.stats) + + # Broadcast via WebSocket + from main import get_connection_manager + manager = get_connection_manager() + await manager.send_to_user(user_id, { + "type": "stats_update", + "stats": { + "active": total_active, + "processed": total_processed, + "queue": total_queued + } + }) + + except Exception as e: + print(f"Failed to update agent stats: {e}") async def log_event(mission_id: str, user_id: str, content: str, log_type: str = "action", role: str = "agent", metadata: Dict = {}): """Log an event to DB and broadcast via WebSocket""" @@ -98,6 +142,7 @@ async def scout_prospects(state: AgentState): if raw_prospects: await log_event(state["mission_id"], state["user_id"], f"Found {len(raw_prospects)} potential contact points.", "success") + await update_agent_stats(state["user_id"], processed=len(raw_prospects)) return {"prospects": raw_prospects} await log_event(state["mission_id"], state["user_id"], f"Firecrawl returned status {response.status_code}", "error") @@ -262,6 +307,7 @@ async def write_draft(state: AgentState): # attachments=prospect.get('attachments', []) ) await d_doc.insert() + await update_agent_stats(state["user_id"], queued=1) return {"draft_id": str(d_doc.id)} diff --git a/backend/app/models.py b/backend/app/models.py index 1abe5ca..5484a6f 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -10,13 +10,22 @@ class DraftStatus(str, Enum): APPROVED = "APPROVED" REJECTED = "REJECTED" +class UserSettings(BaseModel): + """User preferences that persist""" + email_notifications: bool = True + daily_digest_time: str = "9am" + auto_approve_low_risk: bool = False + personalization_threshold: int = 80 + daily_sending_limit: int = 50 + class User(Document): clerk_id: Indexed(str, unique=True) email: str credits: int = 10 gmail_connection_id: Optional[str] = None slack_connection_id: Optional[str] = None - other_connections: Dict[str, str] = {} # Map tool_name -> connection_id + other_connections: Dict[str, str] = {} # Map tool_name -> connection_id + settings: UserSettings = Field(default_factory=UserSettings) class Settings: name = "users" @@ -71,14 +80,23 @@ class Settings: name = "mission_logs" +class AgentStats(BaseModel): + """Real-time agent statistics""" + processed: int = 0 + queued: int = 0 + errors: int = 0 + last_run_at: Optional[datetime] = None + class Agent(Document): user_id: str name: str description: Optional[str] = None - status: str = "active" # active, paused, error - workflow: Dict = {} # Stores the React Flow nodes and edges - integrations: List[str] = [] # List of enabled integration IDs - api_keys: Dict[str, str] = {} # Map of integration_id -> api_key + agent_type: str = "custom" # scout, writer, enricher, custom + status: str = "idle" # active, idle, paused, error + workflow: Dict = {} # Stores the React Flow nodes and edges + integrations: List[str] = [] # List of enabled integration IDs + api_keys: Dict[str, str] = {} # Map of integration_id -> api_key + stats: AgentStats = Field(default_factory=AgentStats) created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/backend/app/routers/agents.py b/backend/app/routers/agents.py index 2beb66e..ac2f082 100644 --- a/backend/app/routers/agents.py +++ b/backend/app/routers/agents.py @@ -22,10 +22,44 @@ class AgentUpdate(BaseModel): integrations: Optional[List[str]] = None api_keys: Optional[Dict[str, str]] = None -@router.get("/", response_model=List[Agent]) +@router.get("/") async def get_agents(user: User = Depends(get_current_user)): - """Get all agents for the current user""" - return await Agent.find(Agent.user_id == user.clerk_id).to_list() + """Get all agents for the current user with stats""" + agents = await Agent.find(Agent.user_id == user.clerk_id).to_list() + + result = [] + now = datetime.utcnow() + + for agent in agents: + # Calculate uptime + uptime_delta = now - agent.created_at + hours = int(uptime_delta.total_seconds() // 3600) + minutes = int((uptime_delta.total_seconds() % 3600) // 60) + uptime_str = f"{hours}h {minutes}m" + + # Get stats from model or defaults + stats = agent.stats if hasattr(agent, 'stats') and agent.stats else {} + + result.append({ + "_id": str(agent.id), + "id": str(agent.id), + "name": agent.name, + "description": agent.description, + "agent_type": getattr(agent, 'agent_type', 'custom'), + "status": agent.status, + "workflow": agent.workflow, + "integrations": agent.integrations, + "stats": { + "processed": getattr(stats, 'processed', 0) if hasattr(stats, 'processed') else stats.get('processed', 0), + "queued": getattr(stats, 'queued', 0) if hasattr(stats, 'queued') else stats.get('queued', 0), + "errors": getattr(stats, 'errors', 0) if hasattr(stats, 'errors') else stats.get('errors', 0), + }, + "uptime": uptime_str, + "created_at": agent.created_at.isoformat(), + "updated_at": agent.updated_at.isoformat(), + }) + + return result @router.post("/", response_model=Agent) async def create_agent(agent_data: AgentCreate, user: User = Depends(get_current_user)): diff --git a/backend/app/routers/missions.py b/backend/app/routers/missions.py index a2ae4b5..1f7cde9 100644 --- a/backend/app/routers/missions.py +++ b/backend/app/routers/missions.py @@ -31,9 +31,41 @@ async def create_mission(mission_in: MissionCreate, user: User = Depends(get_cur return mission -@router.get("/", response_model=List[Mission]) +@router.get("/") async def list_missions(user: User = Depends(get_current_user)): - return await Mission.find(Mission.user_id == user.clerk_id).to_list() + from app.models import Prospect, Draft, DraftStatus + + missions = await Mission.find(Mission.user_id == user.clerk_id).to_list() + + # Enrich with counts + result = [] + for mission in missions: + mission_id = str(mission.id) + + # Count prospects for this mission + prospects_count = await Prospect.find(Prospect.mission_id == mission_id).count() + + # Count pending drafts for this mission (via prospect_id lookup) + prospects = await Prospect.find(Prospect.mission_id == mission_id).to_list() + prospect_ids = [str(p.id) for p in prospects] + drafts_count = 0 + if prospect_ids: + drafts_count = await Draft.find( + {"prospect_id": {"$in": prospect_ids}, "status": DraftStatus.PENDING} + ).count() + + result.append({ + "_id": str(mission.id), + "id": str(mission.id), + "user_id": mission.user_id, + "objective": mission.objective, + "status": mission.status, + "created_at": mission.created_at.isoformat(), + "prospects_count": prospects_count, + "drafts_count": drafts_count, + }) + + return result @router.get("/{mission_id}/logs") async def get_mission_logs(mission_id: str, user: User = Depends(get_current_user)): diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py new file mode 100644 index 0000000..ef02c81 --- /dev/null +++ b/backend/app/routers/settings.py @@ -0,0 +1,65 @@ +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional +from app.api.deps import get_current_user +from app.models import User, UserSettings + +router = APIRouter() + + +class SettingsUpdate(BaseModel): + email_notifications: Optional[bool] = None + daily_digest_time: Optional[str] = None + auto_approve_low_risk: Optional[bool] = None + personalization_threshold: Optional[int] = None + daily_sending_limit: Optional[int] = None + + +@router.get("/") +async def get_settings(user: User = Depends(get_current_user)): + """Get current user settings""" + # Ensure settings field exists (for older users) + if not hasattr(user, 'settings') or user.settings is None: + user.settings = UserSettings() + await user.save() + + return { + "email_notifications": user.settings.email_notifications, + "daily_digest_time": user.settings.daily_digest_time, + "auto_approve_low_risk": user.settings.auto_approve_low_risk, + "personalization_threshold": user.settings.personalization_threshold, + "daily_sending_limit": user.settings.daily_sending_limit, + } + + +@router.patch("/") +async def update_settings(update: SettingsUpdate, user: User = Depends(get_current_user)): + """Update user settings""" + # Ensure settings field exists + if not hasattr(user, 'settings') or user.settings is None: + user.settings = UserSettings() + + # Update only provided fields + if update.email_notifications is not None: + user.settings.email_notifications = update.email_notifications + if update.daily_digest_time is not None: + user.settings.daily_digest_time = update.daily_digest_time + if update.auto_approve_low_risk is not None: + user.settings.auto_approve_low_risk = update.auto_approve_low_risk + if update.personalization_threshold is not None: + user.settings.personalization_threshold = update.personalization_threshold + if update.daily_sending_limit is not None: + user.settings.daily_sending_limit = update.daily_sending_limit + + await user.save() + + return { + "status": "updated", + "settings": { + "email_notifications": user.settings.email_notifications, + "daily_digest_time": user.settings.daily_digest_time, + "auto_approve_low_risk": user.settings.auto_approve_low_risk, + "personalization_threshold": user.settings.personalization_threshold, + "daily_sending_limit": user.settings.daily_sending_limit, + } + } diff --git a/backend/main.py b/backend/main.py index f5215b3..6fffed1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -44,6 +44,8 @@ async def lifespan(app: FastAPI): app.include_router(health.router, prefix="/api/v1/health", tags=["health"]) from app.routers import timeline app.include_router(timeline.router, prefix="/api/v1", tags=["timeline"]) +from app.routers import settings as settings_router +app.include_router(settings_router.router, prefix="/api/v1/settings", tags=["settings"]) from fastapi import WebSocket, WebSocketDisconnect diff --git a/frontend/src/components/layout/LiveBrainSidebar.tsx b/frontend/src/components/layout/LiveBrainSidebar.tsx index ab8d2fe..5df324b 100644 --- a/frontend/src/components/layout/LiveBrainSidebar.tsx +++ b/frontend/src/components/layout/LiveBrainSidebar.tsx @@ -55,6 +55,13 @@ export function LiveBrainSidebar({ isOpen, onToggle }: LiveBrainSidebarProps) { ws.onmessage = (event) => { try { const data = JSON.parse(event.data); + + // Handle stats update without adding to logs + if (data.type === 'stats_update' && data.stats) { + setStats(data.stats); + return; + } + setLogs((prev) => [ ...prev, @@ -67,9 +74,6 @@ export function LiveBrainSidebar({ isOpen, onToggle }: LiveBrainSidebarProps) { }, ].slice(-30) ); - if (data.stats) { - setStats(data.stats); - } } catch { // Plain text message setLogs((prev) => diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 455c6d8..7c9445f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -273,6 +273,28 @@ export function useApi() { if (!res.ok) throw new Error('Failed to fetch contact stats'); return res.json(); }, + + // Settings + getSettings: async () => { + const res = await fetchWithAuth('/settings/'); + if (!res.ok) throw new Error('Failed to fetch settings'); + return res.json(); + }, + + updateSettings: async (settings: { + email_notifications?: boolean; + daily_digest_time?: string; + auto_approve_low_risk?: boolean; + personalization_threshold?: number; + daily_sending_limit?: number; + }) => { + const res = await fetchWithAuth('/settings/', { + method: 'PATCH', + body: JSON.stringify(settings), + }); + if (!res.ok) throw new Error('Failed to update settings'); + return res.json(); + }, }; } diff --git a/frontend/src/pages/ActiveAgents.tsx b/frontend/src/pages/ActiveAgents.tsx index ad7426e..024b2b4 100644 --- a/frontend/src/pages/ActiveAgents.tsx +++ b/frontend/src/pages/ActiveAgents.tsx @@ -65,19 +65,19 @@ export default function ActiveAgents() { const fetchAgents = async () => { try { const data = await getAgents(); - const mappedAgents: AgentUI[] = data.map((items: any) => ({ - id: items._id || items.id, - name: items.name, - type: "custom", // Default to custom as backend doesn't have type yet - status: items.status || "idle", - mission: items.description || "No mission assigned", - progress: 0, // Not tracked in backend yet + const mappedAgents: AgentUI[] = data.map((item: any) => ({ + id: item._id || item.id, + name: item.name, + type: item.agent_type || "custom", + status: item.status || "idle", + mission: item.description || "No mission assigned", + progress: item.status === "active" ? 50 : 0, stats: { - processed: 0, - queued: 0, - errors: 0 + processed: item.stats?.processed ?? 0, + queued: item.stats?.queued ?? 0, + errors: item.stats?.errors ?? 0 }, - uptime: "0h 0m" // Placeholder + uptime: item.uptime || "0h 0m" })); setAgents(mappedAgents); } catch (error) { diff --git a/frontend/src/pages/ReviewQueue.tsx b/frontend/src/pages/ReviewQueue.tsx index 9a45172..a6b19ab 100644 --- a/frontend/src/pages/ReviewQueue.tsx +++ b/frontend/src/pages/ReviewQueue.tsx @@ -4,17 +4,46 @@ import { ProspectCard } from "@/components/review/ProspectCard"; import { EmailEditor } from "@/components/review/EmailEditor"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { Check, X, ChevronLeft, ChevronRight, Inbox, Loader2 } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; +import { Check, X, ChevronLeft, ChevronRight, Inbox, Loader2, CheckCheck, XCircle } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; import { useApi } from "@/lib/api"; import { toast } from "sonner"; +interface DraftEdits { + [draftId: string]: { + subject: string; + body: string; + }; +} + export default function ReviewQueue() { const [currentIndex, setCurrentIndex] = useState(0); const [drafts, setDrafts] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isActioning, setIsActioning] = useState(false); const [isRegenerating, setIsRegenerating] = useState(false); + + // Multi-select state + const [selectedDrafts, setSelectedDrafts] = useState>(new Set()); + const [selectMode, setSelectMode] = useState(false); + + // Bulk reject dialog + const [showBulkRejectDialog, setShowBulkRejectDialog] = useState(false); + const [bulkRejectFeedback, setBulkRejectFeedback] = useState(""); + + // Track edits for each draft + const [draftEdits, setDraftEdits] = useState({}); + const api = useApi(); const navigate = useNavigate(); @@ -22,6 +51,13 @@ export default function ReviewQueue() { try { const data = await api.getPendingDrafts(); setDrafts(data || []); + // Initialize edits for all drafts + const initialEdits: DraftEdits = {}; + (data || []).forEach((d: any) => { + const id = d.id || d._id; + initialEdits[id] = { subject: d.subject, body: d.body }; + }); + setDraftEdits(initialEdits); } catch (error) { console.error("Failed to fetch drafts:", error); } finally { @@ -34,6 +70,32 @@ export default function ReviewQueue() { }, [api]); const currentDraft = drafts[currentIndex]; + const currentDraftId = currentDraft?.id || currentDraft?._id; + const currentEdits = currentDraftId ? draftEdits[currentDraftId] : null; + + // ========================================================================== + // EDIT HANDLERS + // ========================================================================== + + const handleSubjectChange = (value: string) => { + if (!currentDraftId) return; + setDraftEdits(prev => ({ + ...prev, + [currentDraftId]: { ...prev[currentDraftId], subject: value } + })); + }; + + const handleBodyChange = (value: string) => { + if (!currentDraftId) return; + setDraftEdits(prev => ({ + ...prev, + [currentDraftId]: { ...prev[currentDraftId], body: value } + })); + }; + + // ========================================================================== + // NAVIGATION + // ========================================================================== const handleNext = () => { if (currentIndex < drafts.length - 1) { @@ -47,11 +109,20 @@ export default function ReviewQueue() { } }; + // ========================================================================== + // SINGLE ACTIONS + // ========================================================================== + const handleApprove = async () => { if (!currentDraft) return; setIsActioning(true); try { - const res = await api.approveDraft(currentDraft.id || currentDraft._id); + const edits = draftEdits[currentDraftId]; + const res = await api.approveDraft( + currentDraftId, + edits?.subject || currentDraft.subject, + edits?.body || currentDraft.body + ); if (res.workflow_created) { toast.success("Workflow Started", { description: "Redirecting to active agents..." }); @@ -60,7 +131,6 @@ export default function ReviewQueue() { } toast.success("Draft Approved", { description: "Email has been queued for sending." }); - // Remove from local list const newDrafts = drafts.filter((_, i) => i !== currentIndex); setDrafts(newDrafts); if (currentIndex >= newDrafts.length && currentIndex > 0) { @@ -76,21 +146,8 @@ export default function ReviewQueue() { const handleReject = async () => { if (!currentDraft) return; - const feedback = prompt("Please provide feedback for the AI to improve the email:"); - if (!feedback) return; - - setIsActioning(true); - try { - await api.rejectDraft(currentDraft.id || currentDraft._id, feedback); - toast.success("Draft Rejected", { description: "Feedback sent to AI agent." }); - // Remove from local list (or refresh to get updated version) - await fetchDrafts(); - } catch (error) { - console.error("Failed to reject:", error); - toast.error("Failed to reject draft"); - } finally { - setIsActioning(false); - } + setShowBulkRejectDialog(true); + setSelectedDrafts(new Set([currentDraftId])); }; const handleSkip = () => { @@ -101,7 +158,7 @@ export default function ReviewQueue() { if (!currentDraft) return; setIsRegenerating(true); try { - const result = await api.regenerateDraft(currentDraft.id || currentDraft._id); + const result = await api.regenerateDraft(currentDraftId); // Update the current draft with new content const updatedDrafts = [...drafts]; updatedDrafts[currentIndex] = { @@ -111,6 +168,11 @@ export default function ReviewQueue() { ai_reasoning: result.ai_reasoning, }; setDrafts(updatedDrafts); + // Update edits + setDraftEdits(prev => ({ + ...prev, + [currentDraftId]: { subject: result.subject, body: result.body } + })); toast.success("Draft Regenerated", { description: "AI has created a new draft." }); } catch (error) { console.error("Failed to regenerate:", error); @@ -120,10 +182,111 @@ export default function ReviewQueue() { } }; + // ========================================================================== + // MULTI-SELECT & BULK ACTIONS + // ========================================================================== + + const toggleSelectMode = () => { + setSelectMode(!selectMode); + setSelectedDrafts(new Set()); + }; + + const toggleDraftSelection = (draftId: string) => { + const newSelected = new Set(selectedDrafts); + if (newSelected.has(draftId)) { + newSelected.delete(draftId); + } else { + newSelected.add(draftId); + } + setSelectedDrafts(newSelected); + }; + + const selectAllDrafts = () => { + const allIds = new Set(drafts.map(d => d.id || d._id)); + setSelectedDrafts(allIds); + }; + + const deselectAllDrafts = () => { + setSelectedDrafts(new Set()); + }; + + const handleBulkApprove = async () => { + if (selectedDrafts.size === 0) { + toast.error("No drafts selected"); + return; + } + + setIsActioning(true); + let successCount = 0; + let failCount = 0; + + for (const draftId of selectedDrafts) { + try { + const draft = drafts.find(d => (d.id || d._id) === draftId); + const edits = draftEdits[draftId]; + await api.approveDraft( + draftId, + edits?.subject || draft?.subject, + edits?.body || draft?.body + ); + successCount++; + } catch (e) { + console.error(`Failed to approve ${draftId}:`, e); + failCount++; + } + } + + toast.success(`Approved ${successCount} drafts`, { + description: failCount > 0 ? `${failCount} failed` : undefined + }); + + // Refresh drafts + await fetchDrafts(); + setSelectedDrafts(new Set()); + setSelectMode(false); + setIsActioning(false); + setCurrentIndex(0); + }; + + const handleBulkRejectConfirm = async () => { + if (selectedDrafts.size === 0 || !bulkRejectFeedback.trim()) { + toast.error("Please provide feedback"); + return; + } + + setIsActioning(true); + let successCount = 0; + + for (const draftId of selectedDrafts) { + try { + await api.rejectDraft(draftId, bulkRejectFeedback); + successCount++; + } catch (e) { + console.error(`Failed to reject ${draftId}:`, e); + } + } + + toast.success(`Rejected ${successCount} drafts`, { + description: "Feedback sent to AI agent." + }); + + // Refresh drafts + await fetchDrafts(); + setSelectedDrafts(new Set()); + setSelectMode(false); + setShowBulkRejectDialog(false); + setBulkRejectFeedback(""); + setIsActioning(false); + setCurrentIndex(0); + }; + + // ========================================================================== + // RENDER + // ========================================================================== + if (isLoading) { return (
- {/* Header Skeleton */}
@@ -134,9 +297,7 @@ export default function ReviewQueue() {
-
- {/* Left Skeleton */}
@@ -146,27 +307,12 @@ export default function ReviewQueue() {
-
- - -
- - {/* Right Skeleton */}
- - {/* Footer Skeleton */} -
- -
- - -
-
); } @@ -190,115 +336,242 @@ export default function ReviewQueue() {

Review Queue

- {currentIndex + 1} / {drafts.length} + {selectMode ? `${selectedDrafts.size} selected` : `${currentIndex + 1} / ${drafts.length}`}
- -
- - + {/* Bulk Actions */} + {selectMode ? ( + <> + + + + + + ) : ( + <> + + +
+ + + + )}
{/* Split View */}
- {/* Left: Prospect Context */} + {/* Left: Prospect List (in select mode) or Single Prospect */}
- + {selectMode ? ( +
+

+ Select drafts to approve or reject in bulk: +

+ {drafts.map((draft, idx) => { + const draftId = draft.id || draft._id; + const isSelected = selectedDrafts.has(draftId); + return ( +
toggleDraftSelection(draftId)} + className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all border ${isSelected + ? "bg-primary/10 border-primary/50" + : "bg-secondary/30 border-transparent hover:bg-secondary/50" + }`} + > + toggleDraftSelection(draftId)} + /> +
+

{draft.name || "Unknown"}

+

{draft.company || "Unknown"}

+

+ Subject: {draft.subject?.slice(0, 40)}... +

+
+
+ ); + })} +
+ ) : ( + + )}
{/* Right: Email Draft */}
- + {!selectMode && currentDraft && ( + + )} + {selectMode && ( +
+
+ +

Select drafts from the left panel

+

Then use bulk actions in the header

+
+
+ )}
{/* Action Footer */} -
- -
+ {!selectMode && ( +
- -
-
+
+ + +
+ + )} + + {/* Bulk Reject Dialog */} + + + + Reject {selectedDrafts.size} Draft{selectedDrafts.size > 1 ? "s" : ""} + + Provide feedback for the AI to improve future drafts. + + +
+