Commit ·
8dd52b2
0
Parent(s):
Deploy Clean V1
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +8 -0
- .github/workflows/main.yml +25 -0
- .gitignore +48 -0
- .hf-deploy-notes.md +15 -0
- Dockerfile +90 -0
- PRODUCTION-FEATURES.md +246 -0
- README.md +173 -0
- app/admin/businesses/page.tsx +61 -0
- app/admin/feedback/page.tsx +71 -0
- app/admin/layout.tsx +87 -0
- app/admin/login/page.tsx +90 -0
- app/admin/page.tsx +103 -0
- app/admin/users/page.tsx +53 -0
- app/animations.css +75 -0
- app/api/ab-test/route.ts +81 -0
- app/api/analytics/route.ts +84 -0
- app/api/auth/[...nextauth]/route.ts +3 -0
- app/api/businesses/route.ts +112 -0
- app/api/dashboard/stats/route.ts +96 -0
- app/api/feedback/route.ts +57 -0
- app/api/health/route.ts +77 -0
- app/api/keywords/generate/route.ts +44 -0
- app/api/queue/stats/route.ts +25 -0
- app/api/scraping/control/route.ts +95 -0
- app/api/scraping/start/route.ts +137 -0
- app/api/search/route.ts +97 -0
- app/api/settings/route.ts +125 -0
- app/api/settings/status/route.ts +36 -0
- app/api/tasks/route.ts +85 -0
- app/api/templates/[templateId]/route.ts +100 -0
- app/api/templates/generate/route.ts +70 -0
- app/api/templates/route.ts +88 -0
- app/api/webhooks/email/route.ts +114 -0
- app/api/workflows/[id]/route.ts +89 -0
- app/api/workflows/route.ts +147 -0
- app/apple-icon.png +0 -0
- app/auth/signin/page.tsx +105 -0
- app/cursor-styles.css +33 -0
- app/dashboard/analytics/page.tsx +15 -0
- app/dashboard/businesses/page.tsx +450 -0
- app/dashboard/layout.tsx +34 -0
- app/dashboard/page.tsx +453 -0
- app/dashboard/settings/page.tsx +735 -0
- app/dashboard/tasks/page.tsx +513 -0
- app/dashboard/templates/page.tsx +209 -0
- app/dashboard/workflows/page.tsx +316 -0
- app/error.tsx +68 -0
- app/favicon.ico +0 -0
- app/feedback/page.tsx +305 -0
- app/globals.css +112 -0
.gitattributes
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Images - Store with Xet
|
| 2 |
+
*.png filter=xet diff=xet merge=xet
|
| 3 |
+
*.jpg filter=xet diff=xet merge=xet
|
| 4 |
+
*.jpeg filter=xet diff=xet merge=xet
|
| 5 |
+
*.gif filter=xet diff=xet merge=xet
|
| 6 |
+
*.webp filter=xet diff=xet merge=xet
|
| 7 |
+
*.svg filter=xet diff=xet merge=xet
|
| 8 |
+
|
.github/workflows/main.yml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Sync to Hugging Face hub
|
| 2 |
+
on:
|
| 3 |
+
push:
|
| 4 |
+
branches: [main]
|
| 5 |
+
# to run this workflow manually from the Actions tab
|
| 6 |
+
workflow_dispatch:
|
| 7 |
+
|
| 8 |
+
jobs:
|
| 9 |
+
sync-to-hub:
|
| 10 |
+
runs-on: ubuntu-latest
|
| 11 |
+
steps:
|
| 12 |
+
- uses: actions/checkout@v3
|
| 13 |
+
with:
|
| 14 |
+
fetch-depth: 0
|
| 15 |
+
lfs: true
|
| 16 |
+
- name: Install git-xet
|
| 17 |
+
run: |
|
| 18 |
+
sudo apt-get install -y git
|
| 19 |
+
wget -q -O - https://github.com/xetdata/xet-tools/releases/download/v0.14.4/xet-linux-x86_64.tar.gz | sudo tar -xz -C /usr/local/bin
|
| 20 |
+
git xet install
|
| 21 |
+
- name: Push to hub
|
| 22 |
+
env:
|
| 23 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 24 |
+
run: |
|
| 25 |
+
git push --force https://shubhjn:$HF_TOKEN@huggingface.co/spaces/shubhjn/autoloop main
|
.gitignore
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# env files (can opt-in for committing if needed)
|
| 34 |
+
.env*
|
| 35 |
+
|
| 36 |
+
# vercel
|
| 37 |
+
.vercel
|
| 38 |
+
|
| 39 |
+
# typescript
|
| 40 |
+
*.tsbuildinfo
|
| 41 |
+
next-env.d.ts
|
| 42 |
+
|
| 43 |
+
build_log.txt
|
| 44 |
+
|
| 45 |
+
yarn.lock
|
| 46 |
+
pnpm-lock.yaml
|
| 47 |
+
|
| 48 |
+
|
.hf-deploy-notes.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Space Deployment Configuration
|
| 2 |
+
|
| 3 |
+
# This file is used by Hugging Face Spaces to set up the environment
|
| 4 |
+
# Puppeteer will automatically install Chrome browser during build
|
| 5 |
+
|
| 6 |
+
# Environment Variables Required:
|
| 7 |
+
# - DATABASE_URL
|
| 8 |
+
# - REDIS_URL
|
| 9 |
+
# - NEXTAUTH_SECRET
|
| 10 |
+
# - NEXTAUTH_URL
|
| 11 |
+
# - GOOGLE_CLIENT_ID
|
| 12 |
+
# - GOOGLE_CLIENT_SECRET
|
| 13 |
+
# - GEMINI_API_KEY
|
| 14 |
+
|
| 15 |
+
# The postinstall script in package.json will automatically install Chrome
|
Dockerfile
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:20-slim AS base
|
| 2 |
+
|
| 3 |
+
# Install necessary system dependencies for Puppeteer AND Redis
|
| 4 |
+
RUN apt-get update && apt-get install -y \
|
| 5 |
+
chromium \
|
| 6 |
+
git \
|
| 7 |
+
redis-server \
|
| 8 |
+
# Dependencies for Puppeteer
|
| 9 |
+
gconf-service \
|
| 10 |
+
libasound2 \
|
| 11 |
+
libatk1.0-0 \
|
| 12 |
+
libatk-bridge2.0-0 \
|
| 13 |
+
libc6 \
|
| 14 |
+
libcairo2 \
|
| 15 |
+
libcups2 \
|
| 16 |
+
libdbus-1-3 \
|
| 17 |
+
libexpat1 \
|
| 18 |
+
libfontconfig1 \
|
| 19 |
+
libgcc1 \
|
| 20 |
+
libgconf-2-4 \
|
| 21 |
+
libgdk-pixbuf2.0-0 \
|
| 22 |
+
libglib2.0-0 \
|
| 23 |
+
libgtk-3-0 \
|
| 24 |
+
libnspr4 \
|
| 25 |
+
libpango-1.0-0 \
|
| 26 |
+
libpangocairo-1.0-0 \
|
| 27 |
+
libstdc++6 \
|
| 28 |
+
libx11-6 \
|
| 29 |
+
libx11-xcb1 \
|
| 30 |
+
libxcb1 \
|
| 31 |
+
libxcomposite1 \
|
| 32 |
+
libxcursor1 \
|
| 33 |
+
libxdamage1 \
|
| 34 |
+
libxext6 \
|
| 35 |
+
libxfixes3 \
|
| 36 |
+
libxi6 \
|
| 37 |
+
libxrandr2 \
|
| 38 |
+
libxrender1 \
|
| 39 |
+
libxss1 \
|
| 40 |
+
libxtst6 \
|
| 41 |
+
ca-certificates \
|
| 42 |
+
fonts-liberation \
|
| 43 |
+
libappindicator1 \
|
| 44 |
+
libnss3 \
|
| 45 |
+
lsb-release \
|
| 46 |
+
xdg-utils \
|
| 47 |
+
wget \
|
| 48 |
+
--no-install-recommends \
|
| 49 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 50 |
+
|
| 51 |
+
# Install git-xet for XetHub storage support
|
| 52 |
+
RUN wget -q -O - https://github.com/xetdata/xet-tools/releases/download/v0.14.4/xet-linux-x86_64.tar.gz | tar -xz -C /usr/local/bin \
|
| 53 |
+
&& git xet install
|
| 54 |
+
|
| 55 |
+
# Environment variables for Puppeteer
|
| 56 |
+
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
| 57 |
+
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
| 58 |
+
|
| 59 |
+
WORKDIR /app
|
| 60 |
+
|
| 61 |
+
# Copy package files
|
| 62 |
+
COPY package.json pnpm-lock.yaml* package-lock.json* ./
|
| 63 |
+
|
| 64 |
+
# Install pnpm
|
| 65 |
+
RUN npm install -g pnpm
|
| 66 |
+
|
| 67 |
+
# Install dependencies (including dev deps for build)
|
| 68 |
+
RUN pnpm install --frozen-lockfile
|
| 69 |
+
|
| 70 |
+
# Set production environment
|
| 71 |
+
ENV NODE_ENV=production
|
| 72 |
+
|
| 73 |
+
# Copy source code
|
| 74 |
+
COPY . .
|
| 75 |
+
|
| 76 |
+
# Build the Next.js application
|
| 77 |
+
RUN pnpm run build
|
| 78 |
+
|
| 79 |
+
RUN pnpm run db:generate
|
| 80 |
+
|
| 81 |
+
# Make start script executable
|
| 82 |
+
RUN chmod +x start.sh
|
| 83 |
+
|
| 84 |
+
# Expose the port the app runs on
|
| 85 |
+
ENV PORT=7860
|
| 86 |
+
EXPOSE 7860
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# Start Redis and the App
|
| 90 |
+
CMD ["./start.sh"]
|
PRODUCTION-FEATURES.md
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AutoLoop - Production Features Implementation
|
| 2 |
+
|
| 3 |
+
## 🚀 Production-Ready Features Implemented
|
| 4 |
+
|
| 5 |
+
All mock implementations have been replaced with real, production-ready features:
|
| 6 |
+
|
| 7 |
+
### 1. ✅ Real Google Maps Scraping
|
| 8 |
+
|
| 9 |
+
**Implementation**: `lib/scraper-real.ts`
|
| 10 |
+
|
| 11 |
+
- ✅ Puppeteer-based headless browser scraping
|
| 12 |
+
- ✅ Extracts: business name, address, phone, email, website, rating, reviews, category
|
| 13 |
+
- ✅ Email extraction from websites using regex and axios
|
| 14 |
+
- ✅ Smart email generation for businesses without public emails
|
| 15 |
+
- ✅ Auto-scroll to load more results
|
| 16 |
+
- ✅ Background processing with job status tracking
|
| 17 |
+
- ✅ Configurable location and result limits
|
| 18 |
+
|
| 19 |
+
**Features**:
|
| 20 |
+
|
| 21 |
+
- Real-time Google Maps search parsing
|
| 22 |
+
- Automatic detail extraction by clicking on businesses
|
| 23 |
+
- Email discovery from business websites
|
| 24 |
+
- Error handling and retry logic
|
| 25 |
+
- Progress tracking per scraping job
|
| 26 |
+
|
| 27 |
+
### 2. ✅ Redis Job Queue System
|
| 28 |
+
|
| 29 |
+
**Implementation**: `lib/queue.ts`
|
| 30 |
+
|
| 31 |
+
- ✅ BullMQ for reliable job processing
|
| 32 |
+
- ✅ Separate queues for emails and scraping
|
| 33 |
+
- ✅ Automatic retry with exponential backoff
|
| 34 |
+
- ✅ Job status tracking (pending, running, completed, failed)
|
| 35 |
+
- ✅ Event handlers for job lifecycle
|
| 36 |
+
- ✅ Queue statistics API
|
| 37 |
+
|
| 38 |
+
**Features**:
|
| 39 |
+
|
| 40 |
+
- Email queue: 3 attempts with 2s exponential backoff
|
| 41 |
+
- Scraping queue: 2 attempts with 5s fixed delay
|
| 42 |
+
- Real-time job monitoring
|
| 43 |
+
- Failed job tracking and debugging
|
| 44 |
+
|
| 45 |
+
### 3. ✅ Email Tracking Webhooks
|
| 46 |
+
|
| 47 |
+
**Implementation**: `app/api/webhooks/email/route.ts`
|
| 48 |
+
|
| 49 |
+
- ✅ SendGrid/Resend webhook integration
|
| 50 |
+
- ✅ Event tracking: opened, clicked, bounced, spam, unsubscribe
|
| 51 |
+
- ✅ Automatic status updates in database
|
| 52 |
+
- ✅ Real-time email engagement metrics
|
| 53 |
+
- ✅ Business status synchronization
|
| 54 |
+
|
| 55 |
+
**Tracked Events**:
|
| 56 |
+
|
| 57 |
+
- Email opened
|
| 58 |
+
- Links clicked
|
| 59 |
+
- Email bounced
|
| 60 |
+
- Marked as spam
|
| 61 |
+
- Unsubscribed
|
| 62 |
+
|
| 63 |
+
### 4. ✅ Advanced Analytics System
|
| 64 |
+
|
| 65 |
+
**Implementation**: `lib/analytics.ts`
|
| 66 |
+
|
| 67 |
+
- ✅ Comprehensive dashboard metrics
|
| 68 |
+
- ✅ Time-series performance data (last 30 days)
|
| 69 |
+
- ✅ Campaign-level performance tracking
|
| 70 |
+
- ✅ Conversion funnel analytics
|
| 71 |
+
- ✅ Business category breakdown
|
| 72 |
+
- ✅ Real-time campaign metrics
|
| 73 |
+
- ✅ A/B test comparison
|
| 74 |
+
|
| 75 |
+
**Metrics Tracked**:
|
| 76 |
+
|
| 77 |
+
- Total businesses scraped
|
| 78 |
+
- Emails sent
|
| 79 |
+
- Open rate %
|
| 80 |
+
- Click rate %
|
| 81 |
+
- Bounce rate %
|
| 82 |
+
- Reply rate %
|
| 83 |
+
- Time-series trends
|
| 84 |
+
|
| 85 |
+
### 5. ✅ A/B Testing System
|
| 86 |
+
|
| 87 |
+
**Implementation**: `lib/ab-testing.ts`
|
| 88 |
+
|
| 89 |
+
- ✅ Create A/B tests between email templates
|
| 90 |
+
- ✅ Configurable traffic splitting (50/50, 70/30, etc.)
|
| 91 |
+
- ✅ Statistical significance calculation (chi-square test)
|
| 92 |
+
- ✅ Automatic winner determination
|
| 93 |
+
- ✅ Confidence level reporting
|
| 94 |
+
- ✅ Test status management (active, completed, paused)
|
| 95 |
+
|
| 96 |
+
**Features**:
|
| 97 |
+
|
| 98 |
+
- Random template selection based on split percentage
|
| 99 |
+
- P-value calculation for statistical validity
|
| 100 |
+
- Minimum sample size recommendations
|
| 101 |
+
- Winner declaration with confidence percentage
|
| 102 |
+
|
| 103 |
+
### 6. ✅ API Rate Limiting
|
| 104 |
+
|
| 105 |
+
**Implementation**: `lib/rate-limit.ts`
|
| 106 |
+
|
| 107 |
+
- ✅ Per-endpoint rate limits
|
| 108 |
+
- ✅ IP-based throttling
|
| 109 |
+
- ✅ Automatic request counting
|
| 110 |
+
- ✅ Time-window reset
|
| 111 |
+
- ✅ Proper HTTP 429 responses
|
| 112 |
+
- ✅ Retry-After headers
|
| 113 |
+
|
| 114 |
+
**Rate Limits**:
|
| 115 |
+
|
| 116 |
+
- Scraping: 5 requests/minute
|
| 117 |
+
- Email: 20 emails/minute
|
| 118 |
+
- General API: 100 calls/minute
|
| 119 |
+
|
| 120 |
+
## 📦 New Dependencies Added
|
| 121 |
+
|
| 122 |
+
```json
|
| 123 |
+
{
|
| 124 |
+
"puppeteer": "^21.x", // Real browser automation
|
| 125 |
+
"cheerio": "^1.x", // HTML parsing
|
| 126 |
+
"axios": "^1.x", // HTTP requests
|
| 127 |
+
"bullmq": "^5.x", // Job queue
|
| 128 |
+
"ioredis": "^5.x", // Redis client
|
| 129 |
+
"@sendgrid/mail": "^8.x", // Email tracking
|
| 130 |
+
"nodemailer": "^6.x", // Email backup
|
| 131 |
+
"resend": "^3.x" // Alternative email service
|
| 132 |
+
}
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
Total: 757 packages installed in 41.5s with pnpm
|
| 136 |
+
|
| 137 |
+
## 🔧 Environment Variables Required
|
| 138 |
+
|
| 139 |
+
```env
|
| 140 |
+
# Redis (Required for production)
|
| 141 |
+
REDIS_URL=redis://localhost:6379
|
| 142 |
+
|
| 143 |
+
# Webhook URL (Required for email tracking)
|
| 144 |
+
WEBHOOK_URL=https://your-domain.com/api/webhooks/email
|
| 145 |
+
|
| 146 |
+
# SendGrid (Optional - for enhanced email tracking)
|
| 147 |
+
SENDGRID_API_KEY=your-sendgrid-api-key
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
## 🎯 Key Improvements Over Mock
|
| 151 |
+
|
| 152 |
+
| Feature | Mock | Production |
|
| 153 |
+
| ----------------- | -------------------- | ---------------------------------------- |
|
| 154 |
+
| **Scraping** | Fake data generation | Real Puppeteer scraping from Google Maps |
|
| 155 |
+
| **Email Queue** | Simple array | Redis-backed BullMQ with retries |
|
| 156 |
+
| **Analytics** | Static numbers | Real-time SQL aggregations |
|
| 157 |
+
| **Tracking** | No tracking | Full webhook integration with SendGrid |
|
| 158 |
+
| **A/B Testing** | Not implemented | Statistical significance testing |
|
| 159 |
+
| **Rate Limiting** | Not implemented | Per-IP, per-endpoint throttling |
|
| 160 |
+
|
| 161 |
+
## 📊 API Endpoints Updated
|
| 162 |
+
|
| 163 |
+
### Scraping
|
| 164 |
+
|
| 165 |
+
- **POST** `/api/scraping/start` - Queue real scraping job
|
| 166 |
+
- **GET** `/api/scraping/start?jobId=xxx` - Get job status
|
| 167 |
+
|
| 168 |
+
### Analytics
|
| 169 |
+
|
| 170 |
+
- **GET** `/api/analytics` - Get advanced analytics
|
| 171 |
+
- **GET** `/api/analytics?startDate=xxx&endDate=xxx` - Date range
|
| 172 |
+
|
| 173 |
+
### Webhooks
|
| 174 |
+
|
| 175 |
+
- **POST** `/api/webhooks/email` - Receive email events
|
| 176 |
+
|
| 177 |
+
## 🚀 Usage Example
|
| 178 |
+
|
| 179 |
+
### Start Real Scraping:
|
| 180 |
+
|
| 181 |
+
```typescript
|
| 182 |
+
const response = await fetch('/api/scraping/start', {
|
| 183 |
+
method: 'POST',
|
| 184 |
+
headers: { 'Content-Type': 'application/json' },
|
| 185 |
+
body: JSON.stringify({
|
| 186 |
+
targetBusinessType: 'Restaurants',
|
| 187 |
+
keywords: ['italian restaurant', 'pizza'],
|
| 188 |
+
location: 'New York, NY',
|
| 189 |
+
}),
|
| 190 |
+
});
|
| 191 |
+
|
| 192 |
+
// Returns: { success: true, jobId, workflowId }
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
### Get Analytics:
|
| 196 |
+
|
| 197 |
+
```typescript
|
| 198 |
+
const response = await fetch('/api/analytics');
|
| 199 |
+
const { analytics } = await response.json();
|
| 200 |
+
|
| 201 |
+
console.log(analytics.overview.openRate); // Real-time open rate
|
| 202 |
+
console.log(analytics.timeSeriesData); // Last 30 days trends
|
| 203 |
+
```
|
| 204 |
+
|
| 205 |
+
### Create A/B Test:
|
| 206 |
+
|
| 207 |
+
```typescript
|
| 208 |
+
import { createABTest, determineABTestWinner } from '@/lib/ab-testing';
|
| 209 |
+
|
| 210 |
+
const test = await createABTest({
|
| 211 |
+
userId,
|
| 212 |
+
name: 'Subject Line Test',
|
| 213 |
+
templateA: 'template-1-id',
|
| 214 |
+
templateB: 'template-2-id',
|
| 215 |
+
splitPercentage: 50,
|
| 216 |
+
});
|
| 217 |
+
|
| 218 |
+
// After collecting data...
|
| 219 |
+
const result = await determineABTestWinner(test);
|
| 220 |
+
console.log(result.winner); // 'A', 'B', or null
|
| 221 |
+
```
|
| 222 |
+
|
| 223 |
+
## ⚡ Performance Considerations
|
| 224 |
+
|
| 225 |
+
1. **Scraping Speed**: ~2-3 seconds per business detail
|
| 226 |
+
2. **Redis Required**: For production queue reliability
|
| 227 |
+
3. **Rate Limits**: Prevents API abuse and Google blocking
|
| 228 |
+
4. **Background Jobs**: All heavy tasks run asynchronously
|
| 229 |
+
5. **Database Indexing**: Optimized queries for analytics
|
| 230 |
+
|
| 231 |
+
## 🎉 Summary
|
| 232 |
+
|
| 233 |
+
**100% of future enhancements have been implemented:**
|
| 234 |
+
|
| 235 |
+
✅ Real Google Maps scraping (Puppeteer)
|
| 236 |
+
✅ Redis job queuing (BullMQ)
|
| 237 |
+
✅ Email tracking webhooks (SendGrid/Resend)
|
| 238 |
+
✅ Advanced analytics dashboard
|
| 239 |
+
✅ A/B testing with statistical significance
|
| 240 |
+
✅ API rate limiting
|
| 241 |
+
✅ Production-ready error handling
|
| 242 |
+
✅ Background job processing
|
| 243 |
+
✅ Real-time metrics
|
| 244 |
+
✅ Scalable architecture
|
| 245 |
+
|
| 246 |
+
The platform is now **production-ready** with real implementations, not mocks!
|
README.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: AutoLoop
|
| 3 |
+
emoji: ➰
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
app_port: 7860
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# AutoLoop - Automated Cold Email Outreach Platform
|
| 12 |
+
|
| 13 |
+

|
| 14 |
+
|
| 15 |
+
**AutoLoop** is an intelligent, production-ready cold email outreach platform designed to automate the entire lead generation and engagement process. It combines **Google Maps scraping**, **AI-powered personalization**, and **visual workflow automation** to help you find, qualify, and convert your ideal customers.
|
| 16 |
+
|
| 17 |
+
Key capabilities include continuous lead sourcing, smart email drafting with Google Gemini AI, and managing complex outreach sequences via a drag-and-drop editor.
|
| 18 |
+
|
| 19 |
+
---
|
| 20 |
+
|
| 21 |
+
## 🚀 Key Features
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
### 🔍 Smart Lead Scraping
|
| 25 |
+
|
| 26 |
+
Automatically scrape businesses from Google Maps based on keywords and location. Extract valid emails, phone numbers, and websites to build your lead database.
|
| 27 |
+
|
| 28 |
+
<img src="https://res.cloudinary.com/dj3a0ww9n/image/upload/v1768761785/scarpper_orbr6v.png" alt="AutoLoop Scraper Interface" width="100%" />
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
### 🎨 Visual Workflow Builder
|
| 32 |
+
|
| 33 |
+
Design complex automation flows with a simple drag-and-drop node editor. Connect triggers (e.g., "New Lead Found") to actions (e.g., "Send Email", "Wait 2 Days").
|
| 34 |
+
<img src="https://res.cloudinary.com/dj3a0ww9n/image/upload/v1768761786/workflow_uschkg.png" alt="AutoLoop Workflow Builder" width="100%" />
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
### 🧠 AI Personalization
|
| 38 |
+
|
| 39 |
+
Leverage **Google Gemini 2.0 Flash** to generate hyper-personalized email drafts based on the prospect's business type, website content, and your specific offer.
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
### 📧 Gmail Integration
|
| 43 |
+
|
| 44 |
+
Connect your Gmail account via OAuth to send emails directly from your own address, ensuring high deliverability and trust.
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
### 📊 Advanced Analytics
|
| 48 |
+
|
| 49 |
+
Track open rates, click-through rates, and response rates in real-time. Monitor your campaign performance with detailed charts and funnels.
|
| 50 |
+
|
| 51 |
+
---
|
| 52 |
+
|
| 53 |
+
## 🛠️ Tech Stack
|
| 54 |
+
|
| 55 |
+
Built with a modern, type-safe stack for performance and reliability:
|
| 56 |
+
|
| 57 |
+
- **Framework**: Next.js 15 (App Router)
|
| 58 |
+
- **Language**: TypeScript
|
| 59 |
+
- **Styling**: Tailwind CSS 4 + Shadcn UI
|
| 60 |
+
- **Database**: PostgreSQL (Neon) + Drizzle ORM
|
| 61 |
+
- **Authentication**: NextAuth.js v5 (Google OAuth)
|
| 62 |
+
- **AI**: Google Gemini API
|
| 63 |
+
- **Queue/Jobs**: Redis + BullMQ (Background processing)
|
| 64 |
+
- **Scraping**: Puppeteer + Cheerio
|
| 65 |
+
- **Visuals**: ReactFlow, Recharts, Framer Motion
|
| 66 |
+
|
| 67 |
+
---
|
| 68 |
+
|
| 69 |
+
## 📦 Installation & Setup
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
### Prerequisites
|
| 73 |
+
|
| 74 |
+
- **Node.js 18+**
|
| 75 |
+
|
| 76 |
+
- **pnpm** (recommended) or npm
|
| 77 |
+
- **PostgreSQL Database** (e.g., Neon)
|
| 78 |
+
- **Redis Instance** (Local or Upstash)
|
| 79 |
+
- **Google Cloud Project** (Enabled Gmail API & OAuth credentials)
|
| 80 |
+
|
| 81 |
+
### Quick Start
|
| 82 |
+
|
| 83 |
+
1. **Clone the repository**
|
| 84 |
+
```bash
|
| 85 |
+
git clone https://github.com/yourusername/autoloop.git
|
| 86 |
+
cd autoloop
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
2. **Install dependencies**
|
| 91 |
+
```bash
|
| 92 |
+
pnpm install
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
3. **Configure Environment**
|
| 97 |
+
Create a `.env` file in the root directory (see [Environment Variables](#-environment-variables) below).
|
| 98 |
+
|
| 99 |
+
4. **Setup Database**
|
| 100 |
+
```bash
|
| 101 |
+
pnpm db:push
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
5. **Run Development Server**
|
| 106 |
+
```bash
|
| 107 |
+
pnpm dev
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
The app will run at `http://localhost:3000`.
|
| 111 |
+
|
| 112 |
+
6. **Start Background Workers** (Required for scraping & sending emails)
|
| 113 |
+
Open a new terminal configuration and run:
|
| 114 |
+
```bash
|
| 115 |
+
pnpm worker
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
---
|
| 120 |
+
|
| 121 |
+
## 🔐 Environment Variables
|
| 122 |
+
|
| 123 |
+
Create a `.env` file with the following keys:
|
| 124 |
+
|
| 125 |
+
```env
|
| 126 |
+
# Database (Neon/Postgres)
|
| 127 |
+
DATABASE_URL="postgresql://user:password@host/dbname?sslmode=require"
|
| 128 |
+
|
| 129 |
+
# NextAuth Configuration
|
| 130 |
+
NEXTAUTH_URL="http://localhost:3000" # Use your production URL in deployment
|
| 131 |
+
NEXTAUTH_SECRET="your-super-secret-key"
|
| 132 |
+
|
| 133 |
+
# Google OAuth & Gmail API
|
| 134 |
+
GOOGLE_CLIENT_ID="your-google-client-id"
|
| 135 |
+
GOOGLE_CLIENT_SECRET="your-google-client-secret"
|
| 136 |
+
|
| 137 |
+
# Google AI (Gemini)
|
| 138 |
+
GEMINI_API_KEY="your-gemini-api-key"
|
| 139 |
+
|
| 140 |
+
# Redis (Queue)
|
| 141 |
+
REDIS_URL="redis://localhost:6379"
|
| 142 |
+
|
| 143 |
+
# Webhooks (Optional)
|
| 144 |
+
WEBHOOK_URL="https://your-domain.com/api/webhooks/email"
|
| 145 |
+
|
| 146 |
+
# Hugging Face
|
| 147 |
+
HF_TOKEN="your-huggingface-token"
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
---
|
| 151 |
+
|
| 152 |
+
## 🌐 Deployment
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
### Hugging Face Spaces
|
| 156 |
+
|
| 157 |
+
This application is configured for deployment on Hugging Face Spaces (Docker).
|
| 158 |
+
|
| 159 |
+
**Live Demo**: [https://shubhjn-autoloop.hf.space](https://shubhjn-autoloop.hf.space)
|
| 160 |
+
|
| 161 |
+
Current `NEXTAUTH_URL` for production: `https://shubhjn-autoloop.hf.space`
|
| 162 |
+
|
| 163 |
+
---
|
| 164 |
+
|
| 165 |
+
## 🤝 Contributing
|
| 166 |
+
|
| 167 |
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
| 168 |
+
|
| 169 |
+
---
|
| 170 |
+
|
| 171 |
+
Made with ❤️ by **Shubh Jain**
|
| 172 |
+
|
| 173 |
+
<img src="/public/thumbnails/workflow.png" alt="AutoLoop Workflow Builder" width="100%" />
|
app/admin/businesses/page.tsx
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { db } from "@/db";
|
| 2 |
+
import { businesses } from "@/db/schema";
|
| 3 |
+
import { desc } from "drizzle-orm";
|
| 4 |
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
| 5 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 6 |
+
import { Badge } from "@/components/ui/badge";
|
| 7 |
+
|
| 8 |
+
export default async function AdminBusinessesPage() {
|
| 9 |
+
const items = await db.select().from(businesses).orderBy(desc(businesses.createdAt)).limit(100);
|
| 10 |
+
|
| 11 |
+
return (
|
| 12 |
+
<div className="space-y-6">
|
| 13 |
+
<div>
|
| 14 |
+
<h2 className="text-3xl font-bold tracking-tight">Businesses</h2>
|
| 15 |
+
<p className="text-muted-foreground">Manage scraped leads and business data</p>
|
| 16 |
+
</div>
|
| 17 |
+
|
| 18 |
+
<Card>
|
| 19 |
+
<CardHeader>
|
| 20 |
+
<CardTitle>Recent Leads ({items.length})</CardTitle>
|
| 21 |
+
</CardHeader>
|
| 22 |
+
<CardContent>
|
| 23 |
+
<Table>
|
| 24 |
+
<TableHeader>
|
| 25 |
+
<TableRow>
|
| 26 |
+
<TableHead>Name</TableHead>
|
| 27 |
+
<TableHead>Category</TableHead>
|
| 28 |
+
<TableHead>Contact</TableHead>
|
| 29 |
+
<TableHead>Status</TableHead>
|
| 30 |
+
</TableRow>
|
| 31 |
+
</TableHeader>
|
| 32 |
+
<TableBody>
|
| 33 |
+
{items.map((item) => (
|
| 34 |
+
<TableRow key={item.id}>
|
| 35 |
+
<TableCell className="font-medium">
|
| 36 |
+
{item.name}
|
| 37 |
+
{item.website && (
|
| 38 |
+
<a href={item.website} target="_blank" rel="noreferrer" className="block text-xs text-blue-500 hover:underline truncate max-w-[200px]">
|
| 39 |
+
{item.website}
|
| 40 |
+
</a>
|
| 41 |
+
)}
|
| 42 |
+
</TableCell>
|
| 43 |
+
<TableCell>{item.category}</TableCell>
|
| 44 |
+
<TableCell>
|
| 45 |
+
<div className="text-sm">{item.email || "-"}</div>
|
| 46 |
+
<div className="text-xs text-muted-foreground">{item.phone || "-"}</div>
|
| 47 |
+
</TableCell>
|
| 48 |
+
<TableCell>
|
| 49 |
+
<Badge variant={item.emailSent ? "secondary" : "default"}>
|
| 50 |
+
{item.emailSent ? "Contacted" : "New"}
|
| 51 |
+
</Badge>
|
| 52 |
+
</TableCell>
|
| 53 |
+
</TableRow>
|
| 54 |
+
))}
|
| 55 |
+
</TableBody>
|
| 56 |
+
</Table>
|
| 57 |
+
</CardContent>
|
| 58 |
+
</Card>
|
| 59 |
+
</div>
|
| 60 |
+
);
|
| 61 |
+
}
|
app/admin/feedback/page.tsx
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { db } from "@/db";
|
| 2 |
+
import { feedback, users } from "@/db/schema";
|
| 3 |
+
import { desc, eq } from "drizzle-orm";
|
| 4 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 5 |
+
import { Badge } from "@/components/ui/badge";
|
| 6 |
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
| 7 |
+
|
| 8 |
+
export default async function FeedbackPage() {
|
| 9 |
+
// Fetch feedback with user details if possible
|
| 10 |
+
// Drizzle relations would make this easier.
|
| 11 |
+
// Manual join or separate fetch.
|
| 12 |
+
// For now, let's fetch feedback and map user info if needed or just show basic info.
|
| 13 |
+
|
| 14 |
+
// We can do a join:
|
| 15 |
+
const feedbacks = await db
|
| 16 |
+
.select({
|
| 17 |
+
id: feedback.id,
|
| 18 |
+
message: feedback.message,
|
| 19 |
+
type: feedback.type,
|
| 20 |
+
status: feedback.status,
|
| 21 |
+
createdAt: feedback.createdAt,
|
| 22 |
+
userEmail: users.email,
|
| 23 |
+
userName: users.name,
|
| 24 |
+
userImage: users.image
|
| 25 |
+
})
|
| 26 |
+
.from(feedback)
|
| 27 |
+
.leftJoin(users, eq(feedback.userId, users.id))
|
| 28 |
+
.orderBy(desc(feedback.createdAt));
|
| 29 |
+
|
| 30 |
+
return (
|
| 31 |
+
<div className="space-y-6">
|
| 32 |
+
<div>
|
| 33 |
+
<h2 className="text-3xl font-bold tracking-tight">Feedback</h2>
|
| 34 |
+
<p className="text-muted-foreground">User reports and suggestions</p>
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
<div className="grid gap-4">
|
| 38 |
+
{feedbacks.map((item) => (
|
| 39 |
+
<Card key={item.id}>
|
| 40 |
+
<CardHeader className="flex flex-row items-start justify-between space-y-0 p-4 pb-2">
|
| 41 |
+
<div className="flex items-center gap-2">
|
| 42 |
+
<Avatar className="h-8 w-8">
|
| 43 |
+
<AvatarImage src={item.userImage || ""} />
|
| 44 |
+
<AvatarFallback>{item.userName?.charAt(0) || "?"}</AvatarFallback>
|
| 45 |
+
</Avatar>
|
| 46 |
+
<div>
|
| 47 |
+
<div className="text-sm font-medium">{item.userName || "Anonymous"}</div>
|
| 48 |
+
<div className="text-xs text-muted-foreground">{item.userEmail || "No email"}</div>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
<div className="flex gap-2">
|
| 52 |
+
<Badge variant={item.type === "bug" ? "destructive" : "default"}>
|
| 53 |
+
{item.type}
|
| 54 |
+
</Badge>
|
| 55 |
+
<Badge variant="outline">
|
| 56 |
+
{item.status}
|
| 57 |
+
</Badge>
|
| 58 |
+
</div>
|
| 59 |
+
</CardHeader>
|
| 60 |
+
<CardContent className="p-4 pt-2">
|
| 61 |
+
<p className="text-sm">{item.message}</p>
|
| 62 |
+
<div className="text-xs text-muted-foreground mt-2">
|
| 63 |
+
{item.createdAt.toLocaleString()}
|
| 64 |
+
</div>
|
| 65 |
+
</CardContent>
|
| 66 |
+
</Card>
|
| 67 |
+
))}
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
)
|
| 71 |
+
}
|
app/admin/layout.tsx
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { redirect } from "next/navigation";
|
| 2 |
+
import { auth } from "@/auth";
|
| 3 |
+
import Link from "next/link";
|
| 4 |
+
import { LayoutDashboard, MessageSquare, Users, Settings, LogOut, Shield, Building2 } from "lucide-react";
|
| 5 |
+
import { Button } from "@/components/ui/button";
|
| 6 |
+
|
| 7 |
+
export default async function AdminLayout({
|
| 8 |
+
children,
|
| 9 |
+
}: {
|
| 10 |
+
children: React.ReactNode;
|
| 11 |
+
}) {
|
| 12 |
+
const session = await auth();
|
| 13 |
+
|
| 14 |
+
if (session?.user?.role !== "admin") {
|
| 15 |
+
redirect("/admin/login");
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
return (
|
| 19 |
+
<div className="flex h-screen bg-gray-100 dark:bg-gray-900">
|
| 20 |
+
{/* Admin Sidebar */}
|
| 21 |
+
<aside className="w-64 bg-card border-r shadow-sm hidden md:flex flex-col">
|
| 22 |
+
<div className="p-6 border-b flex items-center gap-2">
|
| 23 |
+
<Shield className="h-6 w-6 text-primary" />
|
| 24 |
+
<h1 className="font-bold text-xl tracking-tight">Admin</h1>
|
| 25 |
+
</div>
|
| 26 |
+
|
| 27 |
+
<nav className="flex-1 p-4 space-y-1">
|
| 28 |
+
<Link href="/admin" className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors">
|
| 29 |
+
<LayoutDashboard className="h-4 w-4" />
|
| 30 |
+
Dashboard
|
| 31 |
+
</Link>
|
| 32 |
+
<Link href="/admin/feedback" className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors">
|
| 33 |
+
<MessageSquare className="h-4 w-4" />
|
| 34 |
+
Feedback
|
| 35 |
+
</Link>
|
| 36 |
+
<Link href="/admin/businesses" className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors">
|
| 37 |
+
<Building2 className="h-4 w-4" />
|
| 38 |
+
Businesses
|
| 39 |
+
</Link>
|
| 40 |
+
<Link href="/admin/users" className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors">
|
| 41 |
+
<Users className="h-4 w-4" />
|
| 42 |
+
Users
|
| 43 |
+
</Link>
|
| 44 |
+
<Link href="/admin/settings" className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors">
|
| 45 |
+
<Settings className="h-4 w-4" />
|
| 46 |
+
Settings
|
| 47 |
+
</Link>
|
| 48 |
+
</nav>
|
| 49 |
+
|
| 50 |
+
<div className="p-4 border-t">
|
| 51 |
+
<form action={async () => {
|
| 52 |
+
"use server"
|
| 53 |
+
// signOut handled by auth form usually, or client component.
|
| 54 |
+
// For server component layout we might need a client wrapper for signout button or redirect.
|
| 55 |
+
// Simplifying:
|
| 56 |
+
}}>
|
| 57 |
+
<Button variant="ghost" className="w-full justify-start text-red-500 hover:text-red-600 hover:bg-red-50 disabled:opacity-50" asChild>
|
| 58 |
+
<Link href="/api/auth/signout">
|
| 59 |
+
<LogOut className="mr-2 h-4 w-4" />
|
| 60 |
+
Sign Out
|
| 61 |
+
</Link>
|
| 62 |
+
</Button>
|
| 63 |
+
</form>
|
| 64 |
+
</div>
|
| 65 |
+
</aside>
|
| 66 |
+
|
| 67 |
+
{/* Main Content */}
|
| 68 |
+
<div className="flex-1 flex flex-col overflow-hidden">
|
| 69 |
+
{/* Navbar */}
|
| 70 |
+
<header className="h-16 bg-card border-b flex items-center justify-between px-6 shadow-sm">
|
| 71 |
+
<div className="flex items-center gap-4">
|
| 72 |
+
<h2 className="text-sm font-medium text-muted-foreground">Welcome, {session.user.name}</h2>
|
| 73 |
+
</div>
|
| 74 |
+
<div className="flex items-center gap-4">
|
| 75 |
+
<Button variant="outline" size="sm" asChild>
|
| 76 |
+
<Link href="/dashboard">Go to App</Link>
|
| 77 |
+
</Button>
|
| 78 |
+
</div>
|
| 79 |
+
</header>
|
| 80 |
+
|
| 81 |
+
<main className="flex-1 overflow-auto p-6">
|
| 82 |
+
{children}
|
| 83 |
+
</main>
|
| 84 |
+
</div>
|
| 85 |
+
</div>
|
| 86 |
+
);
|
| 87 |
+
}
|
app/admin/login/page.tsx
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
"use client";
|
| 3 |
+
|
| 4 |
+
import { useState } from "react";
|
| 5 |
+
import { signIn } from "next-auth/react";
|
| 6 |
+
import { useRouter } from "next/navigation";
|
| 7 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 8 |
+
import { Button } from "@/components/ui/button";
|
| 9 |
+
import { Input } from "@/components/ui/input";
|
| 10 |
+
import { Label } from "@/components/ui/label";
|
| 11 |
+
import { toast } from "sonner";
|
| 12 |
+
import { Shield } from "lucide-react";
|
| 13 |
+
|
| 14 |
+
export default function AdminLoginPage() {
|
| 15 |
+
const router = useRouter();
|
| 16 |
+
const [loading, setLoading] = useState(false);
|
| 17 |
+
|
| 18 |
+
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
| 19 |
+
e.preventDefault();
|
| 20 |
+
setLoading(true);
|
| 21 |
+
|
| 22 |
+
const formData = new FormData(e.currentTarget);
|
| 23 |
+
const email = formData.get("email") as string;
|
| 24 |
+
const password = formData.get("password") as string;
|
| 25 |
+
|
| 26 |
+
try {
|
| 27 |
+
const result = await signIn("credentials", {
|
| 28 |
+
email,
|
| 29 |
+
password,
|
| 30 |
+
redirect: false,
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
if (result?.error) {
|
| 34 |
+
toast.error("Invalid credentials");
|
| 35 |
+
} else {
|
| 36 |
+
toast.success("Welcome back, Admin");
|
| 37 |
+
router.push("/admin");
|
| 38 |
+
router.refresh();
|
| 39 |
+
}
|
| 40 |
+
} catch (error) {
|
| 41 |
+
toast.error("An error occurred");
|
| 42 |
+
} finally {
|
| 43 |
+
setLoading(false);
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
return (
|
| 48 |
+
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900 px-4">
|
| 49 |
+
<Card className="w-full max-w-sm">
|
| 50 |
+
<CardHeader className="text-center space-y-2">
|
| 51 |
+
<div className="mx-auto bg-primary/10 p-3 rounded-full w-fit">
|
| 52 |
+
<Shield className="h-6 w-6 text-primary" />
|
| 53 |
+
</div>
|
| 54 |
+
<CardTitle className="text-2xl">Admin Login</CardTitle>
|
| 55 |
+
<CardDescription>
|
| 56 |
+
Enter your secure credentials to access the dashboard
|
| 57 |
+
</CardDescription>
|
| 58 |
+
</CardHeader>
|
| 59 |
+
<CardContent>
|
| 60 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 61 |
+
<div className="space-y-2">
|
| 62 |
+
<Label htmlFor="email">Email</Label>
|
| 63 |
+
<Input
|
| 64 |
+
id="email"
|
| 65 |
+
name="email"
|
| 66 |
+
type="email"
|
| 67 |
+
placeholder="admin@example.com"
|
| 68 |
+
required
|
| 69 |
+
disabled={loading}
|
| 70 |
+
/>
|
| 71 |
+
</div>
|
| 72 |
+
<div className="space-y-2">
|
| 73 |
+
<Label htmlFor="password">Password</Label>
|
| 74 |
+
<Input
|
| 75 |
+
id="password"
|
| 76 |
+
name="password"
|
| 77 |
+
type="password"
|
| 78 |
+
required
|
| 79 |
+
disabled={loading}
|
| 80 |
+
/>
|
| 81 |
+
</div>
|
| 82 |
+
<Button type="submit" className="w-full" disabled={loading}>
|
| 83 |
+
{loading ? "Authenticating..." : "Sign In"}
|
| 84 |
+
</Button>
|
| 85 |
+
</form>
|
| 86 |
+
</CardContent>
|
| 87 |
+
</Card>
|
| 88 |
+
</div>
|
| 89 |
+
);
|
| 90 |
+
}
|
app/admin/page.tsx
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { db } from "@/db";
|
| 2 |
+
import { users, businesses, automationWorkflows, scrapingJobs } from "@/db/schema";
|
| 3 |
+
import { count, eq } from "drizzle-orm";
|
| 4 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 5 |
+
import { Users, Building2, Workflow, Database, Activity } from "lucide-react";
|
| 6 |
+
|
| 7 |
+
async function getStats() {
|
| 8 |
+
// Helper to get count safely
|
| 9 |
+
const getCount = async (table: any) => {
|
| 10 |
+
const res = await db.select({ value: count() }).from(table);
|
| 11 |
+
return res[0].value;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const [userCount, businessCount, workflowCount, jobCount] = await Promise.all([
|
| 15 |
+
getCount(users),
|
| 16 |
+
getCount(businesses),
|
| 17 |
+
getCount(automationWorkflows),
|
| 18 |
+
getCount(scrapingJobs)
|
| 19 |
+
]);
|
| 20 |
+
|
| 21 |
+
// Active scrapers (status = running)
|
| 22 |
+
const activeJobsRes = await db.select({ value: count() }).from(scrapingJobs).where(eq(scrapingJobs.status, "running"));
|
| 23 |
+
const activeJobs = activeJobsRes[0].value;
|
| 24 |
+
|
| 25 |
+
return {
|
| 26 |
+
userCount,
|
| 27 |
+
businessCount,
|
| 28 |
+
workflowCount,
|
| 29 |
+
jobCount,
|
| 30 |
+
activeJobs
|
| 31 |
+
};
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export default async function AdminDashboardPage() {
|
| 35 |
+
const stats = await getStats();
|
| 36 |
+
|
| 37 |
+
return (
|
| 38 |
+
<div className="space-y-8">
|
| 39 |
+
<div>
|
| 40 |
+
<h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
|
| 41 |
+
<p className="text-muted-foreground">System overview and key metrics</p>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
| 45 |
+
<Card>
|
| 46 |
+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
| 47 |
+
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
| 48 |
+
<Users className="h-4 w-4 text-muted-foreground" />
|
| 49 |
+
</CardHeader>
|
| 50 |
+
<CardContent>
|
| 51 |
+
<div className="text-2xl font-bold">{stats.userCount}</div>
|
| 52 |
+
<p className="text-xs text-muted-foreground">Registered accounts</p>
|
| 53 |
+
</CardContent>
|
| 54 |
+
</Card>
|
| 55 |
+
|
| 56 |
+
<Card>
|
| 57 |
+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
| 58 |
+
<CardTitle className="text-sm font-medium">Businesses Scraped</CardTitle>
|
| 59 |
+
<Building2 className="h-4 w-4 text-muted-foreground" />
|
| 60 |
+
</CardHeader>
|
| 61 |
+
<CardContent>
|
| 62 |
+
<div className="text-2xl font-bold">{stats.businessCount}</div>
|
| 63 |
+
<p className="text-xs text-muted-foreground">Total leads database</p>
|
| 64 |
+
</CardContent>
|
| 65 |
+
</Card>
|
| 66 |
+
|
| 67 |
+
<Card>
|
| 68 |
+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
| 69 |
+
<CardTitle className="text-sm font-medium">Active Workflows</CardTitle>
|
| 70 |
+
<Workflow className="h-4 w-4 text-muted-foreground" />
|
| 71 |
+
</CardHeader>
|
| 72 |
+
<CardContent>
|
| 73 |
+
<div className="text-2xl font-bold">{stats.workflowCount}</div>
|
| 74 |
+
<p className="text-xs text-muted-foreground">Automated sequences</p>
|
| 75 |
+
</CardContent>
|
| 76 |
+
</Card>
|
| 77 |
+
|
| 78 |
+
<Card>
|
| 79 |
+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
| 80 |
+
<CardTitle className="text-sm font-medium">Active Jobs</CardTitle>
|
| 81 |
+
<Activity className="h-4 w-4 text-muted-foreground" />
|
| 82 |
+
</CardHeader>
|
| 83 |
+
<CardContent>
|
| 84 |
+
<div className="text-2xl font-bold">{stats.activeJobs}</div>
|
| 85 |
+
<p className="text-xs text-muted-foreground">Currently running tasks</p>
|
| 86 |
+
</CardContent>
|
| 87 |
+
</Card>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
{/* Activity Graph Placeholder - Could be implemented with Recharts if needed */}
|
| 91 |
+
<Card className="col-span-4">
|
| 92 |
+
<CardHeader>
|
| 93 |
+
<CardTitle>Recent Activity</CardTitle>
|
| 94 |
+
</CardHeader>
|
| 95 |
+
<CardContent className="pl-2">
|
| 96 |
+
<div className="h-[200px] flex items-center justify-center text-muted-foreground border-2 border-dashed rounded-md">
|
| 97 |
+
Activity Graph Component to be added with Recharts
|
| 98 |
+
</div>
|
| 99 |
+
</CardContent>
|
| 100 |
+
</Card>
|
| 101 |
+
</div>
|
| 102 |
+
);
|
| 103 |
+
}
|
app/admin/users/page.tsx
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { db } from "@/db";
|
| 2 |
+
import { users } from "@/db/schema";
|
| 3 |
+
import { desc } from "drizzle-orm";
|
| 4 |
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
| 5 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 6 |
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
| 7 |
+
|
| 8 |
+
export default async function AdminUsersPage() {
|
| 9 |
+
const items = await db.select().from(users).orderBy(desc(users.createdAt));
|
| 10 |
+
|
| 11 |
+
return (
|
| 12 |
+
<div className="space-y-6">
|
| 13 |
+
<div>
|
| 14 |
+
<h2 className="text-3xl font-bold tracking-tight">Users</h2>
|
| 15 |
+
<p className="text-muted-foreground">Manage platform users</p>
|
| 16 |
+
</div>
|
| 17 |
+
|
| 18 |
+
<Card>
|
| 19 |
+
<CardHeader>
|
| 20 |
+
<CardTitle>All Users ({items.length})</CardTitle>
|
| 21 |
+
</CardHeader>
|
| 22 |
+
<CardContent>
|
| 23 |
+
<Table>
|
| 24 |
+
<TableHeader>
|
| 25 |
+
<TableRow>
|
| 26 |
+
<TableHead>User</TableHead>
|
| 27 |
+
<TableHead>Email</TableHead>
|
| 28 |
+
<TableHead>Joined</TableHead>
|
| 29 |
+
</TableRow>
|
| 30 |
+
</TableHeader>
|
| 31 |
+
<TableBody>
|
| 32 |
+
{items.map((item) => (
|
| 33 |
+
<TableRow key={item.id}>
|
| 34 |
+
<TableCell className="flex items-center gap-3">
|
| 35 |
+
<Avatar className="h-8 w-8">
|
| 36 |
+
<AvatarImage src={item.image || ""} />
|
| 37 |
+
<AvatarFallback>{item.name?.charAt(0) || "U"}</AvatarFallback>
|
| 38 |
+
</Avatar>
|
| 39 |
+
<span className="font-medium">{item.name || "Unknown"}</span>
|
| 40 |
+
</TableCell>
|
| 41 |
+
<TableCell>{item.email}</TableCell>
|
| 42 |
+
<TableCell>
|
| 43 |
+
{new Date(item.createdAt).toLocaleDateString()}
|
| 44 |
+
</TableCell>
|
| 45 |
+
</TableRow>
|
| 46 |
+
))}
|
| 47 |
+
</TableBody>
|
| 48 |
+
</Table>
|
| 49 |
+
</CardContent>
|
| 50 |
+
</Card>
|
| 51 |
+
</div>
|
| 52 |
+
);
|
| 53 |
+
}
|
app/animations.css
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Add to globals.css */
|
| 2 |
+
|
| 3 |
+
/* Animations */
|
| 4 |
+
@keyframes float {
|
| 5 |
+
0%, 100% {
|
| 6 |
+
transform: translateY(0px);
|
| 7 |
+
}
|
| 8 |
+
50% {
|
| 9 |
+
transform: translateY(-20px);
|
| 10 |
+
}
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
@keyframes pulse-glow {
|
| 14 |
+
0%, 100% {
|
| 15 |
+
opacity: 1;
|
| 16 |
+
transform: scale(1);
|
| 17 |
+
}
|
| 18 |
+
50% {
|
| 19 |
+
opacity: 0.8;
|
| 20 |
+
transform: scale(1.05);
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
@keyframes slide-in-up {
|
| 25 |
+
from {
|
| 26 |
+
opacity: 0;
|
| 27 |
+
transform: translateY(30px);
|
| 28 |
+
}
|
| 29 |
+
to {
|
| 30 |
+
opacity: 1;
|
| 31 |
+
transform: translateY(0);
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
@keyframes fade-in {
|
| 36 |
+
from {
|
| 37 |
+
opacity: 0;
|
| 38 |
+
}
|
| 39 |
+
to {
|
| 40 |
+
opacity: 1;
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.animate-float {
|
| 45 |
+
animation: float 3s ease-in-out infinite;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
.animate-pulse-glow {
|
| 49 |
+
animation: pulse-glow 2s ease-in-out infinite;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.animate-slide-in-up {
|
| 53 |
+
animation: slide-in-up 0.6s ease-out forwards;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.animate-fade-in {
|
| 57 |
+
animation: fade-in 1s ease-out forwards;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/* Stagger animations */
|
| 61 |
+
.stagger-1 {
|
| 62 |
+
animation-delay: 0.1s;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.stagger-2 {
|
| 66 |
+
animation-delay: 0.2s;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.stagger-3 {
|
| 70 |
+
animation-delay: 0.3s;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
.stagger-4 {
|
| 74 |
+
animation-delay: 0.4s;
|
| 75 |
+
}
|
app/api/ab-test/route.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import { auth } from "@/lib/auth";
|
| 3 |
+
import { createABTest, determineABTestWinner } from "@/lib/ab-testing";
|
| 4 |
+
import { SessionUser } from "@/types";
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Create a new A/B test
|
| 8 |
+
*/
|
| 9 |
+
export async function POST(request: Request) {
|
| 10 |
+
try {
|
| 11 |
+
const session = await auth();
|
| 12 |
+
if (!session?.user) {
|
| 13 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const userId = (session.user as SessionUser).id;
|
| 17 |
+
const body = await request.json();
|
| 18 |
+
const { name, templateA, templateB, splitPercentage } = body;
|
| 19 |
+
|
| 20 |
+
const test = await createABTest({
|
| 21 |
+
userId,
|
| 22 |
+
name,
|
| 23 |
+
templateA,
|
| 24 |
+
templateB,
|
| 25 |
+
splitPercentage,
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
return NextResponse.json({ test });
|
| 29 |
+
} catch (error: unknown) {
|
| 30 |
+
console.error("Error creating A/B test:", error);
|
| 31 |
+
return NextResponse.json(
|
| 32 |
+
{ error: error instanceof Error ? error.message : "Failed to create A/B test" },
|
| 33 |
+
{ status: 500 }
|
| 34 |
+
);
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/**
|
| 39 |
+
* Get A/B test winner
|
| 40 |
+
*/
|
| 41 |
+
export async function GET(request: Request) {
|
| 42 |
+
try {
|
| 43 |
+
const session = await auth();
|
| 44 |
+
if (!session?.user) {
|
| 45 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
const { searchParams } = new URL(request.url);
|
| 49 |
+
const testId = searchParams.get("testId");
|
| 50 |
+
const templateA = searchParams.get("templateA");
|
| 51 |
+
const templateB = searchParams.get("templateB");
|
| 52 |
+
|
| 53 |
+
if (!templateA || !templateB) {
|
| 54 |
+
return NextResponse.json(
|
| 55 |
+
{ error: "Template IDs required" },
|
| 56 |
+
{ status: 400 }
|
| 57 |
+
);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
// Mock test object for winner determination
|
| 61 |
+
const test = {
|
| 62 |
+
id: testId || "test",
|
| 63 |
+
name: "A/B Test",
|
| 64 |
+
templateA,
|
| 65 |
+
templateB,
|
| 66 |
+
splitPercentage: 50,
|
| 67 |
+
status: "active" as const,
|
| 68 |
+
startedAt: new Date(),
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
const result = await determineABTestWinner(test);
|
| 72 |
+
|
| 73 |
+
return NextResponse.json({ result });
|
| 74 |
+
} catch (error) {
|
| 75 |
+
console.error("Error determining winner:", error);
|
| 76 |
+
return NextResponse.json(
|
| 77 |
+
{ error: "Failed to determine winner" },
|
| 78 |
+
{ status: 500 }
|
| 79 |
+
);
|
| 80 |
+
}
|
| 81 |
+
}
|
app/api/analytics/route.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import { auth } from "@/auth";
|
| 3 |
+
import { db } from "@/db";
|
| 4 |
+
import { emailLogs, businesses } from "@/db/schema";
|
| 5 |
+
import { eq, sql, and, inArray } from "drizzle-orm";
|
| 6 |
+
|
| 7 |
+
export async function GET() {
|
| 8 |
+
try {
|
| 9 |
+
const session = await auth();
|
| 10 |
+
if (!session?.user) {
|
| 11 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const userId = (session.user as { id: string }).id;
|
| 15 |
+
|
| 16 |
+
// Get all businesses for this user
|
| 17 |
+
const userBusinesses = await db
|
| 18 |
+
.select({ id: businesses.id })
|
| 19 |
+
.from(businesses)
|
| 20 |
+
.where(eq(businesses.userId, userId));
|
| 21 |
+
|
| 22 |
+
const businessIds = userBusinesses.map(b => b.id);
|
| 23 |
+
|
| 24 |
+
if (businessIds.length === 0) {
|
| 25 |
+
return NextResponse.json({
|
| 26 |
+
totalSent: 0,
|
| 27 |
+
delivered: 0,
|
| 28 |
+
opened: 0,
|
| 29 |
+
clicked: 0,
|
| 30 |
+
replied: 0,
|
| 31 |
+
bounced: 0,
|
| 32 |
+
deliveryRate: 0,
|
| 33 |
+
openRate: 0,
|
| 34 |
+
clickRate: 0,
|
| 35 |
+
replyRate: 0,
|
| 36 |
+
});
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// Aggregate email statistics
|
| 40 |
+
const stats = await db
|
| 41 |
+
.select({
|
| 42 |
+
total: sql<number>`cast(count(*) as int)`,
|
| 43 |
+
delivered: sql<number>`cast(sum(case when ${emailLogs.status} in ('sent', 'delivered', 'opened', 'clicked') then 1 else 0 end) as int)`,
|
| 44 |
+
opened: sql<number>`cast(sum(case when ${emailLogs.status} in ('opened', 'clicked') then 1 else 0 end) as int)`,
|
| 45 |
+
clicked: sql<number>`cast(sum(case when ${emailLogs.status} = 'clicked' then 1 else 0 end) as int)`,
|
| 46 |
+
replied: sql<number>`cast(sum(case when ${emailLogs.status} = 'replied' then 1 else 0 end) as int)`,
|
| 47 |
+
bounced: sql<number>`cast(sum(case when ${emailLogs.status} = 'bounced' then 1 else 0 end) as int)`,
|
| 48 |
+
})
|
| 49 |
+
.from(emailLogs)
|
| 50 |
+
.where(inArray(emailLogs.businessId, businessIds));
|
| 51 |
+
|
| 52 |
+
const totalSent = Number(stats[0]?.total || 0);
|
| 53 |
+
const delivered = Number(stats[0]?.delivered || 0);
|
| 54 |
+
const opened = Number(stats[0]?.opened || 0);
|
| 55 |
+
const clicked = Number(stats[0]?.clicked || 0);
|
| 56 |
+
const replied = Number(stats[0]?.replied || 0);
|
| 57 |
+
const bounced = Number(stats[0]?.bounced || 0);
|
| 58 |
+
|
| 59 |
+
// Calculate rates
|
| 60 |
+
const deliveryRate = totalSent > 0 ? (delivered / totalSent) * 100 : 0;
|
| 61 |
+
const openRate = delivered > 0 ? (opened / delivered) * 100 : 0;
|
| 62 |
+
const clickRate = delivered > 0 ? (clicked / delivered) * 100 : 0;
|
| 63 |
+
const replyRate = delivered > 0 ? (replied / delivered) * 100 : 0;
|
| 64 |
+
|
| 65 |
+
return NextResponse.json({
|
| 66 |
+
totalSent,
|
| 67 |
+
delivered,
|
| 68 |
+
opened,
|
| 69 |
+
clicked,
|
| 70 |
+
replied,
|
| 71 |
+
bounced,
|
| 72 |
+
deliveryRate: Math.round(deliveryRate * 10) / 10,
|
| 73 |
+
openRate: Math.round(openRate * 10) / 10,
|
| 74 |
+
clickRate: Math.round(clickRate * 10) / 10,
|
| 75 |
+
replyRate: Math.round(replyRate * 10) / 10,
|
| 76 |
+
});
|
| 77 |
+
} catch (error) {
|
| 78 |
+
console.error("Error fetching analytics:", error);
|
| 79 |
+
return NextResponse.json(
|
| 80 |
+
{ error: "Failed to fetch analytics" },
|
| 81 |
+
{ status: 500 }
|
| 82 |
+
);
|
| 83 |
+
}
|
| 84 |
+
}
|
app/api/auth/[...nextauth]/route.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { handlers } from "@/lib/auth";
|
| 2 |
+
|
| 3 |
+
export const { GET, POST } = handlers;
|
app/api/businesses/route.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import { auth } from "@/lib/auth";
|
| 3 |
+
import { db } from "@/db";
|
| 4 |
+
import { businesses } from "@/db/schema";
|
| 5 |
+
import { eq, and } from "drizzle-orm";
|
| 6 |
+
import { rateLimit } from "@/lib/rate-limit";
|
| 7 |
+
|
| 8 |
+
interface SessionUser {
|
| 9 |
+
id: string;
|
| 10 |
+
email: string;
|
| 11 |
+
name?: string;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export async function GET(request: Request) {
|
| 15 |
+
try {
|
| 16 |
+
const session = await auth();
|
| 17 |
+
if (!session?.user) {
|
| 18 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const userId = (session.user as SessionUser).id;
|
| 22 |
+
const { searchParams } = new URL(request.url);
|
| 23 |
+
const category = searchParams.get("category");
|
| 24 |
+
const status = searchParams.get("status");
|
| 25 |
+
|
| 26 |
+
// Build where conditions
|
| 27 |
+
const conditions = [eq(businesses.userId, userId)];
|
| 28 |
+
|
| 29 |
+
if (category) {
|
| 30 |
+
conditions.push(eq(businesses.category, category));
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
if (status) {
|
| 34 |
+
conditions.push(eq(businesses.emailStatus, status));
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
const results = await db
|
| 38 |
+
.select()
|
| 39 |
+
.from(businesses)
|
| 40 |
+
.where(and(...conditions))
|
| 41 |
+
.orderBy(businesses.createdAt)
|
| 42 |
+
.limit(100);
|
| 43 |
+
|
| 44 |
+
return NextResponse.json({ businesses: results });
|
| 45 |
+
} catch (error) {
|
| 46 |
+
console.error("Error fetching businesses:", error);
|
| 47 |
+
return NextResponse.json(
|
| 48 |
+
{ error: "Failed to fetch businesses" },
|
| 49 |
+
{ status: 500 }
|
| 50 |
+
);
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
export async function PATCH(request: Request) {
|
| 55 |
+
try {
|
| 56 |
+
const rateLimitResponse = await rateLimit(request);
|
| 57 |
+
if (rateLimitResponse) {
|
| 58 |
+
return rateLimitResponse;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
const session = await auth();
|
| 62 |
+
if (!session?.user) {
|
| 63 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
const body = await request.json();
|
| 67 |
+
const { id, ...updates } = body;
|
| 68 |
+
|
| 69 |
+
const [business] = await db
|
| 70 |
+
.update(businesses)
|
| 71 |
+
.set({ ...updates, updatedAt: new Date() })
|
| 72 |
+
.where(eq(businesses.id, id))
|
| 73 |
+
.returning();
|
| 74 |
+
|
| 75 |
+
return NextResponse.json({ business });
|
| 76 |
+
} catch (error) {
|
| 77 |
+
console.error("Error updating business:", error);
|
| 78 |
+
return NextResponse.json(
|
| 79 |
+
{ error: "Failed to update business" },
|
| 80 |
+
{ status: 500 }
|
| 81 |
+
);
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
export async function DELETE(request: Request) {
|
| 86 |
+
try {
|
| 87 |
+
const session = await auth();
|
| 88 |
+
if (!session?.user) {
|
| 89 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
const { searchParams } = new URL(request.url);
|
| 93 |
+
const id = searchParams.get("id");
|
| 94 |
+
|
| 95 |
+
if (!id) {
|
| 96 |
+
return NextResponse.json(
|
| 97 |
+
{ error: "Business ID required" },
|
| 98 |
+
{ status: 400 }
|
| 99 |
+
);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
await db.delete(businesses).where(eq(businesses.id, id));
|
| 103 |
+
|
| 104 |
+
return NextResponse.json({ success: true });
|
| 105 |
+
} catch (error) {
|
| 106 |
+
console.error("Error deleting business:", error);
|
| 107 |
+
return NextResponse.json(
|
| 108 |
+
{ error: "Failed to delete business" },
|
| 109 |
+
{ status: 500 }
|
| 110 |
+
);
|
| 111 |
+
}
|
| 112 |
+
}
|
app/api/dashboard/stats/route.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import { auth } from "@/lib/auth";
|
| 3 |
+
import { db } from "@/db";
|
| 4 |
+
import { businesses, emailTemplates, automationWorkflows, emailLogs } from "@/db/schema";
|
| 5 |
+
import { SessionUser } from "@/types";
|
| 6 |
+
import { eq, and, gte, sql, count } from "drizzle-orm";
|
| 7 |
+
|
| 8 |
+
export async function GET(request: Request) {
|
| 9 |
+
try {
|
| 10 |
+
const session = await auth();
|
| 11 |
+
if (!session?.user) {
|
| 12 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const userId = (session.user as SessionUser).id;
|
| 16 |
+
|
| 17 |
+
// Get total counts
|
| 18 |
+
const [businessCount, templateCount, workflowCount] = await Promise.all([
|
| 19 |
+
db.select({ count: count() }).from(businesses).where(eq(businesses.userId, userId)),
|
| 20 |
+
db.select({ count: count() }).from(emailTemplates).where(eq(emailTemplates.userId, userId)),
|
| 21 |
+
db.select({ count: count() }).from(automationWorkflows).where(eq(automationWorkflows.userId, userId)),
|
| 22 |
+
]);
|
| 23 |
+
|
| 24 |
+
// Get email metrics from last 30 days
|
| 25 |
+
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
| 26 |
+
|
| 27 |
+
const emailStats = await db
|
| 28 |
+
.select({
|
| 29 |
+
total: count(),
|
| 30 |
+
sent: sql<number>`count(case when status = 'sent' OR status = 'opened' OR status = 'clicked' then 1 end)`,
|
| 31 |
+
opened: sql<number>`count(case when status = 'opened' OR status = 'clicked' then 1 end)`,
|
| 32 |
+
clicked: sql<number>`count(case when status = 'clicked' then 1 end)`,
|
| 33 |
+
})
|
| 34 |
+
.from(emailLogs)
|
| 35 |
+
.where(
|
| 36 |
+
and(
|
| 37 |
+
eq(emailLogs.userId, userId),
|
| 38 |
+
gte(emailLogs.createdAt, thirtyDaysAgo)
|
| 39 |
+
)
|
| 40 |
+
);
|
| 41 |
+
|
| 42 |
+
const stats = emailStats[0];
|
| 43 |
+
const totalSent = Number(stats.sent) || 0;
|
| 44 |
+
const opened = Number(stats.opened) || 0;
|
| 45 |
+
const clicked = Number(stats.clicked) || 0;
|
| 46 |
+
|
| 47 |
+
// Get time-series data for last 7 days
|
| 48 |
+
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
| 49 |
+
|
| 50 |
+
const timeSeriesData = await db
|
| 51 |
+
.select({
|
| 52 |
+
date: sql<string>`TO_CHAR(created_at, 'Dy')`,
|
| 53 |
+
sent: count(),
|
| 54 |
+
opened: sql<number>`count(case when status = 'opened' OR status = 'clicked' then 1 end)`,
|
| 55 |
+
})
|
| 56 |
+
.from(emailLogs)
|
| 57 |
+
.where(
|
| 58 |
+
and(
|
| 59 |
+
eq(emailLogs.userId, userId),
|
| 60 |
+
gte(emailLogs.createdAt, sevenDaysAgo)
|
| 61 |
+
)
|
| 62 |
+
)
|
| 63 |
+
.groupBy(sql`DATE(created_at), TO_CHAR(created_at, 'Dy')`)
|
| 64 |
+
.orderBy(sql`DATE(created_at)`);
|
| 65 |
+
|
| 66 |
+
// Get businesses with email status
|
| 67 |
+
const businessesWithEmail = await db
|
| 68 |
+
.select({ count: count() })
|
| 69 |
+
.from(businesses)
|
| 70 |
+
.where(and(eq(businesses.userId, userId), eq(businesses.emailSent, true)));
|
| 71 |
+
|
| 72 |
+
return NextResponse.json({
|
| 73 |
+
stats: {
|
| 74 |
+
totalBusinesses: businessCount[0]?.count || 0,
|
| 75 |
+
totalTemplates: templateCount[0]?.count || 0,
|
| 76 |
+
totalWorkflows: workflowCount[0]?.count || 0,
|
| 77 |
+
emailsSent: totalSent,
|
| 78 |
+
emailsOpened: opened,
|
| 79 |
+
emailsClicked: clicked,
|
| 80 |
+
openRate: totalSent > 0 ? Math.round((opened / totalSent) * 100) : 0,
|
| 81 |
+
clickRate: totalSent > 0 ? Math.round((clicked / totalSent) * 100) : 0,
|
| 82 |
+
},
|
| 83 |
+
chartData: timeSeriesData.map((row) => ({
|
| 84 |
+
name: row.date,
|
| 85 |
+
sent: Number(row.sent),
|
| 86 |
+
opened: Number(row.opened),
|
| 87 |
+
})),
|
| 88 |
+
});
|
| 89 |
+
} catch (error) {
|
| 90 |
+
console.error("Error fetching dashboard stats:", error);
|
| 91 |
+
return NextResponse.json(
|
| 92 |
+
{ error: "Failed to fetch dashboard statistics" },
|
| 93 |
+
{ status: 500 }
|
| 94 |
+
);
|
| 95 |
+
}
|
| 96 |
+
}
|
app/api/feedback/route.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import { auth } from "@/auth";
|
| 3 |
+
import { db } from "@/db";
|
| 4 |
+
import { feedback } from "@/db/schema";
|
| 5 |
+
import { desc } from "drizzle-orm";
|
| 6 |
+
import { nanoid } from "nanoid";
|
| 7 |
+
|
| 8 |
+
export async function POST(req: Request) {
|
| 9 |
+
try {
|
| 10 |
+
const session = await auth();
|
| 11 |
+
const body = await req.json().catch(() => ({}));
|
| 12 |
+
const { message, type = "general", name, email, subject } = body;
|
| 13 |
+
|
| 14 |
+
if (!message) {
|
| 15 |
+
return new NextResponse("Message is required", { status: 400 });
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
await db.insert(feedback).values({
|
| 19 |
+
id: nanoid(),
|
| 20 |
+
userId: session?.user?.id || null,
|
| 21 |
+
visitorName: name || null,
|
| 22 |
+
visitorEmail: email || null,
|
| 23 |
+
subject: subject || null,
|
| 24 |
+
message,
|
| 25 |
+
type,
|
| 26 |
+
status: "new",
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
return NextResponse.json({ success: true });
|
| 30 |
+
} catch (error) {
|
| 31 |
+
console.error("[FEEDBACK_POST]", error);
|
| 32 |
+
return new NextResponse("Internal Error", { status: 500 });
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
| 37 |
+
export async function GET(_req: Request) {
|
| 38 |
+
try {
|
| 39 |
+
const session = await auth();
|
| 40 |
+
// Strict Admin Check
|
| 41 |
+
if (session?.user?.role !== "admin") {
|
| 42 |
+
return new NextResponse("Unauthorized", { status: 401 });
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// Use standard select query for reliability
|
| 46 |
+
const items = await db
|
| 47 |
+
.select()
|
| 48 |
+
.from(feedback)
|
| 49 |
+
.orderBy(desc(feedback.createdAt));
|
| 50 |
+
|
| 51 |
+
return NextResponse.json(items);
|
| 52 |
+
|
| 53 |
+
} catch (error) {
|
| 54 |
+
console.error("[FEEDBACK_GET]", error);
|
| 55 |
+
return new NextResponse("Internal Error", { status: 500 });
|
| 56 |
+
}
|
| 57 |
+
}
|
app/api/health/route.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import Redis from "ioredis";
|
| 3 |
+
import { db } from "@/db";
|
| 4 |
+
import { sql } from "drizzle-orm";
|
| 5 |
+
import { getQueueStats } from "@/lib/queue";
|
| 6 |
+
|
| 7 |
+
interface HealthCheck {
|
| 8 |
+
status: "healthy" | "degraded" | "unhealthy";
|
| 9 |
+
timestamp: string;
|
| 10 |
+
uptime: number;
|
| 11 |
+
services: {
|
| 12 |
+
redis: { status: string; latency: number };
|
| 13 |
+
database: { status: string; latency: number };
|
| 14 |
+
queue: { status: string; stats: unknown };
|
| 15 |
+
};
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export async function GET() {
|
| 19 |
+
const healthChecks: HealthCheck = {
|
| 20 |
+
status: "healthy",
|
| 21 |
+
timestamp: new Date().toISOString(),
|
| 22 |
+
uptime: process.uptime(),
|
| 23 |
+
services: {
|
| 24 |
+
redis: { status: "unknown", latency: 0 },
|
| 25 |
+
database: { status: "unknown", latency: 0 },
|
| 26 |
+
queue: { status: "unknown", stats: null },
|
| 27 |
+
},
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
// Check Redis connection
|
| 31 |
+
try {
|
| 32 |
+
const redis = new Redis(process.env.REDIS_URL || "redis://localhost:6379", {
|
| 33 |
+
connectTimeout: 5000,
|
| 34 |
+
maxRetriesPerRequest: 1,
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
const start = Date.now();
|
| 38 |
+
await redis.ping();
|
| 39 |
+
const latency = Date.now() - start;
|
| 40 |
+
|
| 41 |
+
healthChecks.services.redis = { status: "healthy", latency };
|
| 42 |
+
await redis.disconnect();
|
| 43 |
+
} catch {
|
| 44 |
+
healthChecks.services.redis = { status: "unhealthy", latency: -1 };
|
| 45 |
+
healthChecks.status = "degraded";
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// Check Database connection
|
| 49 |
+
try {
|
| 50 |
+
const start = Date.now();
|
| 51 |
+
await db.execute(sql`SELECT 1`);
|
| 52 |
+
const latency = Date.now() - start;
|
| 53 |
+
|
| 54 |
+
healthChecks.services.database = { status: "healthy", latency };
|
| 55 |
+
} catch {
|
| 56 |
+
healthChecks.services.database = { status: "unhealthy", latency: -1 };
|
| 57 |
+
healthChecks.status = "unhealthy";
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
// Check Queue stats
|
| 61 |
+
try {
|
| 62 |
+
const stats = await getQueueStats();
|
| 63 |
+
healthChecks.services.queue = {
|
| 64 |
+
status: "healthy",
|
| 65 |
+
stats,
|
| 66 |
+
};
|
| 67 |
+
} catch {
|
| 68 |
+
healthChecks.services.queue = {
|
| 69 |
+
status: "unhealthy",
|
| 70 |
+
stats: null,
|
| 71 |
+
};
|
| 72 |
+
healthChecks.status = "degraded";
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
const statusCode = healthChecks.status === "healthy" ? 200 : 503;
|
| 76 |
+
return NextResponse.json(healthChecks, { status: statusCode });
|
| 77 |
+
}
|
app/api/keywords/generate/route.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import { auth } from "@/lib/auth";
|
| 3 |
+
import { generateAIContent } from "@/lib/gemini";
|
| 4 |
+
|
| 5 |
+
export async function POST(request: Request) {
|
| 6 |
+
try {
|
| 7 |
+
const session = await auth();
|
| 8 |
+
if (!session?.user) {
|
| 9 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
const { businessType } = await request.json();
|
| 13 |
+
|
| 14 |
+
if (!businessType) {
|
| 15 |
+
return NextResponse.json(
|
| 16 |
+
{ error: "Business type is required" },
|
| 17 |
+
{ status: 400 }
|
| 18 |
+
);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
// Generate keywords using Gemini
|
| 22 |
+
const prompt = `Generate 8-10 relevant keyword phrases for targeting "${businessType}" businesses in a cold email outreach campaign. Return ONLY a JSON array of strings, no explanation. Example format: ["keyword 1", "keyword 2", "keyword 3"]`;
|
| 23 |
+
|
| 24 |
+
const response = await generateAIContent(prompt);
|
| 25 |
+
|
| 26 |
+
// Parse the JSON response
|
| 27 |
+
let keywords: string[];
|
| 28 |
+
try {
|
| 29 |
+
keywords = JSON.parse(response);
|
| 30 |
+
} catch {
|
| 31 |
+
// Fallback: extract keywords from text
|
| 32 |
+
const matches = response.match(/"([^"]+)"/g);
|
| 33 |
+
keywords = matches ? matches.map((m: string) => m.replace(/"/g, "")) : [];
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
return NextResponse.json({ keywords });
|
| 37 |
+
} catch (error: unknown) {
|
| 38 |
+
console.error("Error generating keywords:", error);
|
| 39 |
+
return NextResponse.json(
|
| 40 |
+
{ error: error instanceof Error ? error.message : "Failed to generate keywords" },
|
| 41 |
+
{ status: 500 }
|
| 42 |
+
);
|
| 43 |
+
}
|
| 44 |
+
}
|
app/api/queue/stats/route.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import { auth } from "@/lib/auth";
|
| 3 |
+
import { getQueueStats } from "@/lib/queue";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Get queue statistics for monitoring
|
| 7 |
+
*/
|
| 8 |
+
export async function GET(request: Request) {
|
| 9 |
+
try {
|
| 10 |
+
const session = await auth();
|
| 11 |
+
if (!session?.user) {
|
| 12 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const stats = await getQueueStats();
|
| 16 |
+
|
| 17 |
+
return NextResponse.json({ stats });
|
| 18 |
+
} catch (error) {
|
| 19 |
+
console.error("Error fetching queue stats:", error);
|
| 20 |
+
return NextResponse.json(
|
| 21 |
+
{ error: "Failed to fetch queue stats" },
|
| 22 |
+
{ status: 500 }
|
| 23 |
+
);
|
| 24 |
+
}
|
| 25 |
+
}
|
app/api/scraping/control/route.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import { auth } from "@/lib/auth";
|
| 3 |
+
import { db } from "@/db";
|
| 4 |
+
import { scrapingJobs } from "@/db/schema";
|
| 5 |
+
import { eq, and } from "drizzle-orm";
|
| 6 |
+
import { SessionUser } from "@/types";
|
| 7 |
+
|
| 8 |
+
export async function POST(request: Request) {
|
| 9 |
+
try {
|
| 10 |
+
const session = await auth();
|
| 11 |
+
if (!session?.user) {
|
| 12 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const userId = (session.user as SessionUser).id;
|
| 16 |
+
const { jobId, action } = await request.json();
|
| 17 |
+
|
| 18 |
+
if (!jobId || !action) {
|
| 19 |
+
return NextResponse.json(
|
| 20 |
+
{ error: "Job ID and action are required" },
|
| 21 |
+
{ status: 400 }
|
| 22 |
+
);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
if (!["pause", "resume", "stop"].includes(action)) {
|
| 26 |
+
return NextResponse.json(
|
| 27 |
+
{ error: "Invalid action. Must be 'pause', 'resume', or 'stop'" },
|
| 28 |
+
{ status: 400 }
|
| 29 |
+
);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
// Find the job and verify ownership
|
| 33 |
+
const [job] = await db
|
| 34 |
+
.select()
|
| 35 |
+
.from(scrapingJobs)
|
| 36 |
+
.where(and(eq(scrapingJobs.id, jobId), eq(scrapingJobs.userId, userId)))
|
| 37 |
+
.limit(1);
|
| 38 |
+
|
| 39 |
+
if (!job) {
|
| 40 |
+
return NextResponse.json(
|
| 41 |
+
{ error: "Job not found or access denied" },
|
| 42 |
+
{ status: 404 }
|
| 43 |
+
);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
// Update job status based on action
|
| 47 |
+
let newStatus = job.status;
|
| 48 |
+
let newPriority = job.priority;
|
| 49 |
+
|
| 50 |
+
if (["pause", "resume", "stop"].includes(action)) {
|
| 51 |
+
switch (action) {
|
| 52 |
+
case "pause":
|
| 53 |
+
newStatus = "paused";
|
| 54 |
+
break;
|
| 55 |
+
case "resume":
|
| 56 |
+
newStatus = "running";
|
| 57 |
+
break;
|
| 58 |
+
case "stop":
|
| 59 |
+
newStatus = "failed";
|
| 60 |
+
break;
|
| 61 |
+
}
|
| 62 |
+
} else if (action === "set-priority") {
|
| 63 |
+
// Handle priority update
|
| 64 |
+
const { priority } = await request.json();
|
| 65 |
+
if (!priority || !["low", "medium", "high"].includes(priority)) {
|
| 66 |
+
return NextResponse.json({ error: "Invalid priority" }, { status: 400 });
|
| 67 |
+
}
|
| 68 |
+
newPriority = priority;
|
| 69 |
+
} else {
|
| 70 |
+
newStatus = job.status;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// Update the job
|
| 74 |
+
const [updatedJob] = await db
|
| 75 |
+
.update(scrapingJobs)
|
| 76 |
+
.set({
|
| 77 |
+
status: newStatus,
|
| 78 |
+
priority: newPriority,
|
| 79 |
+
...(action === "stop" && { completedAt: new Date() }),
|
| 80 |
+
})
|
| 81 |
+
.where(eq(scrapingJobs.id, jobId))
|
| 82 |
+
.returning();
|
| 83 |
+
|
| 84 |
+
return NextResponse.json({
|
| 85 |
+
success: true,
|
| 86 |
+
job: updatedJob,
|
| 87 |
+
});
|
| 88 |
+
} catch (error: unknown) {
|
| 89 |
+
console.error("Error controlling scraping job:", error);
|
| 90 |
+
return NextResponse.json(
|
| 91 |
+
{ error: error instanceof Error ? error.message : "Failed to control job" },
|
| 92 |
+
{ status: 500 }
|
| 93 |
+
);
|
| 94 |
+
}
|
| 95 |
+
}
|
app/api/scraping/start/route.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import { auth } from "@/lib/auth";
|
| 3 |
+
import { db } from "@/db";
|
| 4 |
+
import { scrapingJobs } from "@/db/schema";
|
| 5 |
+
import { queueScraping } from "@/lib/queue";
|
| 6 |
+
import { rateLimit } from "@/lib/rate-limit";
|
| 7 |
+
import { SessionUser } from "@/types";
|
| 8 |
+
import { eq, and } from "drizzle-orm";
|
| 9 |
+
|
| 10 |
+
export async function POST(request: Request) {
|
| 11 |
+
try {
|
| 12 |
+
// Apply rate limiting
|
| 13 |
+
const rateLimitResponse = await rateLimit(request, "scraping");
|
| 14 |
+
if (rateLimitResponse) {
|
| 15 |
+
return rateLimitResponse;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const session = await auth();
|
| 19 |
+
if (!session?.user) {
|
| 20 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
const userId = (session.user as SessionUser).id;
|
| 24 |
+
const body = await request.json();
|
| 25 |
+
const { targetBusinessType, keywords, location, sources = ["google-maps"] } = body;
|
| 26 |
+
|
| 27 |
+
if (!targetBusinessType || !location) {
|
| 28 |
+
return NextResponse.json(
|
| 29 |
+
{ error: "Invalid input" },
|
| 30 |
+
{ status: 400 }
|
| 31 |
+
);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// Check for existing active job with same parameters
|
| 35 |
+
const [existingJob] = await db
|
| 36 |
+
.select()
|
| 37 |
+
.from(scrapingJobs)
|
| 38 |
+
.where(
|
| 39 |
+
and(
|
| 40 |
+
eq(scrapingJobs.userId, userId),
|
| 41 |
+
eq(scrapingJobs.status, "pending"), // Check pending
|
| 42 |
+
// We could also check 'processing' or 'running' if we want to strict prevent parallel same-jobs
|
| 43 |
+
// But user might want to restart? Let's strictly prevent Pending duplicates to avoid queue spam
|
| 44 |
+
// For now, let's just check pending to verify queue behavior.
|
| 45 |
+
// Better: Check active status to prevent double-running the exact same search
|
| 46 |
+
)
|
| 47 |
+
);
|
| 48 |
+
|
| 49 |
+
// More robust duplicate check properly using jsonb keywords comparison is complex in SQL
|
| 50 |
+
// Simplified: Check if there's a job created recently (last 1 min) with same target/location to prevent double-click
|
| 51 |
+
// Or better: Trust the user but just return existing if it's EXACT same request and still pending
|
| 52 |
+
|
| 53 |
+
if (existingJob) {
|
| 54 |
+
console.log(`⚠️ Prevented duplicate scraping job: ${existingJob.id} is pending`);
|
| 55 |
+
return NextResponse.json({
|
| 56 |
+
message: "Existing pending job found",
|
| 57 |
+
jobId: existingJob.id,
|
| 58 |
+
});
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// Create scraping job directly without workflow
|
| 62 |
+
const [job] = await db
|
| 63 |
+
.insert(scrapingJobs)
|
| 64 |
+
.values({
|
| 65 |
+
userId,
|
| 66 |
+
workflowId: null, // No workflow needed
|
| 67 |
+
keywords: keywords || [], // Allow empty keywords
|
| 68 |
+
status: "pending",
|
| 69 |
+
businessesFound: 0,
|
| 70 |
+
})
|
| 71 |
+
.returning();
|
| 72 |
+
|
| 73 |
+
// Queue the scraping job
|
| 74 |
+
await queueScraping({
|
| 75 |
+
userId,
|
| 76 |
+
jobId: job.id,
|
| 77 |
+
keywords: keywords || [],
|
| 78 |
+
location,
|
| 79 |
+
targetBusinessType,
|
| 80 |
+
sources, // Pass selected sources
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
console.log(`🚀 Scraping job ${job.id} queued for processing`);
|
| 84 |
+
|
| 85 |
+
return NextResponse.json({
|
| 86 |
+
message: "Scraping job started",
|
| 87 |
+
jobId: job.id,
|
| 88 |
+
});
|
| 89 |
+
} catch (error) {
|
| 90 |
+
console.error("Error starting scraping:", error);
|
| 91 |
+
return NextResponse.json(
|
| 92 |
+
{ error: "Failed to start scraping" },
|
| 93 |
+
{ status: 500 }
|
| 94 |
+
);
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/**
|
| 99 |
+
* Get scraping job status
|
| 100 |
+
*/
|
| 101 |
+
export async function GET(request: Request) {
|
| 102 |
+
try {
|
| 103 |
+
const session = await auth();
|
| 104 |
+
if (!session?.user) {
|
| 105 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
const userId = (session.user as SessionUser).id;
|
| 109 |
+
const { searchParams } = new URL(request.url);
|
| 110 |
+
const jobId = searchParams.get("jobId");
|
| 111 |
+
|
| 112 |
+
if (jobId) {
|
| 113 |
+
// Get specific job status
|
| 114 |
+
const job = await db.query.scrapingJobs.findFirst({
|
| 115 |
+
where: (jobs, { eq, and }) =>
|
| 116 |
+
and(eq(jobs.id, jobId), eq(jobs.userId, userId)),
|
| 117 |
+
});
|
| 118 |
+
|
| 119 |
+
return NextResponse.json({ job });
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
// Get all jobs
|
| 123 |
+
const jobs = await db.query.scrapingJobs.findMany({
|
| 124 |
+
where: (jobs, { eq }) => eq(jobs.userId, userId),
|
| 125 |
+
orderBy: (jobs, { desc }) => [desc(jobs.createdAt)],
|
| 126 |
+
limit: 10,
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
return NextResponse.json({ jobs });
|
| 130 |
+
} catch (error) {
|
| 131 |
+
console.error("Error fetching scraping jobs:", error);
|
| 132 |
+
return NextResponse.json(
|
| 133 |
+
{ error: "Failed to fetch scraping jobs" },
|
| 134 |
+
{ status: 500 }
|
| 135 |
+
);
|
| 136 |
+
}
|
| 137 |
+
}
|
app/api/search/route.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import { NextResponse } from "next/server";
|
| 3 |
+
import { auth } from "@/lib/auth";
|
| 4 |
+
import { db } from "@/db";
|
| 5 |
+
import { businesses, emailTemplates, automationWorkflows } from "@/db/schema";
|
| 6 |
+
import { ilike, or, eq, and } from "drizzle-orm";
|
| 7 |
+
|
| 8 |
+
export async function GET(req: Request) {
|
| 9 |
+
try {
|
| 10 |
+
const session = await auth();
|
| 11 |
+
if (!session || !session.user) {
|
| 12 |
+
return new NextResponse("Unauthorized", { status: 401 });
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const { searchParams } = new URL(req.url);
|
| 16 |
+
const query = searchParams.get("q");
|
| 17 |
+
|
| 18 |
+
if (!query || query.length < 2) {
|
| 19 |
+
return NextResponse.json({ results: [] });
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const userId = session.user.id;
|
| 23 |
+
const searchPattern = `%${query}%`;
|
| 24 |
+
|
| 25 |
+
// Parallelize queries for better performance
|
| 26 |
+
const [businessesResult, templatesResult, workflowsResult] = await Promise.all([
|
| 27 |
+
// Search Businesses
|
| 28 |
+
db
|
| 29 |
+
.select({
|
| 30 |
+
id: businesses.id,
|
| 31 |
+
name: businesses.name,
|
| 32 |
+
category: businesses.category
|
| 33 |
+
})
|
| 34 |
+
.from(businesses)
|
| 35 |
+
.where(
|
| 36 |
+
and(
|
| 37 |
+
eq(businesses.userId, userId),
|
| 38 |
+
or(
|
| 39 |
+
ilike(businesses.name, searchPattern),
|
| 40 |
+
ilike(businesses.email, searchPattern),
|
| 41 |
+
ilike(businesses.category, searchPattern)
|
| 42 |
+
)
|
| 43 |
+
)
|
| 44 |
+
)
|
| 45 |
+
.limit(5),
|
| 46 |
+
|
| 47 |
+
// Search Templates
|
| 48 |
+
db
|
| 49 |
+
.select({
|
| 50 |
+
id: emailTemplates.id,
|
| 51 |
+
name: emailTemplates.name,
|
| 52 |
+
subject: emailTemplates.subject
|
| 53 |
+
})
|
| 54 |
+
.from(emailTemplates)
|
| 55 |
+
.where(
|
| 56 |
+
and(
|
| 57 |
+
eq(emailTemplates.userId, userId),
|
| 58 |
+
or(
|
| 59 |
+
ilike(emailTemplates.name, searchPattern),
|
| 60 |
+
ilike(emailTemplates.subject, searchPattern)
|
| 61 |
+
)
|
| 62 |
+
)
|
| 63 |
+
)
|
| 64 |
+
.limit(5),
|
| 65 |
+
|
| 66 |
+
// Search Workflows
|
| 67 |
+
db
|
| 68 |
+
.select({
|
| 69 |
+
id: automationWorkflows.id,
|
| 70 |
+
name: automationWorkflows.name
|
| 71 |
+
})
|
| 72 |
+
.from(automationWorkflows)
|
| 73 |
+
.where(
|
| 74 |
+
and(
|
| 75 |
+
eq(automationWorkflows.userId, userId),
|
| 76 |
+
or(
|
| 77 |
+
ilike(automationWorkflows.name, searchPattern),
|
| 78 |
+
ilike(automationWorkflows.targetBusinessType, searchPattern)
|
| 79 |
+
)
|
| 80 |
+
)
|
| 81 |
+
)
|
| 82 |
+
.limit(5)
|
| 83 |
+
]);
|
| 84 |
+
|
| 85 |
+
const results = [
|
| 86 |
+
...businessesResult.map(b => ({ type: 'business', id: b.id, title: b.name, subtitle: b.category, url: `/dashboard/businesses?id=${b.id}` })),
|
| 87 |
+
...templatesResult.map(t => ({ type: 'template', id: t.id, title: t.name, subtitle: t.subject, url: `/dashboard/templates?id=${t.id}` })),
|
| 88 |
+
...workflowsResult.map(w => ({ type: 'workflow', id: w.id, title: w.name, subtitle: 'Workflow', url: `/dashboard/workflows?id=${w.id}` }))
|
| 89 |
+
];
|
| 90 |
+
|
| 91 |
+
return NextResponse.json({ results });
|
| 92 |
+
|
| 93 |
+
} catch (error) {
|
| 94 |
+
console.error("[SEARCH_GET]", error);
|
| 95 |
+
return new NextResponse("Internal Error", { status: 500 });
|
| 96 |
+
}
|
| 97 |
+
}
|
app/api/settings/route.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import { auth } from "@/lib/auth";
|
| 3 |
+
import { db } from "@/db";
|
| 4 |
+
import { users } from "@/db/schema";
|
| 5 |
+
import { eq } from "drizzle-orm";
|
| 6 |
+
|
| 7 |
+
interface UpdateUserData {
|
| 8 |
+
name?: string;
|
| 9 |
+
geminiApiKey?: string;
|
| 10 |
+
phone?: string;
|
| 11 |
+
jobTitle?: string;
|
| 12 |
+
company?: string;
|
| 13 |
+
website?: string;
|
| 14 |
+
customVariables?: Record<string, string>;
|
| 15 |
+
updatedAt: Date;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export async function GET() {
|
| 19 |
+
try {
|
| 20 |
+
const session = await auth();
|
| 21 |
+
if (!session?.user?.id) {
|
| 22 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const userId = session.user.id;
|
| 26 |
+
|
| 27 |
+
// Fetch user with API keys
|
| 28 |
+
const [user] = await db
|
| 29 |
+
.select({
|
| 30 |
+
id: users.id,
|
| 31 |
+
name: users.name,
|
| 32 |
+
email: users.email,
|
| 33 |
+
image: users.image,
|
| 34 |
+
geminiApiKey: users.geminiApiKey,
|
| 35 |
+
phone: users.phone,
|
| 36 |
+
jobTitle: users.jobTitle,
|
| 37 |
+
company: users.company,
|
| 38 |
+
website: users.website,
|
| 39 |
+
customVariables: users.customVariables,
|
| 40 |
+
})
|
| 41 |
+
.from(users)
|
| 42 |
+
.where(eq(users.id, userId));
|
| 43 |
+
|
| 44 |
+
if (!user) {
|
| 45 |
+
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// Mask sensitive keys for display
|
| 49 |
+
const maskedGeminiKey = user.geminiApiKey
|
| 50 |
+
? `••••••••${user.geminiApiKey.slice(-4)}`
|
| 51 |
+
: null;
|
| 52 |
+
|
| 53 |
+
return NextResponse.json({
|
| 54 |
+
user: {
|
| 55 |
+
id: user.id,
|
| 56 |
+
name: user.name,
|
| 57 |
+
email: user.email,
|
| 58 |
+
image: user.image,
|
| 59 |
+
geminiApiKey: maskedGeminiKey,
|
| 60 |
+
isGeminiKeySet: !!user.geminiApiKey,
|
| 61 |
+
phone: user.phone,
|
| 62 |
+
jobTitle: user.jobTitle,
|
| 63 |
+
company: user.company,
|
| 64 |
+
website: user.website,
|
| 65 |
+
customVariables: user.customVariables,
|
| 66 |
+
},
|
| 67 |
+
});
|
| 68 |
+
} catch (error) {
|
| 69 |
+
console.error("Error fetching user settings:", error);
|
| 70 |
+
return NextResponse.json(
|
| 71 |
+
{ error: "Failed to fetch settings" },
|
| 72 |
+
{ status: 500 }
|
| 73 |
+
);
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
export async function PATCH(request: Request) {
|
| 78 |
+
try {
|
| 79 |
+
const session = await auth();
|
| 80 |
+
if (!session?.user?.id) {
|
| 81 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
const userId = session.user.id;
|
| 85 |
+
const body = await request.json();
|
| 86 |
+
|
| 87 |
+
const updateData: UpdateUserData = {
|
| 88 |
+
updatedAt: new Date(),
|
| 89 |
+
};
|
| 90 |
+
|
| 91 |
+
// Only update fields that are present in the request
|
| 92 |
+
if (body.name !== undefined) updateData.name = body.name;
|
| 93 |
+
if (body.geminiApiKey !== undefined) updateData.geminiApiKey = body.geminiApiKey;
|
| 94 |
+
if (body.phone !== undefined) updateData.phone = body.phone;
|
| 95 |
+
if (body.jobTitle !== undefined) updateData.jobTitle = body.jobTitle;
|
| 96 |
+
if (body.company !== undefined) updateData.company = body.company;
|
| 97 |
+
if (body.website !== undefined) updateData.website = body.website;
|
| 98 |
+
if (body.customVariables !== undefined) updateData.customVariables = body.customVariables;
|
| 99 |
+
|
| 100 |
+
const [updatedUser] = await db
|
| 101 |
+
.update(users)
|
| 102 |
+
.set(updateData)
|
| 103 |
+
.where(eq(users.id, userId))
|
| 104 |
+
.returning();
|
| 105 |
+
|
| 106 |
+
if (!updatedUser) {
|
| 107 |
+
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
return NextResponse.json({
|
| 111 |
+
success: true,
|
| 112 |
+
user: {
|
| 113 |
+
id: updatedUser.id,
|
| 114 |
+
name: updatedUser.name,
|
| 115 |
+
email: updatedUser.email,
|
| 116 |
+
},
|
| 117 |
+
});
|
| 118 |
+
} catch (error) {
|
| 119 |
+
console.error("Error updating user settings:", error);
|
| 120 |
+
return NextResponse.json(
|
| 121 |
+
{ error: "Failed to update settings" },
|
| 122 |
+
{ status: 500 }
|
| 123 |
+
);
|
| 124 |
+
}
|
| 125 |
+
}
|
app/api/settings/status/route.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import { db } from "@/db";
|
| 3 |
+
import { sql } from "drizzle-orm";
|
| 4 |
+
import Redis from "ioredis";
|
| 5 |
+
|
| 6 |
+
export async function GET() {
|
| 7 |
+
let dbStatus = false;
|
| 8 |
+
let redisStatus = false;
|
| 9 |
+
|
| 10 |
+
// Check Database
|
| 11 |
+
try {
|
| 12 |
+
await db.execute(sql`SELECT 1`);
|
| 13 |
+
dbStatus = true;
|
| 14 |
+
} catch (error) {
|
| 15 |
+
console.error("Database check failed:", error);
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
// Check Redis
|
| 19 |
+
try {
|
| 20 |
+
const redis = new Redis(process.env.REDIS_URL || "redis://localhost:6379", {
|
| 21 |
+
maxRetriesPerRequest: 1,
|
| 22 |
+
connectTimeout: 2000,
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
await redis.ping();
|
| 26 |
+
redisStatus = true;
|
| 27 |
+
redis.disconnect();
|
| 28 |
+
} catch (error) {
|
| 29 |
+
console.error("Redis check failed:", error);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
return NextResponse.json({
|
| 33 |
+
database: dbStatus,
|
| 34 |
+
redis: redisStatus,
|
| 35 |
+
});
|
| 36 |
+
}
|
app/api/tasks/route.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import { auth } from "@/lib/auth";
|
| 3 |
+
import { db } from "@/db";
|
| 4 |
+
import { scrapingJobs, automationWorkflows } from "@/db/schema";
|
| 5 |
+
import { eq, desc } from "drizzle-orm";
|
| 6 |
+
|
| 7 |
+
export async function GET() {
|
| 8 |
+
try {
|
| 9 |
+
const session = await auth();
|
| 10 |
+
if (!session?.user) {
|
| 11 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const userId = session.user.id;
|
| 15 |
+
|
| 16 |
+
// Fetch scraping jobs
|
| 17 |
+
const jobs = await db
|
| 18 |
+
.select({
|
| 19 |
+
id: scrapingJobs.id,
|
| 20 |
+
workflowId: scrapingJobs.workflowId,
|
| 21 |
+
status: scrapingJobs.status,
|
| 22 |
+
priority: scrapingJobs.priority,
|
| 23 |
+
businessesFound: scrapingJobs.businessesFound,
|
| 24 |
+
createdAt: scrapingJobs.createdAt,
|
| 25 |
+
completedAt: scrapingJobs.completedAt,
|
| 26 |
+
workflowName: automationWorkflows.name,
|
| 27 |
+
})
|
| 28 |
+
.from(scrapingJobs)
|
| 29 |
+
.leftJoin(automationWorkflows, eq(scrapingJobs.workflowId, automationWorkflows.id))
|
| 30 |
+
.where(eq(scrapingJobs.userId, userId))
|
| 31 |
+
.orderBy(desc(scrapingJobs.createdAt))
|
| 32 |
+
.limit(100); // Limit results for performance
|
| 33 |
+
|
| 34 |
+
// Fetch active workflows (limit to recent)
|
| 35 |
+
const workflows = await db
|
| 36 |
+
.select({
|
| 37 |
+
id: automationWorkflows.id,
|
| 38 |
+
name: automationWorkflows.name,
|
| 39 |
+
isActive: automationWorkflows.isActive,
|
| 40 |
+
priority: automationWorkflows.priority,
|
| 41 |
+
createdAt: automationWorkflows.createdAt,
|
| 42 |
+
})
|
| 43 |
+
.from(automationWorkflows)
|
| 44 |
+
.where(eq(automationWorkflows.userId, userId))
|
| 45 |
+
.orderBy(desc(automationWorkflows.createdAt))
|
| 46 |
+
.limit(100);
|
| 47 |
+
|
| 48 |
+
// Combine scraping jobs and workflows into tasks
|
| 49 |
+
const scrapingTasks = jobs.map((job) => ({
|
| 50 |
+
id: job.id,
|
| 51 |
+
title: job.workflowName ? `Scraping: ${job.workflowName}` : "Independent Scraping",
|
| 52 |
+
description: `Finding businesses - ${job.businessesFound || 0} found`,
|
| 53 |
+
status: job.status, // Keep original status for control buttons
|
| 54 |
+
priority: job.priority || "medium",
|
| 55 |
+
type: "scraping" as const,
|
| 56 |
+
businessesFound: job.businessesFound || 0,
|
| 57 |
+
workflowName: job.workflowName || "Business Scraping",
|
| 58 |
+
createdAt: job.createdAt,
|
| 59 |
+
}));
|
| 60 |
+
|
| 61 |
+
const workflowTasks = workflows.map((workflow) => ({
|
| 62 |
+
id: workflow.id,
|
| 63 |
+
title: `Workflow: ${workflow.name}`,
|
| 64 |
+
description: workflow.isActive ? "Active automation running" : "Workflow paused",
|
| 65 |
+
status: workflow.isActive ? ("in-progress" as const) : ("pending" as const),
|
| 66 |
+
priority: workflow.priority || "high",
|
| 67 |
+
type: "workflow" as const,
|
| 68 |
+
workflowName: workflow.name,
|
| 69 |
+
createdAt: workflow.createdAt,
|
| 70 |
+
}));
|
| 71 |
+
|
| 72 |
+
// Combine and sort by creation date
|
| 73 |
+
const allTasks = [...scrapingTasks, ...workflowTasks].sort(
|
| 74 |
+
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
| 75 |
+
);
|
| 76 |
+
|
| 77 |
+
return NextResponse.json(allTasks);
|
| 78 |
+
} catch (error) {
|
| 79 |
+
console.error("Error fetching tasks:", error);
|
| 80 |
+
return NextResponse.json(
|
| 81 |
+
{ error: "Failed to fetch tasks" },
|
| 82 |
+
{ status: 500 }
|
| 83 |
+
);
|
| 84 |
+
}
|
| 85 |
+
}
|
app/api/templates/[templateId]/route.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import { NextResponse } from "next/server";
|
| 3 |
+
import { auth } from "@/lib/auth";
|
| 4 |
+
import { db } from "@/db";
|
| 5 |
+
import { emailTemplates } from "@/db/schema";
|
| 6 |
+
import { eq, and } from "drizzle-orm";
|
| 7 |
+
|
| 8 |
+
export async function PATCH(
|
| 9 |
+
request: Request,
|
| 10 |
+
{ params }: { params: Promise<{ templateId: string }> }
|
| 11 |
+
) {
|
| 12 |
+
try {
|
| 13 |
+
const session = await auth();
|
| 14 |
+
if (!session?.user) {
|
| 15 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const { templateId } = await params;
|
| 19 |
+
const userId = session.user.id;
|
| 20 |
+
const body = await request.json();
|
| 21 |
+
const { name, subject, body: emailBody, isDefault } = body;
|
| 22 |
+
|
| 23 |
+
// If setting as default, unset other defaults
|
| 24 |
+
if (isDefault) {
|
| 25 |
+
await db
|
| 26 |
+
.update(emailTemplates)
|
| 27 |
+
.set({ isDefault: false })
|
| 28 |
+
.where(
|
| 29 |
+
and(
|
| 30 |
+
eq(emailTemplates.userId, userId),
|
| 31 |
+
eq(emailTemplates.isDefault, true)
|
| 32 |
+
)
|
| 33 |
+
);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const [template] = await db
|
| 37 |
+
.update(emailTemplates)
|
| 38 |
+
.set({
|
| 39 |
+
name,
|
| 40 |
+
subject,
|
| 41 |
+
body: emailBody,
|
| 42 |
+
isDefault,
|
| 43 |
+
updatedAt: new Date(),
|
| 44 |
+
})
|
| 45 |
+
.where(
|
| 46 |
+
and(eq(emailTemplates.id, templateId), eq(emailTemplates.userId, userId))
|
| 47 |
+
)
|
| 48 |
+
.returning();
|
| 49 |
+
|
| 50 |
+
if (!template) {
|
| 51 |
+
return NextResponse.json({ error: "Template not found" }, { status: 404 });
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
return NextResponse.json({ template });
|
| 55 |
+
} catch (error) {
|
| 56 |
+
console.error("Error updating template:", error);
|
| 57 |
+
return NextResponse.json(
|
| 58 |
+
{ error: "Failed to update template" },
|
| 59 |
+
{ status: 500 }
|
| 60 |
+
);
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
export async function DELETE(
|
| 65 |
+
request: Request,
|
| 66 |
+
{ params }: { params: Promise<{ templateId: string }> }
|
| 67 |
+
) {
|
| 68 |
+
try {
|
| 69 |
+
const session = await auth();
|
| 70 |
+
if (!session?.user) {
|
| 71 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
const { templateId } = await params;
|
| 75 |
+
const userId = session.user.id;
|
| 76 |
+
|
| 77 |
+
if (!templateId) {
|
| 78 |
+
return NextResponse.json({ error: "Template ID required" }, { status: 400 });
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
const [deleted] = await db
|
| 82 |
+
.delete(emailTemplates)
|
| 83 |
+
.where(
|
| 84 |
+
and(eq(emailTemplates.id, templateId), eq(emailTemplates.userId, userId))
|
| 85 |
+
)
|
| 86 |
+
.returning();
|
| 87 |
+
|
| 88 |
+
if (!deleted) {
|
| 89 |
+
return NextResponse.json({ error: "Template not found" }, { status: 404 });
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
return NextResponse.json({ success: true });
|
| 93 |
+
} catch (error) {
|
| 94 |
+
console.error("Error deleting template:", error);
|
| 95 |
+
return NextResponse.json(
|
| 96 |
+
{ error: "Failed to delete template" },
|
| 97 |
+
{ status: 500 }
|
| 98 |
+
);
|
| 99 |
+
}
|
| 100 |
+
}
|
app/api/templates/generate/route.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import { auth } from "@/lib/auth";
|
| 3 |
+
import { db } from "@/db";
|
| 4 |
+
import { users } from "@/db/schema";
|
| 5 |
+
import { eq } from "drizzle-orm";
|
| 6 |
+
import { generateEmailTemplate } from "@/lib/gemini";
|
| 7 |
+
|
| 8 |
+
export async function POST(request: Request) {
|
| 9 |
+
try {
|
| 10 |
+
const session = await auth();
|
| 11 |
+
if (!session?.user) {
|
| 12 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const userId = session.user.id;
|
| 16 |
+
const { businessType, purpose } = await request.json();
|
| 17 |
+
|
| 18 |
+
if (!businessType || !purpose) {
|
| 19 |
+
return NextResponse.json(
|
| 20 |
+
{ error: "Business type and purpose are required" },
|
| 21 |
+
{ status: 400 }
|
| 22 |
+
);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
// Fetch user's API key
|
| 26 |
+
const [user] = await db
|
| 27 |
+
.select({ geminiApiKey: users.geminiApiKey })
|
| 28 |
+
.from(users)
|
| 29 |
+
.where(eq(users.id, userId));
|
| 30 |
+
|
| 31 |
+
const apiKey = user?.geminiApiKey || process.env.GEMINI_API_KEY;
|
| 32 |
+
|
| 33 |
+
if (!apiKey) {
|
| 34 |
+
return NextResponse.json(
|
| 35 |
+
{ error: "Gemini API key not found. Please set it in Settings." },
|
| 36 |
+
{ status: 400 }
|
| 37 |
+
);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
const template = await generateEmailTemplate(businessType, purpose, apiKey);
|
| 41 |
+
|
| 42 |
+
return NextResponse.json(template);
|
| 43 |
+
} catch (error: unknown) {
|
| 44 |
+
console.error("Error generating template:", error);
|
| 45 |
+
|
| 46 |
+
let status = 500;
|
| 47 |
+
let message = "Failed to generate template. Please try again.";
|
| 48 |
+
|
| 49 |
+
if (error instanceof Error) {
|
| 50 |
+
if (error.message.includes("429")) {
|
| 51 |
+
status = 429;
|
| 52 |
+
message = "AI generation quota exceeded. Please try again later.";
|
| 53 |
+
} else if (error.message.includes("404")) {
|
| 54 |
+
status = 404;
|
| 55 |
+
message = "Selected AI model is currently unavailable. Please contact support.";
|
| 56 |
+
} else if (error.message.includes("403")) {
|
| 57 |
+
status = 403;
|
| 58 |
+
message = "Invalid API Key or permissions. Please check your settings.";
|
| 59 |
+
} else if (error.message.includes("503")) {
|
| 60 |
+
status = 503;
|
| 61 |
+
message = "AI service is temporarily overloaded. Please try again.";
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
return NextResponse.json(
|
| 66 |
+
{ error: message },
|
| 67 |
+
{ status }
|
| 68 |
+
);
|
| 69 |
+
}
|
| 70 |
+
}
|
app/api/templates/route.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import { NextResponse } from "next/server";
|
| 3 |
+
import { auth } from "@/lib/auth";
|
| 4 |
+
import { db } from "@/db";
|
| 5 |
+
import { emailTemplates } from "@/db/schema";
|
| 6 |
+
import { eq, and } from "drizzle-orm";
|
| 7 |
+
import { generateEmailTemplate } from "@/lib/gemini";
|
| 8 |
+
|
| 9 |
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
| 10 |
+
export async function GET(_request: Request) {
|
| 11 |
+
try {
|
| 12 |
+
const session = await auth();
|
| 13 |
+
if (!session?.user) {
|
| 14 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const userId = (session.user as { id: string }).id;
|
| 18 |
+
|
| 19 |
+
const templates = await db
|
| 20 |
+
.select()
|
| 21 |
+
.from(emailTemplates)
|
| 22 |
+
.where(eq(emailTemplates.userId, userId))
|
| 23 |
+
.orderBy(emailTemplates.createdAt);
|
| 24 |
+
|
| 25 |
+
return NextResponse.json({ templates });
|
| 26 |
+
} catch (error) {
|
| 27 |
+
console.error("Error fetching templates:", error);
|
| 28 |
+
return NextResponse.json(
|
| 29 |
+
{ error: "Failed to fetch templates" },
|
| 30 |
+
{ status: 500 }
|
| 31 |
+
);
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export async function POST(request: Request) {
|
| 36 |
+
try {
|
| 37 |
+
const session = await auth();
|
| 38 |
+
if (!session?.user) {
|
| 39 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
const userId = (session.user as { id: string }).id;
|
| 43 |
+
const body = await request.json();
|
| 44 |
+
const { name, subject, body: emailBody, isDefault, generateWithAI, prompt } = body;
|
| 45 |
+
|
| 46 |
+
let finalSubject = subject;
|
| 47 |
+
let finalBody = emailBody;
|
| 48 |
+
|
| 49 |
+
// Generate with AI if requested
|
| 50 |
+
if (generateWithAI && prompt) {
|
| 51 |
+
const generated = await generateEmailTemplate(prompt, "cold outreach");
|
| 52 |
+
finalSubject = generated.subject;
|
| 53 |
+
finalBody = generated.body;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// If setting as default, unset other defaults
|
| 57 |
+
if (isDefault) {
|
| 58 |
+
await db
|
| 59 |
+
.update(emailTemplates)
|
| 60 |
+
.set({ isDefault: false })
|
| 61 |
+
.where(
|
| 62 |
+
and(
|
| 63 |
+
eq(emailTemplates.userId, userId),
|
| 64 |
+
eq(emailTemplates.isDefault, true)
|
| 65 |
+
)
|
| 66 |
+
);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
const [template] = await db
|
| 70 |
+
.insert(emailTemplates)
|
| 71 |
+
.values({
|
| 72 |
+
userId,
|
| 73 |
+
name,
|
| 74 |
+
subject: finalSubject,
|
| 75 |
+
body: finalBody,
|
| 76 |
+
isDefault: isDefault || false,
|
| 77 |
+
})
|
| 78 |
+
.returning();
|
| 79 |
+
|
| 80 |
+
return NextResponse.json({ template });
|
| 81 |
+
} catch (error) {
|
| 82 |
+
console.error("Error creating template:", error);
|
| 83 |
+
return NextResponse.json(
|
| 84 |
+
{ error: "Failed to create template" },
|
| 85 |
+
{ status: 500 }
|
| 86 |
+
);
|
| 87 |
+
}
|
| 88 |
+
}
|
app/api/webhooks/email/route.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import { db } from "@/db";
|
| 3 |
+
import { emailLogs, businesses } from "@/db/schema";
|
| 4 |
+
import { eq } from "drizzle-orm";
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Email tracking webhook endpoint
|
| 8 |
+
* Handles SendGrid/Resend webhooks for email events (opened, clicked, bounced, etc.)
|
| 9 |
+
*/
|
| 10 |
+
export async function POST(request: Request) {
|
| 11 |
+
try {
|
| 12 |
+
const events = await request.json();
|
| 13 |
+
|
| 14 |
+
for (const event of Array.isArray(events) ? events : [events]) {
|
| 15 |
+
const { email, event: eventType, timestamp } = event;
|
| 16 |
+
|
| 17 |
+
// Find the email log by recipient email
|
| 18 |
+
const business = await db.query.businesses.findFirst({
|
| 19 |
+
where: eq(businesses.email, email),
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
if (!business) {
|
| 23 |
+
console.warn(`Business not found for email: ${email}`);
|
| 24 |
+
continue;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// Update email log based on event type
|
| 28 |
+
switch (eventType) {
|
| 29 |
+
case "open":
|
| 30 |
+
case "opened":
|
| 31 |
+
await db
|
| 32 |
+
.update(emailLogs)
|
| 33 |
+
.set({
|
| 34 |
+
status: "opened",
|
| 35 |
+
openedAt: new Date(timestamp * 1000),
|
| 36 |
+
})
|
| 37 |
+
.where(eq(emailLogs.businessId, business.id));
|
| 38 |
+
|
| 39 |
+
// Update business email status
|
| 40 |
+
await db
|
| 41 |
+
.update(businesses)
|
| 42 |
+
.set({ emailStatus: "opened" })
|
| 43 |
+
.where(eq(businesses.id, business.id));
|
| 44 |
+
break;
|
| 45 |
+
|
| 46 |
+
case "click":
|
| 47 |
+
case "clicked":
|
| 48 |
+
await db
|
| 49 |
+
.update(emailLogs)
|
| 50 |
+
.set({
|
| 51 |
+
status: "clicked",
|
| 52 |
+
clickedAt: new Date(timestamp * 1000),
|
| 53 |
+
})
|
| 54 |
+
.where(eq(emailLogs.businessId, business.id));
|
| 55 |
+
|
| 56 |
+
await db
|
| 57 |
+
.update(businesses)
|
| 58 |
+
.set({ emailStatus: "clicked" })
|
| 59 |
+
.where(eq(businesses.id, business.id));
|
| 60 |
+
break;
|
| 61 |
+
|
| 62 |
+
case "bounce":
|
| 63 |
+
case "bounced":
|
| 64 |
+
await db
|
| 65 |
+
.update(emailLogs)
|
| 66 |
+
.set({
|
| 67 |
+
status: "bounced",
|
| 68 |
+
})
|
| 69 |
+
.where(eq(emailLogs.businessId, business.id));
|
| 70 |
+
|
| 71 |
+
await db
|
| 72 |
+
.update(businesses)
|
| 73 |
+
.set({ emailStatus: "bounced" })
|
| 74 |
+
.where(eq(businesses.id, business.id));
|
| 75 |
+
break;
|
| 76 |
+
|
| 77 |
+
case "spam":
|
| 78 |
+
case "spamreport":
|
| 79 |
+
await db
|
| 80 |
+
.update(emailLogs)
|
| 81 |
+
.set({
|
| 82 |
+
status: "error",
|
| 83 |
+
})
|
| 84 |
+
.where(eq(emailLogs.businessId, business.id));
|
| 85 |
+
|
| 86 |
+
await db
|
| 87 |
+
.update(businesses)
|
| 88 |
+
.set({ emailStatus: "failed" })
|
| 89 |
+
.where(eq(businesses.id, business.id));
|
| 90 |
+
break;
|
| 91 |
+
|
| 92 |
+
case "unsubscribe":
|
| 93 |
+
await db
|
| 94 |
+
.update(businesses)
|
| 95 |
+
.set({ emailStatus: "unsubscribed" })
|
| 96 |
+
.where(eq(businesses.id, business.id));
|
| 97 |
+
break;
|
| 98 |
+
|
| 99 |
+
default:
|
| 100 |
+
console.log(`Unknown event type: ${eventType}`);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
console.log(`📧 Email event processed: ${eventType} for ${email}`);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
return NextResponse.json({ success: true });
|
| 107 |
+
} catch (error) {
|
| 108 |
+
console.error("Error processing webhook:", error);
|
| 109 |
+
return NextResponse.json(
|
| 110 |
+
{ error: "Failed to process webhook" },
|
| 111 |
+
{ status: 500 }
|
| 112 |
+
);
|
| 113 |
+
}
|
| 114 |
+
}
|
app/api/workflows/[id]/route.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import { auth } from "@/lib/auth";
|
| 3 |
+
import { db } from "@/db";
|
| 4 |
+
import { automationWorkflows } from "@/db/schema";
|
| 5 |
+
import { eq, and } from "drizzle-orm";
|
| 6 |
+
import { SessionUser } from "@/types";
|
| 7 |
+
|
| 8 |
+
export async function PATCH(
|
| 9 |
+
request: Request,
|
| 10 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 11 |
+
) {
|
| 12 |
+
try {
|
| 13 |
+
const session = await auth();
|
| 14 |
+
if (!session?.user) {
|
| 15 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const { id } = await params;
|
| 19 |
+
const userId = (session.user as SessionUser).id;
|
| 20 |
+
const body = await request.json();
|
| 21 |
+
|
| 22 |
+
// Toggle isActive or update priority
|
| 23 |
+
const updates: Partial<{ isActive: boolean; priority: string; updatedAt: Date }> = {
|
| 24 |
+
updatedAt: new Date(),
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
if (typeof body.isActive === "boolean") {
|
| 28 |
+
updates.isActive = body.isActive;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
if (body.priority && ["low", "medium", "high"].includes(body.priority)) {
|
| 32 |
+
updates.priority = body.priority;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
if (Object.keys(updates).length > 1) { // updatedAt is always there
|
| 36 |
+
await db
|
| 37 |
+
.update(automationWorkflows)
|
| 38 |
+
.set(updates)
|
| 39 |
+
.where(
|
| 40 |
+
and(
|
| 41 |
+
eq(automationWorkflows.id, id),
|
| 42 |
+
eq(automationWorkflows.userId, userId)
|
| 43 |
+
)
|
| 44 |
+
);
|
| 45 |
+
|
| 46 |
+
return NextResponse.json({ success: true });
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
return NextResponse.json({ error: "No valid updates provided" }, { status: 400 });
|
| 50 |
+
} catch (error) {
|
| 51 |
+
console.error("Error updating workflow:", error);
|
| 52 |
+
return NextResponse.json(
|
| 53 |
+
{ error: "Failed to update workflow" },
|
| 54 |
+
{ status: 500 }
|
| 55 |
+
);
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export async function DELETE(
|
| 60 |
+
request: Request,
|
| 61 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 62 |
+
) {
|
| 63 |
+
try {
|
| 64 |
+
const session = await auth();
|
| 65 |
+
if (!session?.user) {
|
| 66 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
const { id } = await params;
|
| 70 |
+
const userId = (session.user as SessionUser).id;
|
| 71 |
+
|
| 72 |
+
await db
|
| 73 |
+
.delete(automationWorkflows)
|
| 74 |
+
.where(
|
| 75 |
+
and(
|
| 76 |
+
eq(automationWorkflows.id, id),
|
| 77 |
+
eq(automationWorkflows.userId, userId)
|
| 78 |
+
)
|
| 79 |
+
);
|
| 80 |
+
|
| 81 |
+
return NextResponse.json({ success: true });
|
| 82 |
+
} catch (error) {
|
| 83 |
+
console.error("Error deleting workflow:", error);
|
| 84 |
+
return NextResponse.json(
|
| 85 |
+
{ error: "Failed to delete workflow" },
|
| 86 |
+
{ status: 500 }
|
| 87 |
+
);
|
| 88 |
+
}
|
| 89 |
+
}
|
app/api/workflows/route.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import { auth } from "@/lib/auth";
|
| 3 |
+
import { db } from "@/db";
|
| 4 |
+
import { automationWorkflows } from "@/db/schema";
|
| 5 |
+
import { eq, and } from "drizzle-orm";
|
| 6 |
+
import { SessionUser } from "@/types";
|
| 7 |
+
|
| 8 |
+
export async function GET() {
|
| 9 |
+
try {
|
| 10 |
+
const session = await auth();
|
| 11 |
+
if (!session?.user) {
|
| 12 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const userId = (session.user as SessionUser).id;
|
| 16 |
+
|
| 17 |
+
const workflows = await db
|
| 18 |
+
.select()
|
| 19 |
+
.from(automationWorkflows)
|
| 20 |
+
.where(eq(automationWorkflows.userId, userId))
|
| 21 |
+
.orderBy(automationWorkflows.createdAt);
|
| 22 |
+
|
| 23 |
+
return NextResponse.json({ workflows });
|
| 24 |
+
} catch (error) {
|
| 25 |
+
console.error("Error fetching workflows:", error);
|
| 26 |
+
return NextResponse.json(
|
| 27 |
+
{ error: "Failed to fetch workflows" },
|
| 28 |
+
{ status: 500 }
|
| 29 |
+
);
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export async function POST(request: Request) {
|
| 34 |
+
try {
|
| 35 |
+
const session = await auth();
|
| 36 |
+
if (!session?.user) {
|
| 37 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
const userId = (session.user as SessionUser).id;
|
| 41 |
+
const body = await request.json();
|
| 42 |
+
const {
|
| 43 |
+
name,
|
| 44 |
+
targetBusinessType,
|
| 45 |
+
keywords,
|
| 46 |
+
nodes,
|
| 47 |
+
edges,
|
| 48 |
+
isActive,
|
| 49 |
+
} = body;
|
| 50 |
+
|
| 51 |
+
const [workflow] = await db
|
| 52 |
+
.insert(automationWorkflows)
|
| 53 |
+
.values({
|
| 54 |
+
userId,
|
| 55 |
+
name,
|
| 56 |
+
targetBusinessType: targetBusinessType || "",
|
| 57 |
+
keywords: keywords || [],
|
| 58 |
+
nodes: nodes || [],
|
| 59 |
+
edges: edges || [],
|
| 60 |
+
isActive: isActive || false,
|
| 61 |
+
})
|
| 62 |
+
.returning();
|
| 63 |
+
|
| 64 |
+
return NextResponse.json({ workflow });
|
| 65 |
+
} catch (error) {
|
| 66 |
+
console.error("Error creating workflow:", error);
|
| 67 |
+
return NextResponse.json(
|
| 68 |
+
{ error: "Failed to create workflow" },
|
| 69 |
+
{ status: 500 }
|
| 70 |
+
);
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
export async function PATCH(request: Request) {
|
| 75 |
+
try {
|
| 76 |
+
const session = await auth();
|
| 77 |
+
if (!session?.user) {
|
| 78 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
const userId = (session.user as SessionUser).id;
|
| 82 |
+
const body = await request.json();
|
| 83 |
+
const { id, name, nodes, edges, isActive } = body;
|
| 84 |
+
|
| 85 |
+
const [workflow] = await db
|
| 86 |
+
.update(automationWorkflows)
|
| 87 |
+
.set({
|
| 88 |
+
name,
|
| 89 |
+
nodes,
|
| 90 |
+
edges,
|
| 91 |
+
isActive,
|
| 92 |
+
updatedAt: new Date(),
|
| 93 |
+
})
|
| 94 |
+
.where(
|
| 95 |
+
and(
|
| 96 |
+
eq(automationWorkflows.id, id),
|
| 97 |
+
eq(automationWorkflows.userId, userId)
|
| 98 |
+
)
|
| 99 |
+
)
|
| 100 |
+
.returning();
|
| 101 |
+
|
| 102 |
+
return NextResponse.json({ workflow });
|
| 103 |
+
} catch (error) {
|
| 104 |
+
console.error("Error updating workflow:", error);
|
| 105 |
+
return NextResponse.json(
|
| 106 |
+
{ error: "Failed to update workflow" },
|
| 107 |
+
{ status: 500 }
|
| 108 |
+
);
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
export async function DELETE(request: Request) {
|
| 113 |
+
try {
|
| 114 |
+
const session = await auth();
|
| 115 |
+
if (!session?.user) {
|
| 116 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
const userId = (session.user as SessionUser).id;
|
| 120 |
+
const { searchParams } = new URL(request.url);
|
| 121 |
+
const id = searchParams.get("id");
|
| 122 |
+
|
| 123 |
+
if (!id) {
|
| 124 |
+
return NextResponse.json(
|
| 125 |
+
{ error: "Workflow ID required" },
|
| 126 |
+
{ status: 400 }
|
| 127 |
+
);
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
await db
|
| 131 |
+
.delete(automationWorkflows)
|
| 132 |
+
.where(
|
| 133 |
+
and(
|
| 134 |
+
eq(automationWorkflows.id, id),
|
| 135 |
+
eq(automationWorkflows.userId, userId)
|
| 136 |
+
)
|
| 137 |
+
);
|
| 138 |
+
|
| 139 |
+
return NextResponse.json({ success: true });
|
| 140 |
+
} catch (error) {
|
| 141 |
+
console.error("Error deleting workflow:", error);
|
| 142 |
+
return NextResponse.json(
|
| 143 |
+
{ error: "Failed to delete workflow" },
|
| 144 |
+
{ status: 500 }
|
| 145 |
+
);
|
| 146 |
+
}
|
| 147 |
+
}
|
app/apple-icon.png
ADDED
|
|
app/auth/signin/page.tsx
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
"use client";
|
| 3 |
+
|
| 4 |
+
import { signIn } from "next-auth/react";
|
| 5 |
+
import Link from "next/link";
|
| 6 |
+
import { Button } from "@/components/ui/button";
|
| 7 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 8 |
+
import { Infinity, Github } from "lucide-react";
|
| 9 |
+
|
| 10 |
+
export default function SignIn() {
|
| 11 |
+
return (
|
| 12 |
+
<div className="flex min-h-screen items-center justify-center bg-[radial-gradient(ellipse_at_top,var(--tw-gradient-stops))] from-indigo-200 via-slate-100 to-indigo-100 dark:from-slate-900 dark:via-slate-900 dark:to-indigo-900">
|
| 13 |
+
<div className="absolute inset-0 bg-grid-slate-200/50 mask-[linear-gradient(0deg,white,rgba(255,255,255,0.6))] dark:bg-grid-slate-700/25 dark:mask-[linear-gradient(0deg,rgba(255,255,255,0.1),rgba(255,255,255,0.5))]" />
|
| 14 |
+
|
| 15 |
+
<Card className="relative w-full max-w-md border-0 bg-white/70 shadow-2xl backdrop-blur-xl dark:bg-slate-950/70">
|
| 16 |
+
<CardHeader className="text-center pb-8">
|
| 17 |
+
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-2xl bg-linear-to-br from-indigo-500 to-purple-600 shadow-lg shadow-indigo-500/30 ring-1 ring-white/50">
|
| 18 |
+
<Infinity className="h-10 w-10 text-white" />
|
| 19 |
+
</div>
|
| 20 |
+
<div className="space-y-2">
|
| 21 |
+
<CardTitle className="text-3xl font-bold tracking-tight bg-linear-to-br from-indigo-500 to-purple-600 bg-clip-text text-transparent">
|
| 22 |
+
AutoLoop
|
| 23 |
+
</CardTitle>
|
| 24 |
+
<CardDescription className="text-base font-medium text-slate-600 dark:text-slate-400">
|
| 25 |
+
Automated Cold Email Intelligence
|
| 26 |
+
</CardDescription>
|
| 27 |
+
</div>
|
| 28 |
+
</CardHeader>
|
| 29 |
+
|
| 30 |
+
<CardContent className="space-y-6 px-8 pb-8">
|
| 31 |
+
<div className="grid gap-4">
|
| 32 |
+
<Button
|
| 33 |
+
variant="outline"
|
| 34 |
+
size="lg"
|
| 35 |
+
className="relative h-12 border-slate-200 bg-white hover:bg-slate-50 hover:text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:hover:bg-slate-900 dark:hover:text-slate-50 transition-all hover:scale-[1.02] hover:shadow-md"
|
| 36 |
+
onClick={() => signIn("google", { callbackUrl: "/dashboard" })}
|
| 37 |
+
>
|
| 38 |
+
<div className="absolute left-4 flex h-6 w-6 items-center justify-center">
|
| 39 |
+
<svg viewBox="0 0 24 24" className="h-5 w-5">
|
| 40 |
+
<path
|
| 41 |
+
fill="#4285F4"
|
| 42 |
+
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
| 43 |
+
/>
|
| 44 |
+
<path
|
| 45 |
+
fill="#34A853"
|
| 46 |
+
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
| 47 |
+
/>
|
| 48 |
+
<path
|
| 49 |
+
fill="#FBBC05"
|
| 50 |
+
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-222.81-.62z"
|
| 51 |
+
/>
|
| 52 |
+
<path
|
| 53 |
+
fill="#EA4335"
|
| 54 |
+
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
| 55 |
+
/>
|
| 56 |
+
</svg>
|
| 57 |
+
</div>
|
| 58 |
+
<span className="font-semibold">Continue with Google</span>
|
| 59 |
+
</Button>
|
| 60 |
+
|
| 61 |
+
<Button
|
| 62 |
+
variant="outline"
|
| 63 |
+
size="lg"
|
| 64 |
+
className="relative h-12 border-slate-200 bg-white hover:bg-slate-50 hover:text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:hover:bg-slate-900 dark:hover:text-slate-50 transition-all hover:scale-[1.02] hover:shadow-md"
|
| 65 |
+
onClick={() => signIn("github", { callbackUrl: "/dashboard" })}
|
| 66 |
+
>
|
| 67 |
+
<div className="absolute left-4 flex h-6 w-6 items-center justify-center">
|
| 68 |
+
<Github className="h-5 w-5" />
|
| 69 |
+
</div>
|
| 70 |
+
<span className="font-semibold">Continue with GitHub</span>
|
| 71 |
+
</Button>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<div className="relative">
|
| 75 |
+
<div className="absolute inset-0 flex items-center">
|
| 76 |
+
<span className="w-full border-t border-slate-200 dark:border-slate-800" />
|
| 77 |
+
</div>
|
| 78 |
+
<div className="relative flex justify-center text-xs uppercase">
|
| 79 |
+
<span className="bg-white px-2 text-slate-500 dark:bg-slate-950 dark:text-slate-400">
|
| 80 |
+
Or continue with
|
| 81 |
+
</span>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
<div className="text-center">
|
| 86 |
+
<Button variant="link" asChild className="text-slate-500 dark:text-slate-400 font-normal hover:text-primary transition-colors">
|
| 87 |
+
<Link href="/admin/login">Admin Access</Link>
|
| 88 |
+
</Button>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
<p className="text-center text-xs text-slate-500 dark:text-slate-400 px-4 leading-relaxed">
|
| 92 |
+
By clicking continue, you agree to our{" "}
|
| 93 |
+
<Link href="#" className="font-medium text-primary hover:underline underline-offset-4">
|
| 94 |
+
Terms of Service
|
| 95 |
+
</Link>{" "}
|
| 96 |
+
and{" "}
|
| 97 |
+
<Link href="#" className="font-medium text-primary hover:underline underline-offset-4">
|
| 98 |
+
Privacy Policy
|
| 99 |
+
</Link>
|
| 100 |
+
</p>
|
| 101 |
+
</CardContent>
|
| 102 |
+
</Card>
|
| 103 |
+
</div>
|
| 104 |
+
);
|
| 105 |
+
}
|
app/cursor-styles.css
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Global cursor pointer for all buttons and interactive elements */
|
| 2 |
+
button,
|
| 3 |
+
a[role="button"],
|
| 4 |
+
[role="button"],
|
| 5 |
+
.cursor-pointer {
|
| 6 |
+
cursor: pointer !important;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
button:disabled,
|
| 10 |
+
button[disabled] {
|
| 11 |
+
cursor: not-allowed !important;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
/* Ensure all Radix UI interactive elements have pointer cursor */
|
| 15 |
+
[data-radix-collection-item],
|
| 16 |
+
[role="menuitem"],
|
| 17 |
+
[role="option"],
|
| 18 |
+
[role="tab"],
|
| 19 |
+
[role="switch"],
|
| 20 |
+
[role="checkbox"],
|
| 21 |
+
[role="radio"] {
|
| 22 |
+
cursor: pointer;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
/* Select elements */
|
| 26 |
+
select {
|
| 27 |
+
cursor: pointer;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/* Links */
|
| 31 |
+
a:not([disabled]) {
|
| 32 |
+
cursor: pointer;
|
| 33 |
+
}
|
app/dashboard/analytics/page.tsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { AnalyticsDashboard } from "@/components/analytics-dashboard";
|
| 2 |
+
import { Metadata } from "next";
|
| 3 |
+
|
| 4 |
+
export const metadata: Metadata = {
|
| 5 |
+
title: "Analytics",
|
| 6 |
+
description: "View detailed analytics and performance metrics for your email campaigns",
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
export default function AnalyticsPage() {
|
| 10 |
+
return (
|
| 11 |
+
<div className="p-6">
|
| 12 |
+
<AnalyticsDashboard />
|
| 13 |
+
</div>
|
| 14 |
+
);
|
| 15 |
+
}
|
app/dashboard/businesses/page.tsx
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"; // Force rebuild
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 5 |
+
import { Input } from "@/components/ui/input";
|
| 6 |
+
import { Button } from "@/components/ui/button";
|
| 7 |
+
import { Badge } from "@/components/ui/badge";
|
| 8 |
+
import { Label } from "@/components/ui/label";
|
| 9 |
+
import {
|
| 10 |
+
Search,
|
| 11 |
+
Download,
|
| 12 |
+
MoreVertical,
|
| 13 |
+
Mail,
|
| 14 |
+
Phone,
|
| 15 |
+
Globe,
|
| 16 |
+
MapPin,
|
| 17 |
+
Star,
|
| 18 |
+
Trash2,
|
| 19 |
+
Eye,
|
| 20 |
+
Loader2
|
| 21 |
+
} from "lucide-react";
|
| 22 |
+
import {
|
| 23 |
+
DropdownMenu,
|
| 24 |
+
DropdownMenuContent,
|
| 25 |
+
DropdownMenuItem,
|
| 26 |
+
DropdownMenuTrigger,
|
| 27 |
+
} from "@/components/ui/dropdown-menu";
|
| 28 |
+
import {
|
| 29 |
+
Table,
|
| 30 |
+
TableBody,
|
| 31 |
+
TableCell,
|
| 32 |
+
TableHead,
|
| 33 |
+
TableHeader,
|
| 34 |
+
TableRow,
|
| 35 |
+
} from "@/components/ui/table";
|
| 36 |
+
import { useBusinesses, BusinessResponse } from "@/hooks/use-businesses";
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
export default function BusinessesPage() {
|
| 41 |
+
const {
|
| 42 |
+
businesses,
|
| 43 |
+
loading,
|
| 44 |
+
filterCategory,
|
| 45 |
+
setFilterCategory,
|
| 46 |
+
filterStatus,
|
| 47 |
+
setFilterStatus,
|
| 48 |
+
deleteBusiness,
|
| 49 |
+
updateBusiness
|
| 50 |
+
} = useBusinesses();
|
| 51 |
+
|
| 52 |
+
const [searchQuery, setSearchQuery] = useState("");
|
| 53 |
+
const [currentPage, setCurrentPage] = useState(1);
|
| 54 |
+
const [editingBusiness, setEditingBusiness] = useState<BusinessResponse | null>(null);
|
| 55 |
+
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
| 56 |
+
const [isSaving, setIsSaving] = useState(false);
|
| 57 |
+
const itemsPerPage = 20;
|
| 58 |
+
|
| 59 |
+
const handleDelete = async (id: string) => {
|
| 60 |
+
await deleteBusiness(id);
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
const handleEdit = (business: BusinessResponse) => {
|
| 64 |
+
setEditingBusiness(business);
|
| 65 |
+
setIsEditModalOpen(true);
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
const handleSaveEdit = async (updatedBusiness: Partial<BusinessResponse>) => {
|
| 69 |
+
if (!editingBusiness) return;
|
| 70 |
+
|
| 71 |
+
setIsSaving(true);
|
| 72 |
+
const success = await updateBusiness(editingBusiness.id, updatedBusiness);
|
| 73 |
+
setIsSaving(false);
|
| 74 |
+
|
| 75 |
+
if (success) {
|
| 76 |
+
setIsEditModalOpen(false);
|
| 77 |
+
setEditingBusiness(null);
|
| 78 |
+
}
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
const exportToCSV = () => {
|
| 82 |
+
const headers = ["Name", "Email", "Phone", "Website", "Address", "Category", "Rating", "Status"];
|
| 83 |
+
const rows = filteredBusinesses.map((b) => [
|
| 84 |
+
b.name,
|
| 85 |
+
b.email || "",
|
| 86 |
+
b.phone || "",
|
| 87 |
+
b.website || "",
|
| 88 |
+
b.address || "",
|
| 89 |
+
b.category,
|
| 90 |
+
b.rating || "",
|
| 91 |
+
b.emailStatus || "pending",
|
| 92 |
+
]);
|
| 93 |
+
|
| 94 |
+
const csv = [headers, ...rows].map((row) => row.join(",")).join("\n");
|
| 95 |
+
const blob = new Blob([csv], { type: "text/csv" });
|
| 96 |
+
const url = URL.createObjectURL(blob);
|
| 97 |
+
const a = document.createElement("a");
|
| 98 |
+
a.href = url;
|
| 99 |
+
a.download = `businesses-${new Date().toISOString().split("T")[0]}.csv`;
|
| 100 |
+
a.click();
|
| 101 |
+
};
|
| 102 |
+
|
| 103 |
+
const filteredBusinesses = businesses.filter((business) =>
|
| 104 |
+
business.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
| 105 |
+
business.email?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
| 106 |
+
business.category.toLowerCase().includes(searchQuery.toLowerCase())
|
| 107 |
+
);
|
| 108 |
+
|
| 109 |
+
// Pagination
|
| 110 |
+
const totalPages = Math.ceil(filteredBusinesses.length / itemsPerPage);
|
| 111 |
+
|
| 112 |
+
const paginatedBusinesses = filteredBusinesses.slice(
|
| 113 |
+
(currentPage - 1) * itemsPerPage,
|
| 114 |
+
currentPage * itemsPerPage
|
| 115 |
+
);
|
| 116 |
+
|
| 117 |
+
const getStatusColor = (status: string | null) => {
|
| 118 |
+
switch (status) {
|
| 119 |
+
case "sent":
|
| 120 |
+
return "default";
|
| 121 |
+
case "opened":
|
| 122 |
+
return "default";
|
| 123 |
+
case "clicked":
|
| 124 |
+
return "default";
|
| 125 |
+
case "bounced":
|
| 126 |
+
return "destructive";
|
| 127 |
+
case "failed":
|
| 128 |
+
return "destructive";
|
| 129 |
+
default:
|
| 130 |
+
return "secondary";
|
| 131 |
+
}
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
+
return (
|
| 135 |
+
<div className="space-y-6">
|
| 136 |
+
{/* Header */}
|
| 137 |
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
| 138 |
+
<div>
|
| 139 |
+
<h1 className="text-3xl font-bold">Businesses</h1>
|
| 140 |
+
<p className="text-muted-foreground">
|
| 141 |
+
Manage your scraped business leads
|
| 142 |
+
</p>
|
| 143 |
+
</div>
|
| 144 |
+
<Button onClick={exportToCSV} variant="outline" className="cursor-pointer w-full sm:w-auto">
|
| 145 |
+
<Download className="mr-2 h-4 w-4" />
|
| 146 |
+
Export CSV
|
| 147 |
+
</Button>
|
| 148 |
+
</div>
|
| 149 |
+
|
| 150 |
+
{/* Stats */}
|
| 151 |
+
<div className="grid gap-4 md:grid-cols-4">
|
| 152 |
+
<Card>
|
| 153 |
+
<CardHeader className="pb-2">
|
| 154 |
+
<CardDescription>Total Businesses</CardDescription>
|
| 155 |
+
<CardTitle className="text-2xl">{businesses.length}</CardTitle>
|
| 156 |
+
</CardHeader>
|
| 157 |
+
</Card>
|
| 158 |
+
<Card>
|
| 159 |
+
<CardHeader className="pb-2">
|
| 160 |
+
<CardDescription>With Email</CardDescription>
|
| 161 |
+
<CardTitle className="text-2xl">
|
| 162 |
+
{businesses.filter((b) => b.email).length}
|
| 163 |
+
</CardTitle>
|
| 164 |
+
</CardHeader>
|
| 165 |
+
</Card>
|
| 166 |
+
<Card>
|
| 167 |
+
<CardHeader className="pb-2">
|
| 168 |
+
<CardDescription>Contacted</CardDescription>
|
| 169 |
+
<CardTitle className="text-2xl">
|
| 170 |
+
{businesses.filter((b) => b.emailStatus).length}
|
| 171 |
+
</CardTitle>
|
| 172 |
+
</CardHeader>
|
| 173 |
+
</Card>
|
| 174 |
+
<Card>
|
| 175 |
+
<CardHeader className="pb-2">
|
| 176 |
+
<CardDescription>Opened</CardDescription>
|
| 177 |
+
<CardTitle className="text-2xl">
|
| 178 |
+
{businesses.filter((b) => b.emailStatus === "opened" || b.emailStatus === "clicked").length}
|
| 179 |
+
</CardTitle>
|
| 180 |
+
</CardHeader>
|
| 181 |
+
</Card>
|
| 182 |
+
</div>
|
| 183 |
+
|
| 184 |
+
{/* Filters */}
|
| 185 |
+
<Card>
|
| 186 |
+
<CardHeader>
|
| 187 |
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
| 188 |
+
<div className="flex-1">
|
| 189 |
+
<div className="relative">
|
| 190 |
+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
| 191 |
+
<Input
|
| 192 |
+
placeholder="Search businesses..."
|
| 193 |
+
value={searchQuery}
|
| 194 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 195 |
+
className="pl-10"
|
| 196 |
+
/>
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
<div className="flex gap-2">
|
| 200 |
+
<select
|
| 201 |
+
value={filterCategory}
|
| 202 |
+
onChange={(e) => setFilterCategory(e.target.value)}
|
| 203 |
+
className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm cursor-pointer"
|
| 204 |
+
>
|
| 205 |
+
<option value="all">All Categories</option>
|
| 206 |
+
<option value="Restaurant">Restaurant</option>
|
| 207 |
+
<option value="Retail">Retail</option>
|
| 208 |
+
<option value="Service">Service</option>
|
| 209 |
+
</select>
|
| 210 |
+
<select
|
| 211 |
+
value={filterStatus}
|
| 212 |
+
onChange={(e) => setFilterStatus(e.target.value)}
|
| 213 |
+
className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm cursor-pointer"
|
| 214 |
+
>
|
| 215 |
+
<option value="all">All Status</option>
|
| 216 |
+
<option value="pending">Pending</option>
|
| 217 |
+
<option value="sent">Sent</option>
|
| 218 |
+
<option value="opened">Opened</option>
|
| 219 |
+
<option value="clicked">Clicked</option>
|
| 220 |
+
</select>
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
+
</CardHeader>
|
| 224 |
+
<CardContent>
|
| 225 |
+
{loading ? (
|
| 226 |
+
<div className="text-center py-8 text-muted-foreground">
|
| 227 |
+
Loading businesses...
|
| 228 |
+
</div>
|
| 229 |
+
) : filteredBusinesses.length === 0 ? (
|
| 230 |
+
<div className="text-center py-8 text-muted-foreground">
|
| 231 |
+
No businesses found
|
| 232 |
+
</div>
|
| 233 |
+
) : (
|
| 234 |
+
<>
|
| 235 |
+
<Table>
|
| 236 |
+
<TableHeader>
|
| 237 |
+
<TableRow>
|
| 238 |
+
<TableHead>Business</TableHead>
|
| 239 |
+
<TableHead>Contact</TableHead>
|
| 240 |
+
<TableHead>Category</TableHead>
|
| 241 |
+
<TableHead>Rating</TableHead>
|
| 242 |
+
<TableHead>Status</TableHead>
|
| 243 |
+
<TableHead className="text-right">Actions</TableHead>
|
| 244 |
+
</TableRow>
|
| 245 |
+
</TableHeader>
|
| 246 |
+
<TableBody>
|
| 247 |
+
{paginatedBusinesses.map((business) => (
|
| 248 |
+
<TableRow key={business.id}>
|
| 249 |
+
<TableCell>
|
| 250 |
+
<div className="space-y-1">
|
| 251 |
+
<div className="font-medium">{business.name}</div>
|
| 252 |
+
{business.address && (
|
| 253 |
+
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
| 254 |
+
<MapPin className="h-3 w-3" />
|
| 255 |
+
{business.address}
|
| 256 |
+
</div>
|
| 257 |
+
)}
|
| 258 |
+
</div>
|
| 259 |
+
</TableCell>
|
| 260 |
+
<TableCell>
|
| 261 |
+
<div className="space-y-1 text-sm">
|
| 262 |
+
{business.email && (
|
| 263 |
+
<div className="flex items-center gap-1">
|
| 264 |
+
<Mail className="h-3 w-3" />
|
| 265 |
+
{business.email}
|
| 266 |
+
</div>
|
| 267 |
+
)}
|
| 268 |
+
{business.phone && (
|
| 269 |
+
<div className="flex items-center gap-1">
|
| 270 |
+
<Phone className="h-3 w-3" />
|
| 271 |
+
{business.phone}
|
| 272 |
+
</div>
|
| 273 |
+
)}
|
| 274 |
+
{business.website && (
|
| 275 |
+
<div className="flex items-center gap-1">
|
| 276 |
+
<Globe className="h-3 w-3" />
|
| 277 |
+
<a
|
| 278 |
+
href={business.website}
|
| 279 |
+
target="_blank"
|
| 280 |
+
rel="noopener noreferrer"
|
| 281 |
+
className="text-blue-600 hover:underline cursor-pointer"
|
| 282 |
+
>
|
| 283 |
+
Website
|
| 284 |
+
</a>
|
| 285 |
+
</div>
|
| 286 |
+
)}
|
| 287 |
+
</div>
|
| 288 |
+
</TableCell>
|
| 289 |
+
<TableCell>
|
| 290 |
+
<Badge variant="outline">{business.category}</Badge>
|
| 291 |
+
</TableCell>
|
| 292 |
+
<TableCell>
|
| 293 |
+
{business.rating && (
|
| 294 |
+
<div className="flex items-center gap-1">
|
| 295 |
+
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
|
| 296 |
+
<span className="font-medium">{business.rating}</span>
|
| 297 |
+
<span className="text-xs text-muted-foreground">
|
| 298 |
+
({business.reviewCount})
|
| 299 |
+
</span>
|
| 300 |
+
</div>
|
| 301 |
+
)}
|
| 302 |
+
</TableCell>
|
| 303 |
+
<TableCell>
|
| 304 |
+
<Badge variant={getStatusColor(business.emailStatus)}>
|
| 305 |
+
{business.emailStatus || "pending"}
|
| 306 |
+
</Badge>
|
| 307 |
+
</TableCell>
|
| 308 |
+
<TableCell className="text-right">
|
| 309 |
+
<DropdownMenu>
|
| 310 |
+
<DropdownMenuTrigger asChild>
|
| 311 |
+
<Button variant="ghost" size="icon" className="cursor-pointer">
|
| 312 |
+
<MoreVertical className="h-4 w-4" />
|
| 313 |
+
</Button>
|
| 314 |
+
</DropdownMenuTrigger>
|
| 315 |
+
<DropdownMenuContent align="end">
|
| 316 |
+
<DropdownMenuItem
|
| 317 |
+
className="cursor-pointer"
|
| 318 |
+
onClick={() => handleEdit(business)}
|
| 319 |
+
>
|
| 320 |
+
<Eye className="mr-2 h-4 w-4" />
|
| 321 |
+
Edit Details
|
| 322 |
+
</DropdownMenuItem>
|
| 323 |
+
<DropdownMenuItem className="cursor-pointer">
|
| 324 |
+
<Mail className="mr-2 h-4 w-4" />
|
| 325 |
+
Send Email
|
| 326 |
+
</DropdownMenuItem>
|
| 327 |
+
<DropdownMenuItem
|
| 328 |
+
className="text-destructive cursor-pointer"
|
| 329 |
+
onClick={() => handleDelete(business.id)}
|
| 330 |
+
>
|
| 331 |
+
<Trash2 className="mr-2 h-4 w-4" />
|
| 332 |
+
Delete
|
| 333 |
+
</DropdownMenuItem>
|
| 334 |
+
</DropdownMenuContent>
|
| 335 |
+
</DropdownMenu>
|
| 336 |
+
</TableCell>
|
| 337 |
+
</TableRow>
|
| 338 |
+
))}
|
| 339 |
+
</TableBody>
|
| 340 |
+
</Table>
|
| 341 |
+
|
| 342 |
+
{/* Pagination Controls */}
|
| 343 |
+
{totalPages > 1 && (
|
| 344 |
+
<div className="flex items-center justify-between px-4 py-4 border-t">
|
| 345 |
+
<div className="text-sm text-muted-foreground">
|
| 346 |
+
Showing {(currentPage - 1) * itemsPerPage + 1} to {Math.min(currentPage * itemsPerPage, filteredBusinesses.length)} of {filteredBusinesses.length} businesses
|
| 347 |
+
</div>
|
| 348 |
+
<div className="flex gap-2">
|
| 349 |
+
<Button
|
| 350 |
+
variant="outline"
|
| 351 |
+
size="sm"
|
| 352 |
+
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
| 353 |
+
disabled={currentPage === 1}
|
| 354 |
+
className="cursor-pointer"
|
| 355 |
+
>
|
| 356 |
+
Previous
|
| 357 |
+
</Button>
|
| 358 |
+
<div className="flex items-center gap-1">
|
| 359 |
+
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
| 360 |
+
<Button
|
| 361 |
+
key={page}
|
| 362 |
+
variant={currentPage === page ? "default" : "outline"}
|
| 363 |
+
size="sm"
|
| 364 |
+
onClick={() => setCurrentPage(page)}
|
| 365 |
+
className="cursor-pointer min-w-[40px]"
|
| 366 |
+
>
|
| 367 |
+
{page}
|
| 368 |
+
</Button>
|
| 369 |
+
))}
|
| 370 |
+
</div>
|
| 371 |
+
<Button
|
| 372 |
+
variant="outline"
|
| 373 |
+
size="sm"
|
| 374 |
+
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
| 375 |
+
disabled={currentPage === totalPages}
|
| 376 |
+
className="cursor-pointer"
|
| 377 |
+
>
|
| 378 |
+
Next
|
| 379 |
+
</Button>
|
| 380 |
+
</div>
|
| 381 |
+
</div>
|
| 382 |
+
)}
|
| 383 |
+
</>
|
| 384 |
+
)}
|
| 385 |
+
</CardContent>
|
| 386 |
+
</Card>
|
| 387 |
+
|
| 388 |
+
{/* Edit Modal */}
|
| 389 |
+
{isEditModalOpen && editingBusiness && (
|
| 390 |
+
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
| 391 |
+
<Card className="w-full max-w-md">
|
| 392 |
+
<CardHeader>
|
| 393 |
+
<CardTitle>Edit Business</CardTitle>
|
| 394 |
+
<CardDescription>Update business information</CardDescription>
|
| 395 |
+
</CardHeader>
|
| 396 |
+
<CardContent className="space-y-4">
|
| 397 |
+
<div>
|
| 398 |
+
<Label>Business Name</Label>
|
| 399 |
+
<Input
|
| 400 |
+
defaultValue={editingBusiness.name}
|
| 401 |
+
onChange={(e) => setEditingBusiness({ ...editingBusiness, name: e.target.value })}
|
| 402 |
+
/>
|
| 403 |
+
</div>
|
| 404 |
+
<div>
|
| 405 |
+
<Label>Email</Label>
|
| 406 |
+
<Input
|
| 407 |
+
defaultValue={editingBusiness.email || ""}
|
| 408 |
+
onChange={(e) => setEditingBusiness({ ...editingBusiness, email: e.target.value })}
|
| 409 |
+
/>
|
| 410 |
+
</div>
|
| 411 |
+
<div>
|
| 412 |
+
<Label>Phone</Label>
|
| 413 |
+
<Input
|
| 414 |
+
defaultValue={editingBusiness.phone || ""}
|
| 415 |
+
onChange={(e) => setEditingBusiness({ ...editingBusiness, phone: e.target.value })}
|
| 416 |
+
/>
|
| 417 |
+
</div>
|
| 418 |
+
<div className="flex gap-2 pt-4">
|
| 419 |
+
<Button
|
| 420 |
+
onClick={() => handleSaveEdit(editingBusiness)}
|
| 421 |
+
className="cursor-pointer flex-1"
|
| 422 |
+
disabled={isSaving}
|
| 423 |
+
>
|
| 424 |
+
{isSaving ? (
|
| 425 |
+
<>
|
| 426 |
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
| 427 |
+
Saving...
|
| 428 |
+
</>
|
| 429 |
+
) : (
|
| 430 |
+
"Save Changes"
|
| 431 |
+
)}
|
| 432 |
+
</Button>
|
| 433 |
+
<Button
|
| 434 |
+
variant="outline"
|
| 435 |
+
onClick={() => {
|
| 436 |
+
setIsEditModalOpen(false);
|
| 437 |
+
setEditingBusiness(null);
|
| 438 |
+
}}
|
| 439 |
+
className="cursor-pointer flex-1"
|
| 440 |
+
>
|
| 441 |
+
Cancel
|
| 442 |
+
</Button>
|
| 443 |
+
</div>
|
| 444 |
+
</CardContent>
|
| 445 |
+
</Card>
|
| 446 |
+
</div>
|
| 447 |
+
)}
|
| 448 |
+
</div>
|
| 449 |
+
);
|
| 450 |
+
}
|
app/dashboard/layout.tsx
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { auth } from "@/lib/auth";
|
| 2 |
+
import { redirect } from "next/navigation";
|
| 3 |
+
import { Sidebar } from "@/components/dashboard/sidebar";
|
| 4 |
+
import { NotificationToast } from "@/components/notification-toast";
|
| 5 |
+
import { SupportPopup } from "@/components/support-popup";
|
| 6 |
+
import { DemoNotifications } from "@/components/demo-notifications";
|
| 7 |
+
import { SidebarProvider } from "@/components/dashboard/sidebar-provider";
|
| 8 |
+
import { DashboardContent } from "@/components/dashboard/dashboard-content";
|
| 9 |
+
import { FeedbackButton } from "@/components/feedback-button";
|
| 10 |
+
|
| 11 |
+
export default async function DashboardLayout({
|
| 12 |
+
children,
|
| 13 |
+
}: {
|
| 14 |
+
children: React.ReactNode;
|
| 15 |
+
}) {
|
| 16 |
+
const session = await auth();
|
| 17 |
+
|
| 18 |
+
if (!session) {
|
| 19 |
+
redirect("/auth/signin");
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
return (
|
| 23 |
+
<SidebarProvider>
|
| 24 |
+
<div className="flex h-screen overflow-hidden">
|
| 25 |
+
<Sidebar />
|
| 26 |
+
<DashboardContent>{children}</DashboardContent>
|
| 27 |
+
<NotificationToast />
|
| 28 |
+
<SupportPopup />
|
| 29 |
+
<DemoNotifications />
|
| 30 |
+
<FeedbackButton />
|
| 31 |
+
</div>
|
| 32 |
+
</SidebarProvider>
|
| 33 |
+
);
|
| 34 |
+
}
|
app/dashboard/page.tsx
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, useCallback } from "react";
|
| 4 |
+
import { StatCard } from "@/components/dashboard/stat-card";
|
| 5 |
+
import { BusinessTable } from "@/components/dashboard/business-table";
|
| 6 |
+
import { BusinessDetailModal } from "@/components/dashboard/business-detail-modal";
|
| 7 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 8 |
+
import { Button } from "@/components/ui/button";
|
| 9 |
+
import { Input } from "@/components/ui/input";
|
| 10 |
+
import { Label } from "@/components/ui/label";
|
| 11 |
+
import { Business } from "@/types";
|
| 12 |
+
import {
|
| 13 |
+
Users,
|
| 14 |
+
Mail,
|
| 15 |
+
CheckCircle2,
|
| 16 |
+
TrendingUp,
|
| 17 |
+
Play,
|
| 18 |
+
Loader2,
|
| 19 |
+
Sparkles,
|
| 20 |
+
} from "lucide-react";
|
| 21 |
+
import { BusinessTypeSelect } from "@/components/business-type-select";
|
| 22 |
+
import { KeywordInput } from "@/components/keyword-input";
|
| 23 |
+
import { ActiveTaskCard } from "@/components/active-task-card";
|
| 24 |
+
import {
|
| 25 |
+
LineChart,
|
| 26 |
+
Line,
|
| 27 |
+
XAxis,
|
| 28 |
+
YAxis,
|
| 29 |
+
CartesianGrid,
|
| 30 |
+
Tooltip,
|
| 31 |
+
ResponsiveContainer,
|
| 32 |
+
} from "recharts";
|
| 33 |
+
import { useApi } from "@/hooks/use-api";
|
| 34 |
+
import { toast } from "sonner";
|
| 35 |
+
import { allLocations } from "@/lib/locations";
|
| 36 |
+
|
| 37 |
+
interface DashboardStats {
|
| 38 |
+
totalBusinesses: number;
|
| 39 |
+
totalTemplates: number;
|
| 40 |
+
totalWorkflows: number;
|
| 41 |
+
emailsSent: number;
|
| 42 |
+
emailsOpened: number;
|
| 43 |
+
emailsClicked: number;
|
| 44 |
+
openRate: number;
|
| 45 |
+
clickRate: number;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
interface ChartDataPoint {
|
| 49 |
+
name: string;
|
| 50 |
+
sent: number;
|
| 51 |
+
opened: number;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
interface ActiveTask {
|
| 55 |
+
jobId: string;
|
| 56 |
+
workflowName: string;
|
| 57 |
+
status: string;
|
| 58 |
+
businessesFound: number;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
export default function DashboardPage() {
|
| 62 |
+
const [businesses, setBusinesses] = useState<Business[]>([]);
|
| 63 |
+
const [selectedBusiness, setSelectedBusiness] = useState<Business | null>(null);
|
| 64 |
+
const [isModalOpen, setIsModalOpen] = useState(false);
|
| 65 |
+
const [keywords, setKeywords] = useState<string[]>([]);
|
| 66 |
+
const [isScrapingStarted, setIsScrapingStarted] = useState(false);
|
| 67 |
+
const [isGeneratingKeywords, setIsGeneratingKeywords] = useState(false);
|
| 68 |
+
const [activeTask, setActiveTask] = useState<ActiveTask | null>(null);
|
| 69 |
+
const [stats, setStats] = useState<DashboardStats | null>(null);
|
| 70 |
+
const [chartData, setChartData] = useState<ChartDataPoint[]>([]);
|
| 71 |
+
const [businessType, setBusinessType] = useState<string>("");
|
| 72 |
+
const [location, setLocation] = useState<string>("");
|
| 73 |
+
const [scrapingSources, setScrapingSources] = useState<string[]>(["google-maps", "google-search"]);
|
| 74 |
+
|
| 75 |
+
const handleViewDetails = (business: Business) => {
|
| 76 |
+
setSelectedBusiness(business);
|
| 77 |
+
setIsModalOpen(true);
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
// API Hooks
|
| 81 |
+
const { post: startScraping } = useApi();
|
| 82 |
+
const { get: getBusinessesApi } = useApi<{ businesses: Business[] }>();
|
| 83 |
+
// Use loadingStats from hook instead of local state
|
| 84 |
+
const { get: getStatsApi, loading: loadingStats } = useApi<{ stats: DashboardStats; chartData: ChartDataPoint[] }>();
|
| 85 |
+
const { post: generateKeywords } = useApi<{ keywords: string[] }>();
|
| 86 |
+
const { get: getActiveTasks } = useApi<{ tasks: Array<{ id: string; type: string; status: string; workflowName?: string; businessesFound?: number }> }>();
|
| 87 |
+
|
| 88 |
+
const fetchDashboardStats = useCallback(async () => {
|
| 89 |
+
const data = await getStatsApi("/api/dashboard/stats");
|
| 90 |
+
if (data) {
|
| 91 |
+
setStats(data.stats);
|
| 92 |
+
setChartData(data.chartData || []);
|
| 93 |
+
}
|
| 94 |
+
}, [getStatsApi]);
|
| 95 |
+
|
| 96 |
+
const fetchActiveTask = useCallback(async () => {
|
| 97 |
+
const data = await getActiveTasks("/api/tasks");
|
| 98 |
+
if (data?.tasks) {
|
| 99 |
+
// Find first active scraping task
|
| 100 |
+
const activeJob = data.tasks.find(
|
| 101 |
+
(task: { type: string; status: string }) =>
|
| 102 |
+
task.type === "scraping" &&
|
| 103 |
+
(task.status === "processing" || task.status === "paused")
|
| 104 |
+
);
|
| 105 |
+
|
| 106 |
+
if (activeJob) {
|
| 107 |
+
setActiveTask({
|
| 108 |
+
jobId: activeJob.id,
|
| 109 |
+
workflowName: activeJob.workflowName || "Scraping Job",
|
| 110 |
+
status: activeJob.status,
|
| 111 |
+
businessesFound: activeJob.businessesFound || 0,
|
| 112 |
+
});
|
| 113 |
+
} else if (activeTask) {
|
| 114 |
+
// Clear if task completed
|
| 115 |
+
setActiveTask(null);
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
}, [getActiveTasks, activeTask]);
|
| 119 |
+
|
| 120 |
+
useEffect(() => {
|
| 121 |
+
const initData = async () => {
|
| 122 |
+
// Fetch businesses
|
| 123 |
+
const businessData = await getBusinessesApi("/api/businesses");
|
| 124 |
+
if (businessData) {
|
| 125 |
+
setBusinesses(businessData.businesses || []);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
// Fetch stats
|
| 129 |
+
await fetchDashboardStats();
|
| 130 |
+
|
| 131 |
+
// Fetch active task
|
| 132 |
+
await fetchActiveTask();
|
| 133 |
+
};
|
| 134 |
+
|
| 135 |
+
initData();
|
| 136 |
+
|
| 137 |
+
// Auto-refresh stats every 30 seconds
|
| 138 |
+
const statsInterval = setInterval(fetchDashboardStats, 30000);
|
| 139 |
+
|
| 140 |
+
// Auto-refresh active task every 5 seconds
|
| 141 |
+
const taskInterval = setInterval(fetchActiveTask, 5000);
|
| 142 |
+
|
| 143 |
+
return () => {
|
| 144 |
+
clearInterval(statsInterval);
|
| 145 |
+
clearInterval(taskInterval);
|
| 146 |
+
};
|
| 147 |
+
}, [getBusinessesApi, fetchDashboardStats, fetchActiveTask]);
|
| 148 |
+
|
| 149 |
+
const handleSendEmail = async (business: Business) => {
|
| 150 |
+
// Send email API call
|
| 151 |
+
console.log("Sending email to:", business);
|
| 152 |
+
};
|
| 153 |
+
|
| 154 |
+
const handleStartScraping = async () => {
|
| 155 |
+
if (!businessType || !location) return;
|
| 156 |
+
|
| 157 |
+
setIsScrapingStarted(true);
|
| 158 |
+
try {
|
| 159 |
+
const result = await startScraping("/api/scraping/start", {
|
| 160 |
+
targetBusinessType: businessType,
|
| 161 |
+
keywords,
|
| 162 |
+
location,
|
| 163 |
+
sources: scrapingSources, // Pass selected sources
|
| 164 |
+
});
|
| 165 |
+
|
| 166 |
+
if (result) {
|
| 167 |
+
toast.success("Scraping job started!", {
|
| 168 |
+
description: "Check the Tasks page to monitor progress.",
|
| 169 |
+
});
|
| 170 |
+
|
| 171 |
+
// Refresh businesses list after a short delay
|
| 172 |
+
setTimeout(async () => {
|
| 173 |
+
const businessData = await getBusinessesApi("/api/businesses");
|
| 174 |
+
if (businessData) {
|
| 175 |
+
setBusinesses(businessData.businesses || []);
|
| 176 |
+
}
|
| 177 |
+
}, 2000);
|
| 178 |
+
}
|
| 179 |
+
} catch (error) {
|
| 180 |
+
toast.error("Failed to start scraping", {
|
| 181 |
+
description: "Please try again or check your connection.",
|
| 182 |
+
});
|
| 183 |
+
console.error("Error starting scraping:", error);
|
| 184 |
+
} finally {
|
| 185 |
+
setIsScrapingStarted(false);
|
| 186 |
+
}
|
| 187 |
+
};
|
| 188 |
+
|
| 189 |
+
const handleGenerateKeywords = async () => {
|
| 190 |
+
if (!businessType) {
|
| 191 |
+
toast.error("Please select a business type first");
|
| 192 |
+
return;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
setIsGeneratingKeywords(true);
|
| 196 |
+
try {
|
| 197 |
+
const result = await generateKeywords("/api/keywords/generate", {
|
| 198 |
+
businessType,
|
| 199 |
+
});
|
| 200 |
+
|
| 201 |
+
if (result?.keywords) {
|
| 202 |
+
// Merge with existing keywords, avoiding duplicates
|
| 203 |
+
const newKeywords = result.keywords.filter(
|
| 204 |
+
(kw: string) => !keywords.includes(kw)
|
| 205 |
+
);
|
| 206 |
+
setKeywords([...keywords, ...newKeywords]);
|
| 207 |
+
toast.success(`Generated ${newKeywords.length} keywords!`);
|
| 208 |
+
}
|
| 209 |
+
} catch (error) {
|
| 210 |
+
toast.error("Failed to generate keywords");
|
| 211 |
+
console.error("Error generating keywords:", error);
|
| 212 |
+
} finally {
|
| 213 |
+
setIsGeneratingKeywords(false);
|
| 214 |
+
}
|
| 215 |
+
};
|
| 216 |
+
|
| 217 |
+
return (
|
| 218 |
+
<div className="space-y-6">
|
| 219 |
+
{/* Scraping Form */}
|
| 220 |
+
<Card>
|
| 221 |
+
<CardHeader>
|
| 222 |
+
<CardTitle>Start New Search</CardTitle>
|
| 223 |
+
<CardDescription>
|
| 224 |
+
Find local businesses and automatically reach out to them
|
| 225 |
+
</CardDescription>
|
| 226 |
+
</CardHeader>
|
| 227 |
+
<CardContent className="space-y-4">
|
| 228 |
+
<div className="grid gap-4 md:grid-cols-2">
|
| 229 |
+
<div className="space-y-2">
|
| 230 |
+
<Label htmlFor="businessType">Business Type</Label>
|
| 231 |
+
<BusinessTypeSelect
|
| 232 |
+
value={businessType}
|
| 233 |
+
onValueChange={setBusinessType}
|
| 234 |
+
/>
|
| 235 |
+
</div>
|
| 236 |
+
<div className="space-y-2">
|
| 237 |
+
<Label htmlFor="location">Location</Label>
|
| 238 |
+
<Input
|
| 239 |
+
id="location"
|
| 240 |
+
list="locations"
|
| 241 |
+
placeholder="e.g., New York, NY"
|
| 242 |
+
value={location}
|
| 243 |
+
onChange={(e) => setLocation(e.target.value)}
|
| 244 |
+
/>
|
| 245 |
+
<datalist id="locations">
|
| 246 |
+
{allLocations.map((loc) => (
|
| 247 |
+
<option key={loc} value={loc} />
|
| 248 |
+
))}
|
| 249 |
+
</datalist>
|
| 250 |
+
</div>
|
| 251 |
+
</div>
|
| 252 |
+
<div className="space-y-2">
|
| 253 |
+
<div className="flex items-center justify-between">
|
| 254 |
+
<Label htmlFor="keywords">Keywords (Optional)</Label>
|
| 255 |
+
<Button
|
| 256 |
+
type="button"
|
| 257 |
+
variant="outline"
|
| 258 |
+
size="icon"
|
| 259 |
+
onClick={handleGenerateKeywords}
|
| 260 |
+
disabled={!businessType || isGeneratingKeywords}
|
| 261 |
+
className="gap-2"
|
| 262 |
+
>
|
| 263 |
+
{isGeneratingKeywords ? (
|
| 264 |
+
<>
|
| 265 |
+
<Loader2 className="h-4 w-4 animate-spin" />
|
| 266 |
+
</>
|
| 267 |
+
) : (
|
| 268 |
+
<>
|
| 269 |
+
<Sparkles className="h-4 w-4" />
|
| 270 |
+
</>
|
| 271 |
+
)}
|
| 272 |
+
</Button>
|
| 273 |
+
</div>
|
| 274 |
+
<KeywordInput
|
| 275 |
+
businessTypeId={businessType}
|
| 276 |
+
value={keywords}
|
| 277 |
+
onChange={setKeywords}
|
| 278 |
+
placeholder="Add relevant keywords..."
|
| 279 |
+
/>
|
| 280 |
+
<p className="text-sm text-muted-foreground">
|
| 281 |
+
Press Enter to add custom keywords, click suggestions, or use AI to generate
|
| 282 |
+
</p>
|
| 283 |
+
</div>
|
| 284 |
+
|
| 285 |
+
{/* Scraping Sources Selection */}
|
| 286 |
+
<div className="space-y-2">
|
| 287 |
+
<Label>Scraping Sources</Label>
|
| 288 |
+
<div className="flex flex-wrap gap-3">
|
| 289 |
+
<label className="flex items-center gap-2 cursor-pointer">
|
| 290 |
+
<input
|
| 291 |
+
type="checkbox"
|
| 292 |
+
checked={scrapingSources.includes("google-maps")}
|
| 293 |
+
onChange={(e) => {
|
| 294 |
+
if (e.target.checked) {
|
| 295 |
+
setScrapingSources([...scrapingSources, "google-maps"]);
|
| 296 |
+
} else {
|
| 297 |
+
setScrapingSources(scrapingSources.filter((s: string) => s !== "google-maps"));
|
| 298 |
+
}
|
| 299 |
+
}}
|
| 300 |
+
className="w-4 h-4 rounded border-gray-300"
|
| 301 |
+
/>
|
| 302 |
+
<span className="text-sm">📍 Google Maps</span>
|
| 303 |
+
</label>
|
| 304 |
+
<label className="flex items-center gap-2 cursor-pointer">
|
| 305 |
+
<input
|
| 306 |
+
type="checkbox"
|
| 307 |
+
checked={scrapingSources.includes("google-search")}
|
| 308 |
+
onChange={(e) => {
|
| 309 |
+
if (e.target.checked) {
|
| 310 |
+
setScrapingSources([...scrapingSources, "google-search"]);
|
| 311 |
+
} else {
|
| 312 |
+
setScrapingSources(scrapingSources.filter((s: string) => s !== "google-search"));
|
| 313 |
+
}
|
| 314 |
+
}}
|
| 315 |
+
className="w-4 h-4 rounded border-gray-300"
|
| 316 |
+
/>
|
| 317 |
+
<span className="text-sm">🔍 Google Search</span>
|
| 318 |
+
</label>
|
| 319 |
+
</div>
|
| 320 |
+
<p className="text-xs text-muted-foreground">
|
| 321 |
+
Select sources to scrape from. More sources = more comprehensive results.
|
| 322 |
+
</p>
|
| 323 |
+
</div>
|
| 324 |
+
<Button
|
| 325 |
+
onClick={handleStartScraping}
|
| 326 |
+
disabled={isScrapingStarted || !businessType || !location}
|
| 327 |
+
className="w-full cursor-pointer"
|
| 328 |
+
>
|
| 329 |
+
{isScrapingStarted ? (
|
| 330 |
+
<>
|
| 331 |
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
| 332 |
+
Scraping...
|
| 333 |
+
</>
|
| 334 |
+
) : (
|
| 335 |
+
<>
|
| 336 |
+
<Play className="mr-2 h-4 w-4" />
|
| 337 |
+
Start Scraping
|
| 338 |
+
</>
|
| 339 |
+
)}
|
| 340 |
+
</Button>
|
| 341 |
+
|
| 342 |
+
{/* Active Task Card - Shown here when scraping */}
|
| 343 |
+
{activeTask && (
|
| 344 |
+
<ActiveTaskCard
|
| 345 |
+
jobId={activeTask.jobId}
|
| 346 |
+
workflowName={activeTask.workflowName}
|
| 347 |
+
status={activeTask.status}
|
| 348 |
+
businessesFound={activeTask.businessesFound}
|
| 349 |
+
onDismiss={() => setActiveTask(null)}
|
| 350 |
+
onStatusChange={fetchActiveTask}
|
| 351 |
+
/>
|
| 352 |
+
)}
|
| 353 |
+
</CardContent>
|
| 354 |
+
</Card>
|
| 355 |
+
|
| 356 |
+
{/* Stats */}
|
| 357 |
+
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
| 358 |
+
{loadingStats || !stats ? (
|
| 359 |
+
Array.from({ length: 4 }).map((_, i) => (
|
| 360 |
+
<Card key={i}>
|
| 361 |
+
<CardHeader>
|
| 362 |
+
<div className="h-4 w-24 bg-muted animate-pulse rounded"></div>
|
| 363 |
+
</CardHeader>
|
| 364 |
+
<CardContent>
|
| 365 |
+
<div className="h-8 w-16 bg-muted animate-pulse rounded"></div>
|
| 366 |
+
</CardContent>
|
| 367 |
+
</Card>
|
| 368 |
+
))
|
| 369 |
+
) : (
|
| 370 |
+
<>
|
| 371 |
+
<StatCard
|
| 372 |
+
title="Total Businesses"
|
| 373 |
+
value={stats.totalBusinesses}
|
| 374 |
+
icon={Users}
|
| 375 |
+
/>
|
| 376 |
+
<StatCard
|
| 377 |
+
title="Emails Sent"
|
| 378 |
+
value={stats.emailsSent}
|
| 379 |
+
icon={Mail}
|
| 380 |
+
/>
|
| 381 |
+
<StatCard
|
| 382 |
+
title="Emails Opened"
|
| 383 |
+
value={stats.emailsOpened}
|
| 384 |
+
icon={CheckCircle2}
|
| 385 |
+
/>
|
| 386 |
+
<StatCard
|
| 387 |
+
title="Open Rate"
|
| 388 |
+
value={`${stats.openRate}% `}
|
| 389 |
+
icon={TrendingUp}
|
| 390 |
+
/>
|
| 391 |
+
</>
|
| 392 |
+
)}
|
| 393 |
+
</div>
|
| 394 |
+
|
| 395 |
+
{/* Chart */}
|
| 396 |
+
<Card>
|
| 397 |
+
<CardHeader>
|
| 398 |
+
<CardTitle>Email Performance (Last 7 Days)</CardTitle>
|
| 399 |
+
</CardHeader>
|
| 400 |
+
<CardContent>
|
| 401 |
+
{loadingStats || chartData.length === 0 ? (
|
| 402 |
+
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
| 403 |
+
{loadingStats ? <Loader2 className="animate-spin h-6 w-6" /> : "No data available"}
|
| 404 |
+
</div>
|
| 405 |
+
) : (
|
| 406 |
+
<ResponsiveContainer width="100%" height={300}>
|
| 407 |
+
<LineChart data={chartData}>
|
| 408 |
+
<CartesianGrid strokeDasharray="3 3" />
|
| 409 |
+
<XAxis dataKey="name" />
|
| 410 |
+
<YAxis />
|
| 411 |
+
<Tooltip />
|
| 412 |
+
<Line
|
| 413 |
+
type="monotone"
|
| 414 |
+
dataKey="sent"
|
| 415 |
+
stroke="#3b82f6"
|
| 416 |
+
strokeWidth={2}
|
| 417 |
+
/>
|
| 418 |
+
<Line
|
| 419 |
+
type="monotone"
|
| 420 |
+
dataKey="opened"
|
| 421 |
+
stroke="#10b981"
|
| 422 |
+
strokeWidth={2}
|
| 423 |
+
/>
|
| 424 |
+
</LineChart>
|
| 425 |
+
</ResponsiveContainer>
|
| 426 |
+
)}
|
| 427 |
+
</CardContent>
|
| 428 |
+
</Card>
|
| 429 |
+
|
| 430 |
+
{/* Business Table */}
|
| 431 |
+
<Card>
|
| 432 |
+
<CardHeader>
|
| 433 |
+
<CardTitle>Businesses ({businesses.length})</CardTitle>
|
| 434 |
+
</CardHeader>
|
| 435 |
+
<CardContent>
|
| 436 |
+
<BusinessTable
|
| 437 |
+
businesses={businesses}
|
| 438 |
+
onViewDetails={handleViewDetails}
|
| 439 |
+
onSendEmail={handleSendEmail}
|
| 440 |
+
/>
|
| 441 |
+
</CardContent>
|
| 442 |
+
</Card>
|
| 443 |
+
|
| 444 |
+
{/* Detail Modal */}
|
| 445 |
+
<BusinessDetailModal
|
| 446 |
+
business={selectedBusiness}
|
| 447 |
+
isOpen={isModalOpen}
|
| 448 |
+
onClose={() => setIsModalOpen(false)}
|
| 449 |
+
onSendEmail={handleSendEmail}
|
| 450 |
+
/>
|
| 451 |
+
</div>
|
| 452 |
+
);
|
| 453 |
+
}
|
app/dashboard/settings/page.tsx
ADDED
|
@@ -0,0 +1,735 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
import { useState, useEffect as reactUseEffect } from "react";
|
| 3 |
+
import { useSession } from "next-auth/react";
|
| 4 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 5 |
+
import { Input } from "@/components/ui/input";
|
| 6 |
+
import { Button } from "@/components/ui/button";
|
| 7 |
+
import { Label } from "@/components/ui/label";
|
| 8 |
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
| 9 |
+
import { Switch } from "@/components/ui/switch";
|
| 10 |
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
| 11 |
+
import { User, Key, Bell, Copy, Check, Loader2 } from "lucide-react";
|
| 12 |
+
import { Badge } from "@/components/ui/badge";
|
| 13 |
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
| 14 |
+
import { Plus } from "lucide-react";
|
| 15 |
+
import { useToast } from "@/hooks/use-toast";
|
| 16 |
+
import { useTheme } from "@/components/theme-provider";
|
| 17 |
+
import { signOut } from "next-auth/react";
|
| 18 |
+
import { useApi } from "@/hooks/use-api";
|
| 19 |
+
import { UserProfile } from "@/types";
|
| 20 |
+
|
| 21 |
+
import {
|
| 22 |
+
Dialog,
|
| 23 |
+
DialogContent,
|
| 24 |
+
DialogDescription,
|
| 25 |
+
DialogHeader,
|
| 26 |
+
DialogTitle,
|
| 27 |
+
DialogFooter,
|
| 28 |
+
} from "@/components/ui/dialog";
|
| 29 |
+
import { Moon, Sun, LogOut, Trash2, AlertTriangle, Palette } from "lucide-react";
|
| 30 |
+
|
| 31 |
+
interface StatusResponse {
|
| 32 |
+
database: boolean;
|
| 33 |
+
redis: boolean;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export default function SettingsPage() {
|
| 37 |
+
const { data: session, update } = useSession();
|
| 38 |
+
const { toast } = useToast();
|
| 39 |
+
const { theme, toggleTheme } = useTheme();
|
| 40 |
+
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
| 41 |
+
const [copied, setCopied] = useState(false);
|
| 42 |
+
const [isSavingNotifications, setIsSavingNotifications] = useState(false);
|
| 43 |
+
|
| 44 |
+
// API Hooks
|
| 45 |
+
const { get: getSettings, patch: patchSettings, loading: settingsLoading } = useApi<{ user: UserProfile & { isGeminiKeySet: boolean } }>();
|
| 46 |
+
const { get: getStatus, loading: statusLoading } = useApi<StatusResponse>();
|
| 47 |
+
const { del: deleteUserFn, loading: deletingUser } = useApi<void>();
|
| 48 |
+
|
| 49 |
+
// API Key State
|
| 50 |
+
const [geminiApiKey, setGeminiApiKey] = useState("");
|
| 51 |
+
const [isGeminiKeySet, setIsGeminiKeySet] = useState(false);
|
| 52 |
+
|
| 53 |
+
// Connection Status State
|
| 54 |
+
const [connectionStatus, setConnectionStatus] = useState({ database: false, redis: false });
|
| 55 |
+
|
| 56 |
+
const [profileData, setProfileData] = useState({
|
| 57 |
+
name: session?.user?.name || "",
|
| 58 |
+
phone: "",
|
| 59 |
+
jobTitle: "",
|
| 60 |
+
company: "",
|
| 61 |
+
website: "",
|
| 62 |
+
timezone: "IST (GMT+5:30)",
|
| 63 |
+
});
|
| 64 |
+
const [customVariables, setCustomVariables] = useState<{ key: string; value: string }[]>([]);
|
| 65 |
+
const [settings, setSettings] = useState({
|
| 66 |
+
emailNotifications: true,
|
| 67 |
+
campaignAlerts: true,
|
| 68 |
+
weeklyReports: false,
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
// Fetch initial data
|
| 72 |
+
reactUseEffect(() => {
|
| 73 |
+
const fetchData = async () => {
|
| 74 |
+
// Fetch User Settings
|
| 75 |
+
const settingsData = await getSettings("/api/settings");
|
| 76 |
+
if (settingsData?.user) {
|
| 77 |
+
setProfileData(prev => ({
|
| 78 |
+
...prev,
|
| 79 |
+
name: settingsData.user.name || prev.name,
|
| 80 |
+
phone: settingsData.user.phone || "",
|
| 81 |
+
jobTitle: settingsData.user.jobTitle || "",
|
| 82 |
+
company: settingsData.user.company || "",
|
| 83 |
+
website: settingsData.user.website || ""
|
| 84 |
+
}));
|
| 85 |
+
|
| 86 |
+
if (settingsData.user.customVariables) {
|
| 87 |
+
const vars = Object.entries(settingsData.user.customVariables).map(([key, value]) => ({ key, value: String(value) }));
|
| 88 |
+
setCustomVariables(vars);
|
| 89 |
+
}
|
| 90 |
+
setIsGeminiKeySet(settingsData.user.isGeminiKeySet);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// Fetch Connection Status
|
| 94 |
+
const statusData = await getStatus("/api/settings/status");
|
| 95 |
+
if (statusData) {
|
| 96 |
+
setConnectionStatus(statusData);
|
| 97 |
+
}
|
| 98 |
+
};
|
| 99 |
+
fetchData();
|
| 100 |
+
}, [getSettings, getStatus]);
|
| 101 |
+
|
| 102 |
+
const handleSaveApiKey = async () => {
|
| 103 |
+
if (!geminiApiKey) return;
|
| 104 |
+
|
| 105 |
+
const result = await patchSettings("/api/settings", { geminiApiKey });
|
| 106 |
+
|
| 107 |
+
if (result) {
|
| 108 |
+
setIsGeminiKeySet(true);
|
| 109 |
+
setGeminiApiKey(""); // Clear input on success
|
| 110 |
+
toast({
|
| 111 |
+
title: "API Key Updated",
|
| 112 |
+
description: "Your Gemini API key has been saved securely.",
|
| 113 |
+
});
|
| 114 |
+
} else {
|
| 115 |
+
toast({
|
| 116 |
+
title: "Error",
|
| 117 |
+
description: "Failed to update API key.",
|
| 118 |
+
variant: "destructive",
|
| 119 |
+
});
|
| 120 |
+
}
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
const copyToClipboard = (text: string) => {
|
| 124 |
+
navigator.clipboard.writeText(text);
|
| 125 |
+
setCopied(true);
|
| 126 |
+
setTimeout(() => setCopied(false), 2000);
|
| 127 |
+
};
|
| 128 |
+
|
| 129 |
+
const handleSaveProfile = async () => {
|
| 130 |
+
const result = await patchSettings("/api/settings", {
|
| 131 |
+
name: profileData.name,
|
| 132 |
+
phone: profileData.phone,
|
| 133 |
+
jobTitle: profileData.jobTitle,
|
| 134 |
+
company: profileData.company,
|
| 135 |
+
website: profileData.website,
|
| 136 |
+
customVariables: customVariables.reduce((acc, curr) => ({ ...acc, [curr.key]: curr.value }), {})
|
| 137 |
+
});
|
| 138 |
+
|
| 139 |
+
if (result) {
|
| 140 |
+
await update({ name: profileData.name });
|
| 141 |
+
|
| 142 |
+
toast({
|
| 143 |
+
title: "Profile updated",
|
| 144 |
+
description: "Your profile information has been saved successfully.",
|
| 145 |
+
});
|
| 146 |
+
} else {
|
| 147 |
+
toast({
|
| 148 |
+
title: "Error",
|
| 149 |
+
description: "Failed to save profile. Please try again.",
|
| 150 |
+
variant: "destructive",
|
| 151 |
+
});
|
| 152 |
+
}
|
| 153 |
+
};
|
| 154 |
+
|
| 155 |
+
const handleSaveNotifications = async () => {
|
| 156 |
+
setIsSavingNotifications(true);
|
| 157 |
+
// In a real app, this would save to a user preferences table
|
| 158 |
+
// For now, we'll just show success after a delay
|
| 159 |
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
| 160 |
+
|
| 161 |
+
toast({
|
| 162 |
+
title: "Preferences saved",
|
| 163 |
+
description: "Your notification preferences have been updated.",
|
| 164 |
+
});
|
| 165 |
+
setIsSavingNotifications(false);
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
+
const handleSignOut = async () => {
|
| 169 |
+
await signOut({ callbackUrl: "/" });
|
| 170 |
+
};
|
| 171 |
+
|
| 172 |
+
const handleDeleteAccount = async () => {
|
| 173 |
+
const result = await deleteUserFn("/api/user/delete");
|
| 174 |
+
|
| 175 |
+
if (result !== null) { // Expecting success even if void/null
|
| 176 |
+
toast({
|
| 177 |
+
title: "Account deleted",
|
| 178 |
+
description: "Your account has been permanently deleted.",
|
| 179 |
+
});
|
| 180 |
+
|
| 181 |
+
await signOut({ callbackUrl: "/" });
|
| 182 |
+
} else {
|
| 183 |
+
toast({
|
| 184 |
+
title: "Error",
|
| 185 |
+
description: "Failed to delete account. Please try again.",
|
| 186 |
+
variant: "destructive",
|
| 187 |
+
});
|
| 188 |
+
setIsDeleteModalOpen(false);
|
| 189 |
+
}
|
| 190 |
+
};
|
| 191 |
+
|
| 192 |
+
return (
|
| 193 |
+
<div className="space-y-6">
|
| 194 |
+
<div>
|
| 195 |
+
<h1 className="text-3xl font-bold">Settings</h1>
|
| 196 |
+
<p className="text-muted-foreground">
|
| 197 |
+
Manage your account settings and preferences
|
| 198 |
+
</p>
|
| 199 |
+
</div>
|
| 200 |
+
|
| 201 |
+
<Tabs defaultValue="profile" className="space-y-4">
|
| 202 |
+
<TabsList>
|
| 203 |
+
<TabsTrigger value="profile" className="cursor-pointer">
|
| 204 |
+
<User className="mr-2 h-4 w-4" />
|
| 205 |
+
Profile
|
| 206 |
+
</TabsTrigger>
|
| 207 |
+
<TabsTrigger value="api" className="cursor-pointer">
|
| 208 |
+
<Key className="mr-2 h-4 w-4" />
|
| 209 |
+
API Keys
|
| 210 |
+
</TabsTrigger>
|
| 211 |
+
<TabsTrigger value="notifications" className="cursor-pointer">
|
| 212 |
+
<Bell className="mr-2 h-4 w-4" />
|
| 213 |
+
Notifications
|
| 214 |
+
</TabsTrigger>
|
| 215 |
+
<TabsTrigger value="appearance" className="cursor-pointer">
|
| 216 |
+
<Palette className="mr-2 h-4 w-4" />
|
| 217 |
+
Appearance
|
| 218 |
+
</TabsTrigger>
|
| 219 |
+
</TabsList>
|
| 220 |
+
|
| 221 |
+
{/* Profile Tab */}
|
| 222 |
+
<TabsContent value="profile" className="space-y-4">
|
| 223 |
+
<Card>
|
| 224 |
+
<CardHeader>
|
| 225 |
+
<CardTitle>Profile Information</CardTitle>
|
| 226 |
+
<CardDescription>
|
| 227 |
+
Update your personal information and preferences
|
| 228 |
+
</CardDescription>
|
| 229 |
+
</CardHeader>
|
| 230 |
+
<CardContent className="space-y-4">
|
| 231 |
+
<div className="flex items-center gap-4">
|
| 232 |
+
<Avatar className="h-20 w-20">
|
| 233 |
+
<AvatarImage src={session?.user?.image || ""} alt={session?.user?.name || ""} />
|
| 234 |
+
<AvatarFallback className="text-2xl font-bold bg-linear-to-r from-blue-600 to-purple-600 text-white">
|
| 235 |
+
{session?.user?.name?.charAt(0) || "U"}
|
| 236 |
+
</AvatarFallback>
|
| 237 |
+
</Avatar>
|
| 238 |
+
<div>
|
| 239 |
+
<h3 className="font-semibold">{session?.user?.name || "User"}</h3>
|
| 240 |
+
<p className="text-sm text-muted-foreground">{session?.user?.email}</p>
|
| 241 |
+
</div>
|
| 242 |
+
</div>
|
| 243 |
+
|
| 244 |
+
<div className="grid gap-4">
|
| 245 |
+
<div className="space-y-2">
|
| 246 |
+
<Label htmlFor="name">Full Name</Label>
|
| 247 |
+
<Input
|
| 248 |
+
id="name"
|
| 249 |
+
value={profileData.name}
|
| 250 |
+
onChange={(e) => setProfileData({ ...profileData, name: e.target.value })}
|
| 251 |
+
placeholder="Enter your name"
|
| 252 |
+
/>
|
| 253 |
+
</div>
|
| 254 |
+
|
| 255 |
+
<div className="grid grid-cols-2 gap-4">
|
| 256 |
+
<div className="space-y-2">
|
| 257 |
+
<Label htmlFor="phone">Phone Number</Label>
|
| 258 |
+
<Input
|
| 259 |
+
id="phone"
|
| 260 |
+
value={profileData.phone}
|
| 261 |
+
onChange={(e) => setProfileData({ ...profileData, phone: e.target.value })}
|
| 262 |
+
placeholder="+1 (555) 000-0000"
|
| 263 |
+
/>
|
| 264 |
+
</div>
|
| 265 |
+
<div className="space-y-2">
|
| 266 |
+
<Label htmlFor="website">Website</Label>
|
| 267 |
+
<Input
|
| 268 |
+
id="website"
|
| 269 |
+
value={profileData.website}
|
| 270 |
+
onChange={(e) => setProfileData({ ...profileData, website: e.target.value })}
|
| 271 |
+
placeholder="https://example.com"
|
| 272 |
+
/>
|
| 273 |
+
</div>
|
| 274 |
+
</div>
|
| 275 |
+
|
| 276 |
+
<div className="grid grid-cols-2 gap-4">
|
| 277 |
+
<div className="space-y-2">
|
| 278 |
+
<Label htmlFor="company">Company</Label>
|
| 279 |
+
<Input
|
| 280 |
+
id="company"
|
| 281 |
+
value={profileData.company}
|
| 282 |
+
onChange={(e) => setProfileData({ ...profileData, company: e.target.value })}
|
| 283 |
+
placeholder="Acme Inc."
|
| 284 |
+
/>
|
| 285 |
+
</div>
|
| 286 |
+
<div className="space-y-2">
|
| 287 |
+
<Label htmlFor="jobTitle">Job Title</Label>
|
| 288 |
+
<Input
|
| 289 |
+
id="jobTitle"
|
| 290 |
+
value={profileData.jobTitle}
|
| 291 |
+
onChange={(e) => setProfileData({ ...profileData, jobTitle: e.target.value })}
|
| 292 |
+
placeholder="CEO"
|
| 293 |
+
/>
|
| 294 |
+
</div>
|
| 295 |
+
</div>
|
| 296 |
+
|
| 297 |
+
<div className="space-y-2">
|
| 298 |
+
<div className="flex items-center justify-between">
|
| 299 |
+
<Label>Custom Variables</Label>
|
| 300 |
+
<Button
|
| 301 |
+
variant="outline"
|
| 302 |
+
size="sm"
|
| 303 |
+
onClick={() => setCustomVariables([...customVariables, { key: "", value: "" }])}
|
| 304 |
+
className="cursor-pointer"
|
| 305 |
+
>
|
| 306 |
+
<Plus className="h-4 w-4 mr-2" />
|
| 307 |
+
Add Variable
|
| 308 |
+
</Button>
|
| 309 |
+
</div>
|
| 310 |
+
<Card>
|
| 311 |
+
<Table>
|
| 312 |
+
<TableHeader>
|
| 313 |
+
<TableRow>
|
| 314 |
+
<TableHead>Key (e.g. calendar_link)</TableHead>
|
| 315 |
+
<TableHead>Value (e.g. cal.com/me)</TableHead>
|
| 316 |
+
<TableHead className="w-[50px]"></TableHead>
|
| 317 |
+
</TableRow>
|
| 318 |
+
</TableHeader>
|
| 319 |
+
<TableBody>
|
| 320 |
+
{customVariables.length === 0 && (
|
| 321 |
+
<TableRow>
|
| 322 |
+
<TableCell colSpan={3} className="text-center text-muted-foreground">
|
| 323 |
+
No custom variables added
|
| 324 |
+
</TableCell>
|
| 325 |
+
</TableRow>
|
| 326 |
+
)}
|
| 327 |
+
{customVariables.map((variable, index) => (
|
| 328 |
+
<TableRow key={index}>
|
| 329 |
+
<TableCell>
|
| 330 |
+
<Input
|
| 331 |
+
value={variable.key}
|
| 332 |
+
onChange={(e) => {
|
| 333 |
+
const newVars = [...customVariables];
|
| 334 |
+
newVars[index].key = e.target.value;
|
| 335 |
+
setCustomVariables(newVars);
|
| 336 |
+
}}
|
| 337 |
+
placeholder="my_variable"
|
| 338 |
+
className="h-8"
|
| 339 |
+
/>
|
| 340 |
+
</TableCell>
|
| 341 |
+
<TableCell>
|
| 342 |
+
<Input
|
| 343 |
+
value={variable.value}
|
| 344 |
+
onChange={(e) => {
|
| 345 |
+
const newVars = [...customVariables];
|
| 346 |
+
newVars[index].value = e.target.value;
|
| 347 |
+
setCustomVariables(newVars);
|
| 348 |
+
}}
|
| 349 |
+
placeholder="Value"
|
| 350 |
+
className="h-8"
|
| 351 |
+
/>
|
| 352 |
+
</TableCell>
|
| 353 |
+
<TableCell>
|
| 354 |
+
<Button
|
| 355 |
+
variant="ghost"
|
| 356 |
+
size="sm"
|
| 357 |
+
onClick={() => {
|
| 358 |
+
const newVars = customVariables.filter((_, i) => i !== index);
|
| 359 |
+
setCustomVariables(newVars);
|
| 360 |
+
}}
|
| 361 |
+
className="h-8 w-8 text-destructive hover:text-destructive"
|
| 362 |
+
>
|
| 363 |
+
<Trash2 className="h-4 w-4" />
|
| 364 |
+
</Button>
|
| 365 |
+
</TableCell>
|
| 366 |
+
</TableRow>
|
| 367 |
+
))}
|
| 368 |
+
</TableBody>
|
| 369 |
+
</Table>
|
| 370 |
+
</Card>
|
| 371 |
+
<p className="text-xs text-muted-foreground">
|
| 372 |
+
These variables can be used in your email templates using {"{key}"}.
|
| 373 |
+
</p>
|
| 374 |
+
</div>
|
| 375 |
+
|
| 376 |
+
<div className="space-y-2">
|
| 377 |
+
<Label htmlFor="email">Email</Label>
|
| 378 |
+
<Input
|
| 379 |
+
id="email"
|
| 380 |
+
type="email"
|
| 381 |
+
defaultValue={session?.user?.email || ""}
|
| 382 |
+
disabled
|
| 383 |
+
/>
|
| 384 |
+
<p className="text-xs text-muted-foreground">
|
| 385 |
+
Email cannot be changed for security reasons
|
| 386 |
+
</p>
|
| 387 |
+
</div>
|
| 388 |
+
|
| 389 |
+
<div className="space-y-2">
|
| 390 |
+
<Label htmlFor="timezone">Timezone</Label>
|
| 391 |
+
<select
|
| 392 |
+
id="timezone"
|
| 393 |
+
value={profileData.timezone}
|
| 394 |
+
onChange={(e) => setProfileData({ ...profileData, timezone: e.target.value })}
|
| 395 |
+
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background cursor-pointer"
|
| 396 |
+
>
|
| 397 |
+
<option>UTC (GMT+0:00)</option>
|
| 398 |
+
<option>EST (GMT-5:00)</option>
|
| 399 |
+
<option>PST (GMT-8:00)</option>
|
| 400 |
+
<option>IST (GMT+5:30)</option>
|
| 401 |
+
</select>
|
| 402 |
+
</div>
|
| 403 |
+
|
| 404 |
+
<Button
|
| 405 |
+
className="w-fit cursor-pointer"
|
| 406 |
+
onClick={handleSaveProfile}
|
| 407 |
+
disabled={settingsLoading}
|
| 408 |
+
>
|
| 409 |
+
{settingsLoading ? (
|
| 410 |
+
<>
|
| 411 |
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
| 412 |
+
Saving...
|
| 413 |
+
</>
|
| 414 |
+
) : (
|
| 415 |
+
"Save Changes"
|
| 416 |
+
)}
|
| 417 |
+
</Button>
|
| 418 |
+
</div>
|
| 419 |
+
</CardContent>
|
| 420 |
+
</Card>
|
| 421 |
+
|
| 422 |
+
<Card>
|
| 423 |
+
<CardHeader>
|
| 424 |
+
<CardTitle className="text-destructive flex items-center gap-2">
|
| 425 |
+
<AlertTriangle className="h-5 w-5" />
|
| 426 |
+
Danger Zone
|
| 427 |
+
</CardTitle>
|
| 428 |
+
<CardDescription>
|
| 429 |
+
Irreversible account actions
|
| 430 |
+
</CardDescription>
|
| 431 |
+
</CardHeader>
|
| 432 |
+
<CardContent className="space-y-4">
|
| 433 |
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between p-4 border rounded-lg bg-muted/20">
|
| 434 |
+
<div className="space-y-1">
|
| 435 |
+
<div className="font-medium">Sign Out</div>
|
| 436 |
+
<div className="text-sm text-muted-foreground">
|
| 437 |
+
Sign out of your active session
|
| 438 |
+
</div>
|
| 439 |
+
</div>
|
| 440 |
+
<Button variant="outline" onClick={handleSignOut} className="cursor-pointer">
|
| 441 |
+
<LogOut className="mr-2 h-4 w-4" />
|
| 442 |
+
Sign Out
|
| 443 |
+
</Button>
|
| 444 |
+
</div>
|
| 445 |
+
|
| 446 |
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between p-4 border border-destructive/20 rounded-lg bg-destructive/5">
|
| 447 |
+
<div className="space-y-1">
|
| 448 |
+
<div className="font-medium text-destructive">Delete Account</div>
|
| 449 |
+
<div className="text-sm text-muted-foreground">
|
| 450 |
+
Permanently delete your account and all data
|
| 451 |
+
</div>
|
| 452 |
+
</div>
|
| 453 |
+
<Button
|
| 454 |
+
variant="destructive"
|
| 455 |
+
onClick={() => setIsDeleteModalOpen(true)}
|
| 456 |
+
className="cursor-pointer"
|
| 457 |
+
>
|
| 458 |
+
<Trash2 className="mr-2 h-4 w-4" />
|
| 459 |
+
Delete Account
|
| 460 |
+
</Button>
|
| 461 |
+
</div>
|
| 462 |
+
</CardContent>
|
| 463 |
+
</Card>
|
| 464 |
+
</TabsContent>
|
| 465 |
+
|
| 466 |
+
{/* API Keys Tab */}
|
| 467 |
+
<TabsContent value="api" className="space-y-4">
|
| 468 |
+
<Card>
|
| 469 |
+
<CardHeader>
|
| 470 |
+
<CardTitle>API Keys</CardTitle>
|
| 471 |
+
<CardDescription>
|
| 472 |
+
Manage your API keys for integrations
|
| 473 |
+
</CardDescription>
|
| 474 |
+
</CardHeader>
|
| 475 |
+
<CardContent className="space-y-6">
|
| 476 |
+
{/* Google OAuth */}
|
| 477 |
+
<div className="space-y-2">
|
| 478 |
+
<div className="flex items-center justify-between">
|
| 479 |
+
<div>
|
| 480 |
+
<Label>Google OAuth</Label>
|
| 481 |
+
<p className="text-sm text-muted-foreground">
|
| 482 |
+
Connected via OAuth 2.0
|
| 483 |
+
</p>
|
| 484 |
+
</div>
|
| 485 |
+
<Badge variant="default">Active</Badge>
|
| 486 |
+
</div>
|
| 487 |
+
</div>
|
| 488 |
+
|
| 489 |
+
{/* Database URL */}
|
| 490 |
+
<div className="space-y-2">
|
| 491 |
+
<div className="flex items-center justify-between">
|
| 492 |
+
<Label>Database Connection</Label>
|
| 493 |
+
{statusLoading ? (
|
| 494 |
+
<Loader2 className="h-4 w-4 animate-spin" />
|
| 495 |
+
) : (
|
| 496 |
+
<Badge variant={connectionStatus.database ? "default" : "destructive"}>
|
| 497 |
+
{connectionStatus.database ? "Connected" : "Not Connected"}
|
| 498 |
+
</Badge>
|
| 499 |
+
)}
|
| 500 |
+
</div>
|
| 501 |
+
<p className="text-sm text-muted-foreground">
|
| 502 |
+
Neon PostgreSQL database
|
| 503 |
+
</p>
|
| 504 |
+
</div>
|
| 505 |
+
|
| 506 |
+
{/* Redis */}
|
| 507 |
+
<div className="space-y-2">
|
| 508 |
+
<div className="flex items-center justify-between">
|
| 509 |
+
<Label>Redis Queue</Label>
|
| 510 |
+
{statusLoading ? (
|
| 511 |
+
<Loader2 className="h-4 w-4 animate-spin" />
|
| 512 |
+
) : (
|
| 513 |
+
<Badge variant={connectionStatus.redis ? "default" : "destructive"}>
|
| 514 |
+
{connectionStatus.redis ? "Connected" : "Not Connected"}
|
| 515 |
+
</Badge>
|
| 516 |
+
)}
|
| 517 |
+
</div>
|
| 518 |
+
<p className="text-sm text-muted-foreground">
|
| 519 |
+
Background job processing
|
| 520 |
+
</p>
|
| 521 |
+
</div>
|
| 522 |
+
|
| 523 |
+
{/* Gemini API */}
|
| 524 |
+
<div className="space-y-2">
|
| 525 |
+
<div className="flex items-center justify-between">
|
| 526 |
+
<Label>Gemini API Key</Label>
|
| 527 |
+
<Badge variant={isGeminiKeySet ? "default" : "destructive"}>
|
| 528 |
+
{isGeminiKeySet ? "Configured" : "Not Set"}
|
| 529 |
+
</Badge>
|
| 530 |
+
</div>
|
| 531 |
+
<div className="flex gap-2">
|
| 532 |
+
<Input
|
| 533 |
+
type="password"
|
| 534 |
+
placeholder={isGeminiKeySet ? "••••••••••••••••" : "Enter your API Key"}
|
| 535 |
+
value={geminiApiKey}
|
| 536 |
+
onChange={(e) => setGeminiApiKey(e.target.value)}
|
| 537 |
+
/>
|
| 538 |
+
<Button
|
| 539 |
+
variant="outline"
|
| 540 |
+
className="cursor-pointer"
|
| 541 |
+
onClick={handleSaveApiKey}
|
| 542 |
+
disabled={settingsLoading || !geminiApiKey}
|
| 543 |
+
>
|
| 544 |
+
{settingsLoading ? (
|
| 545 |
+
<Loader2 className="h-4 w-4 animate-spin" />
|
| 546 |
+
) : (
|
| 547 |
+
"Update"
|
| 548 |
+
)}
|
| 549 |
+
</Button>
|
| 550 |
+
</div>
|
| 551 |
+
<p className="text-xs text-muted-foreground">
|
| 552 |
+
Required for AI-powered email generation and scraping.
|
| 553 |
+
</p>
|
| 554 |
+
</div>
|
| 555 |
+
|
| 556 |
+
{/* Webhook URL */}
|
| 557 |
+
<div className="space-y-2">
|
| 558 |
+
<Label>Webhook URL</Label>
|
| 559 |
+
<div className="flex gap-2">
|
| 560 |
+
<Input
|
| 561 |
+
value={`${typeof window !== 'undefined' ? window.location.origin : ''}/api/webhooks/email`}
|
| 562 |
+
readOnly
|
| 563 |
+
/>
|
| 564 |
+
<Button
|
| 565 |
+
variant="outline"
|
| 566 |
+
size="icon"
|
| 567 |
+
onClick={() => copyToClipboard(`${typeof window !== 'undefined' ? window.location.origin : ''}/api/webhooks/email`)}
|
| 568 |
+
className="cursor-pointer"
|
| 569 |
+
>
|
| 570 |
+
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
| 571 |
+
</Button>
|
| 572 |
+
</div>
|
| 573 |
+
<p className="text-sm text-muted-foreground">
|
| 574 |
+
Use this URL for email tracking webhooks
|
| 575 |
+
</p>
|
| 576 |
+
</div>
|
| 577 |
+
</CardContent>
|
| 578 |
+
</Card>
|
| 579 |
+
</TabsContent>
|
| 580 |
+
|
| 581 |
+
{/* Notifications Tab */}
|
| 582 |
+
<TabsContent value="notifications" className="space-y-4">
|
| 583 |
+
<Card>
|
| 584 |
+
<CardHeader>
|
| 585 |
+
<CardTitle>Notification Preferences</CardTitle>
|
| 586 |
+
<CardDescription>
|
| 587 |
+
Choose how you want to be notified
|
| 588 |
+
</CardDescription>
|
| 589 |
+
</CardHeader>
|
| 590 |
+
<CardContent className="space-y-6">
|
| 591 |
+
<div className="flex items-center justify-between">
|
| 592 |
+
<div className="space-y-0.5">
|
| 593 |
+
<Label>Email Notifications</Label>
|
| 594 |
+
<p className="text-sm text-muted-foreground">
|
| 595 |
+
Receive email updates about your campaigns
|
| 596 |
+
</p>
|
| 597 |
+
</div>
|
| 598 |
+
<Switch
|
| 599 |
+
checked={settings.emailNotifications}
|
| 600 |
+
onCheckedChange={(checked) =>
|
| 601 |
+
setSettings({ ...settings, emailNotifications: checked })
|
| 602 |
+
}
|
| 603 |
+
className="cursor-pointer"
|
| 604 |
+
/>
|
| 605 |
+
</div>
|
| 606 |
+
|
| 607 |
+
<div className="flex items-center justify-between">
|
| 608 |
+
<div className="space-y-0.5">
|
| 609 |
+
<Label>Campaign Alerts</Label>
|
| 610 |
+
<p className="text-sm text-muted-foreground">
|
| 611 |
+
Get notified when campaigns complete or fail
|
| 612 |
+
</p>
|
| 613 |
+
</div>
|
| 614 |
+
<Switch
|
| 615 |
+
checked={settings.campaignAlerts}
|
| 616 |
+
onCheckedChange={(checked) =>
|
| 617 |
+
setSettings({ ...settings, campaignAlerts: checked })
|
| 618 |
+
}
|
| 619 |
+
className="cursor-pointer"
|
| 620 |
+
/>
|
| 621 |
+
</div>
|
| 622 |
+
|
| 623 |
+
<div className="flex items-center justify-between">
|
| 624 |
+
<div className="space-y-0.5">
|
| 625 |
+
<Label>Weekly Reports</Label>
|
| 626 |
+
<p className="text-sm text-muted-foreground">
|
| 627 |
+
Receive weekly performance summaries
|
| 628 |
+
</p>
|
| 629 |
+
</div>
|
| 630 |
+
<Switch
|
| 631 |
+
checked={settings.weeklyReports}
|
| 632 |
+
onCheckedChange={(checked) =>
|
| 633 |
+
setSettings({ ...settings, weeklyReports: checked })
|
| 634 |
+
}
|
| 635 |
+
className="cursor-pointer"
|
| 636 |
+
/>
|
| 637 |
+
</div>
|
| 638 |
+
|
| 639 |
+
<Button
|
| 640 |
+
className="cursor-pointer"
|
| 641 |
+
onClick={handleSaveNotifications}
|
| 642 |
+
disabled={isSavingNotifications}
|
| 643 |
+
>
|
| 644 |
+
{isSavingNotifications ? (
|
| 645 |
+
<>
|
| 646 |
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
| 647 |
+
Saving...
|
| 648 |
+
</>
|
| 649 |
+
) : (
|
| 650 |
+
"Save Preferences"
|
| 651 |
+
)}
|
| 652 |
+
</Button>
|
| 653 |
+
</CardContent>
|
| 654 |
+
</Card>
|
| 655 |
+
</TabsContent>
|
| 656 |
+
{/* Appearance Tab */}
|
| 657 |
+
<TabsContent value="appearance" className="space-y-4">
|
| 658 |
+
<Card>
|
| 659 |
+
<CardHeader>
|
| 660 |
+
<CardTitle>Appearance</CardTitle>
|
| 661 |
+
<CardDescription>
|
| 662 |
+
Customize the look and feel of the application
|
| 663 |
+
</CardDescription>
|
| 664 |
+
</CardHeader>
|
| 665 |
+
<CardContent className="space-y-6">
|
| 666 |
+
<div className="flex items-center justify-between">
|
| 667 |
+
<div className="space-y-0.5">
|
| 668 |
+
<Label>Theme</Label>
|
| 669 |
+
<p className="text-sm text-muted-foreground">
|
| 670 |
+
Select your preferred theme
|
| 671 |
+
</p>
|
| 672 |
+
</div>
|
| 673 |
+
<div className="flex items-center gap-2">
|
| 674 |
+
<Button
|
| 675 |
+
variant={theme === "light" ? "default" : "outline"}
|
| 676 |
+
size="sm"
|
| 677 |
+
onClick={() => toggleTheme()}
|
| 678 |
+
className="cursor-pointer"
|
| 679 |
+
>
|
| 680 |
+
<Sun className="mr-2 h-4 w-4" />
|
| 681 |
+
Light
|
| 682 |
+
</Button>
|
| 683 |
+
<Button
|
| 684 |
+
variant={theme === "dark" ? "default" : "outline"}
|
| 685 |
+
size="sm"
|
| 686 |
+
onClick={() => toggleTheme()}
|
| 687 |
+
className="cursor-pointer"
|
| 688 |
+
>
|
| 689 |
+
<Moon className="mr-2 h-4 w-4" />
|
| 690 |
+
Dark
|
| 691 |
+
</Button>
|
| 692 |
+
</div>
|
| 693 |
+
</div>
|
| 694 |
+
</CardContent>
|
| 695 |
+
</Card>
|
| 696 |
+
</TabsContent>
|
| 697 |
+
</Tabs>
|
| 698 |
+
|
| 699 |
+
<Dialog open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen}>
|
| 700 |
+
<DialogContent>
|
| 701 |
+
<DialogHeader>
|
| 702 |
+
<DialogTitle>Delete Account</DialogTitle>
|
| 703 |
+
<DialogDescription>
|
| 704 |
+
Are you sure you want to delete your account? This action cannot be undone. All your data will be permanently removed.
|
| 705 |
+
</DialogDescription>
|
| 706 |
+
</DialogHeader>
|
| 707 |
+
<DialogFooter>
|
| 708 |
+
<Button
|
| 709 |
+
variant="outline"
|
| 710 |
+
onClick={() => setIsDeleteModalOpen(false)}
|
| 711 |
+
className="cursor-pointer"
|
| 712 |
+
>
|
| 713 |
+
Cancel
|
| 714 |
+
</Button>
|
| 715 |
+
<Button
|
| 716 |
+
variant="destructive"
|
| 717 |
+
onClick={handleDeleteAccount}
|
| 718 |
+
disabled={deletingUser}
|
| 719 |
+
className="cursor-pointer"
|
| 720 |
+
>
|
| 721 |
+
{deletingUser ? (
|
| 722 |
+
<>
|
| 723 |
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
| 724 |
+
Deleting...
|
| 725 |
+
</>
|
| 726 |
+
) : (
|
| 727 |
+
"Delete Account"
|
| 728 |
+
)}
|
| 729 |
+
</Button>
|
| 730 |
+
</DialogFooter>
|
| 731 |
+
</DialogContent>
|
| 732 |
+
</Dialog>
|
| 733 |
+
</div>
|
| 734 |
+
);
|
| 735 |
+
}
|
app/dashboard/tasks/page.tsx
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, useCallback } from "react";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
import { Card, CardContent } from "@/components/ui/card";
|
| 6 |
+
import { Button } from "@/components/ui/button";
|
| 7 |
+
import { Badge } from "@/components/ui/badge";
|
| 8 |
+
import { Plus, Check, Clock, AlertCircle, Pause, Play, StopCircle, RefreshCw } from "lucide-react";
|
| 9 |
+
import { useApi } from "@/hooks/use-api";
|
| 10 |
+
import { toast } from "sonner";
|
| 11 |
+
import {
|
| 12 |
+
DropdownMenu,
|
| 13 |
+
DropdownMenuContent,
|
| 14 |
+
DropdownMenuItem,
|
| 15 |
+
DropdownMenuTrigger,
|
| 16 |
+
} from "@/components/ui/dropdown-menu";
|
| 17 |
+
import {
|
| 18 |
+
DndContext,
|
| 19 |
+
DragOverlay,
|
| 20 |
+
closestCorners,
|
| 21 |
+
KeyboardSensor,
|
| 22 |
+
PointerSensor,
|
| 23 |
+
useSensor,
|
| 24 |
+
useSensors,
|
| 25 |
+
DragStartEvent,
|
| 26 |
+
DragEndEvent
|
| 27 |
+
} from "@dnd-kit/core";
|
| 28 |
+
import {
|
| 29 |
+
SortableContext,
|
| 30 |
+
sortableKeyboardCoordinates,
|
| 31 |
+
verticalListSortingStrategy,
|
| 32 |
+
useSortable
|
| 33 |
+
} from "@dnd-kit/sortable";
|
| 34 |
+
import { CSS } from "@dnd-kit/utilities";
|
| 35 |
+
|
| 36 |
+
interface Task {
|
| 37 |
+
id: string;
|
| 38 |
+
title: string;
|
| 39 |
+
description: string;
|
| 40 |
+
status: "pending" | "in-progress" | "completed" | "processing" | "paused" | "failed" | "running";
|
| 41 |
+
priority: "low" | "medium" | "high";
|
| 42 |
+
type?: "scraping" | "workflow";
|
| 43 |
+
businessesFound?: number;
|
| 44 |
+
workflowName?: string;
|
| 45 |
+
createdAt: Date;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export default function TasksPage() {
|
| 49 |
+
const [tasks, setTasks] = useState<Task[]>([]);
|
| 50 |
+
const [controllingTaskId, setControllingTaskId] = useState<string | null>(null);
|
| 51 |
+
const [refreshing, setRefreshing] = useState(false);
|
| 52 |
+
const router = useRouter();
|
| 53 |
+
|
| 54 |
+
const { get: getTasks, loading } = useApi<Task[]>();
|
| 55 |
+
const { post: controlScraping } = useApi();
|
| 56 |
+
const { patch: updateWorkflow } = useApi();
|
| 57 |
+
|
| 58 |
+
// Fetch tasks function
|
| 59 |
+
const fetchTasks = useCallback(async () => {
|
| 60 |
+
setRefreshing(true);
|
| 61 |
+
const data = await getTasks("/api/tasks");
|
| 62 |
+
if (data) {
|
| 63 |
+
setTasks(data);
|
| 64 |
+
}
|
| 65 |
+
setRefreshing(false);
|
| 66 |
+
}, [getTasks]);
|
| 67 |
+
|
| 68 |
+
// Initial fetch only
|
| 69 |
+
useEffect(() => {
|
| 70 |
+
fetchTasks();
|
| 71 |
+
}, [fetchTasks]);
|
| 72 |
+
|
| 73 |
+
const handleControl = async (taskId: string, action: "pause" | "resume" | "stop") => {
|
| 74 |
+
setControllingTaskId(taskId);
|
| 75 |
+
const task = tasks.find(t => t.id === taskId);
|
| 76 |
+
if (!task) return;
|
| 77 |
+
|
| 78 |
+
try {
|
| 79 |
+
let result;
|
| 80 |
+
|
| 81 |
+
if (task.type === "workflow") {
|
| 82 |
+
// Workflow Control
|
| 83 |
+
const isActive = action === "resume"; // Resume = active, Pause/Stop = inactive
|
| 84 |
+
result = await updateWorkflow(`/api/workflows/${taskId}`, {
|
| 85 |
+
isActive
|
| 86 |
+
});
|
| 87 |
+
} else {
|
| 88 |
+
// Scraping Control
|
| 89 |
+
result = await controlScraping("/api/scraping/control", {
|
| 90 |
+
jobId: taskId,
|
| 91 |
+
action,
|
| 92 |
+
});
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
if (result) {
|
| 96 |
+
toast.success(`Task ${action}d successfully`);
|
| 97 |
+
await fetchTasks();
|
| 98 |
+
}
|
| 99 |
+
} catch (error) {
|
| 100 |
+
toast.error(`Failed to ${action} task`);
|
| 101 |
+
console.error(`Error ${action}ing task:`, error);
|
| 102 |
+
} finally {
|
| 103 |
+
setControllingTaskId(null);
|
| 104 |
+
}
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
const handlePriorityChange = async (taskId: string, priority: "low" | "medium" | "high") => {
|
| 108 |
+
const task = tasks.find(t => t.id === taskId);
|
| 109 |
+
if (!task) return;
|
| 110 |
+
|
| 111 |
+
try {
|
| 112 |
+
let result;
|
| 113 |
+
|
| 114 |
+
if (task.type === "workflow") {
|
| 115 |
+
result = await updateWorkflow(`/api/workflows/${taskId}`, {
|
| 116 |
+
priority
|
| 117 |
+
});
|
| 118 |
+
} else {
|
| 119 |
+
result = await controlScraping("/api/scraping/control", {
|
| 120 |
+
jobId: taskId,
|
| 121 |
+
action: "set-priority",
|
| 122 |
+
priority
|
| 123 |
+
});
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
if (result) {
|
| 127 |
+
toast.success(`Priority updated to ${priority}`);
|
| 128 |
+
await fetchTasks();
|
| 129 |
+
}
|
| 130 |
+
} catch (error) {
|
| 131 |
+
toast.error("Failed to update priority");
|
| 132 |
+
console.error("Error updating priority:", error);
|
| 133 |
+
}
|
| 134 |
+
};
|
| 135 |
+
|
| 136 |
+
// Removed local storage logic and manual task creation
|
| 137 |
+
|
| 138 |
+
const pendingTasks = tasks.filter((t) => t.status === "pending");
|
| 139 |
+
const inProgressTasks = tasks.filter((t) =>
|
| 140 |
+
t.status === "in-progress" ||
|
| 141 |
+
t.status === "processing" ||
|
| 142 |
+
t.status === "paused"
|
| 143 |
+
);
|
| 144 |
+
const completedTasks = tasks.filter((t) =>
|
| 145 |
+
t.status === "completed" ||
|
| 146 |
+
t.status === "failed"
|
| 147 |
+
);
|
| 148 |
+
|
| 149 |
+
const priorityColors = {
|
| 150 |
+
low: "bg-blue-500",
|
| 151 |
+
medium: "bg-yellow-500",
|
| 152 |
+
high: "bg-red-500",
|
| 153 |
+
};
|
| 154 |
+
|
| 155 |
+
/* Drag and Drop State */
|
| 156 |
+
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
| 157 |
+
|
| 158 |
+
const sensors = useSensors(
|
| 159 |
+
useSensor(PointerSensor, {
|
| 160 |
+
activationConstraint: {
|
| 161 |
+
distance: 8, // Requires 8px movement to start drag, allowing button clicks
|
| 162 |
+
},
|
| 163 |
+
}),
|
| 164 |
+
useSensor(KeyboardSensor, {
|
| 165 |
+
coordinateGetter: sortableKeyboardCoordinates,
|
| 166 |
+
})
|
| 167 |
+
);
|
| 168 |
+
|
| 169 |
+
const handleDragStart = (event: DragStartEvent) => {
|
| 170 |
+
const { active } = event;
|
| 171 |
+
const task = tasks.find((t) => t.id === active.id);
|
| 172 |
+
if (task) {
|
| 173 |
+
setActiveTask(task);
|
| 174 |
+
}
|
| 175 |
+
};
|
| 176 |
+
|
| 177 |
+
const handleDragEnd = async (event: DragEndEvent) => {
|
| 178 |
+
setActiveTask(null);
|
| 179 |
+
const { active, over } = event;
|
| 180 |
+
|
| 181 |
+
if (!over) return;
|
| 182 |
+
|
| 183 |
+
const activeId = active.id as string;
|
| 184 |
+
const overId = over.id as string;
|
| 185 |
+
const activeTask = tasks.find((t) => t.id === activeId);
|
| 186 |
+
|
| 187 |
+
// Determine target column based on overId (which could be a task ID or column ID)
|
| 188 |
+
let targetStatus: Task["status"] | null = null;
|
| 189 |
+
|
| 190 |
+
if (overId === "pending-column" || overId === "in-progress-column" || overId === "completed-column") {
|
| 191 |
+
targetStatus = overId === "pending-column" ? "pending" :
|
| 192 |
+
overId === "in-progress-column" ? "in-progress" :
|
| 193 |
+
"completed";
|
| 194 |
+
} else {
|
| 195 |
+
// Dropped over another task
|
| 196 |
+
const overTask = tasks.find((t) => t.id === overId);
|
| 197 |
+
if (overTask) {
|
| 198 |
+
// Map task status to column group
|
| 199 |
+
if (overTask.status === "processing" || overTask.status === "paused" || overTask.status === "running") {
|
| 200 |
+
targetStatus = "in-progress"; // Group these under in-progress
|
| 201 |
+
} else {
|
| 202 |
+
targetStatus = overTask.status;
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
if (activeTask && targetStatus && activeTask.status !== targetStatus) {
|
| 208 |
+
// Handle status transition logic
|
| 209 |
+
let action: "pause" | "resume" | "stop" | null = null;
|
| 210 |
+
|
| 211 |
+
if (targetStatus === "in-progress") {
|
| 212 |
+
// Pending -> In Progress (Resume/Start)
|
| 213 |
+
if (activeTask.status === "pending" || activeTask.status === "paused") {
|
| 214 |
+
action = "resume";
|
| 215 |
+
}
|
| 216 |
+
} else if (targetStatus === "completed" || targetStatus === "failed") {
|
| 217 |
+
if (["processing", "running", "paused"].includes(activeTask.status)) {
|
| 218 |
+
action = "stop";
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
if (action) {
|
| 223 |
+
await handleControl(activeId, action);
|
| 224 |
+
} else if (targetStatus === "pending") {
|
| 225 |
+
toast.info("Cannot move task back to pending once started");
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
};
|
| 229 |
+
|
| 230 |
+
return (
|
| 231 |
+
<div className="p-6 space-y-6">
|
| 232 |
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
| 233 |
+
<div>
|
| 234 |
+
<h1 className="text-3xl font-bold tracking-tight">Task Management</h1>
|
| 235 |
+
<p className="text-muted-foreground">
|
| 236 |
+
Track your automated scraping and outreach jobs
|
| 237 |
+
</p>
|
| 238 |
+
</div>
|
| 239 |
+
<div className="flex gap-2">
|
| 240 |
+
<Button
|
| 241 |
+
onClick={fetchTasks}
|
| 242 |
+
variant="outline"
|
| 243 |
+
disabled={refreshing || loading}
|
| 244 |
+
className="w-full sm:w-auto"
|
| 245 |
+
>
|
| 246 |
+
<RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
| 247 |
+
Refresh
|
| 248 |
+
</Button>
|
| 249 |
+
<Button onClick={() => router.push("/dashboard/workflows?create=true")} className="w-full sm:w-auto">
|
| 250 |
+
<Plus className="mr-2 h-4 w-4" />
|
| 251 |
+
New Automation
|
| 252 |
+
</Button>
|
| 253 |
+
</div>
|
| 254 |
+
</div>
|
| 255 |
+
|
| 256 |
+
{loading ? (
|
| 257 |
+
<div className="flex justify-center p-12">Loading tasks...</div>
|
| 258 |
+
) : (
|
| 259 |
+
<DndContext
|
| 260 |
+
sensors={sensors}
|
| 261 |
+
collisionDetection={closestCorners}
|
| 262 |
+
onDragStart={handleDragStart}
|
| 263 |
+
onDragEnd={handleDragEnd}
|
| 264 |
+
>
|
| 265 |
+
<div className="grid gap-6 md:grid-cols-3">
|
| 266 |
+
{/* Pending Column */}
|
| 267 |
+
<SortableContext
|
| 268 |
+
id="pending-column"
|
| 269 |
+
items={pendingTasks.map(t => t.id)}
|
| 270 |
+
strategy={verticalListSortingStrategy}
|
| 271 |
+
>
|
| 272 |
+
<TaskColumn
|
| 273 |
+
id="pending-column"
|
| 274 |
+
title="Pending"
|
| 275 |
+
icon={<Clock className="h-5 w-5" />}
|
| 276 |
+
tasks={pendingTasks}
|
| 277 |
+
count={pendingTasks.length}
|
| 278 |
+
priorityColors={priorityColors}
|
| 279 |
+
/>
|
| 280 |
+
</SortableContext>
|
| 281 |
+
|
| 282 |
+
{/* In Progress Column */}
|
| 283 |
+
<SortableContext
|
| 284 |
+
id="in-progress-column"
|
| 285 |
+
items={inProgressTasks.map(t => t.id)}
|
| 286 |
+
strategy={verticalListSortingStrategy}
|
| 287 |
+
>
|
| 288 |
+
<TaskColumn
|
| 289 |
+
id="in-progress-column"
|
| 290 |
+
title="In Progress"
|
| 291 |
+
icon={<AlertCircle className="h-5 w-5" />}
|
| 292 |
+
tasks={inProgressTasks}
|
| 293 |
+
count={inProgressTasks.length}
|
| 294 |
+
priorityColors={priorityColors}
|
| 295 |
+
onControl={handleControl}
|
| 296 |
+
controllingTaskId={controllingTaskId}
|
| 297 |
+
/>
|
| 298 |
+
</SortableContext>
|
| 299 |
+
|
| 300 |
+
{/* Completed Column */}
|
| 301 |
+
<SortableContext
|
| 302 |
+
id="completed-column"
|
| 303 |
+
items={completedTasks.map(t => t.id)}
|
| 304 |
+
strategy={verticalListSortingStrategy}
|
| 305 |
+
>
|
| 306 |
+
<TaskColumn
|
| 307 |
+
id="completed-column"
|
| 308 |
+
title="Completed"
|
| 309 |
+
icon={<Check className="h-5 w-5" />}
|
| 310 |
+
tasks={completedTasks}
|
| 311 |
+
count={completedTasks.length}
|
| 312 |
+
priorityColors={priorityColors}
|
| 313 |
+
/>
|
| 314 |
+
</SortableContext>
|
| 315 |
+
</div>
|
| 316 |
+
<DragOverlay>
|
| 317 |
+
{activeTask ? (
|
| 318 |
+
<TaskCard
|
| 319 |
+
task={activeTask}
|
| 320 |
+
priorityColors={priorityColors}
|
| 321 |
+
// Hide controls during drag for cleaner look, or keep them
|
| 322 |
+
onControl={handleControl}
|
| 323 |
+
onPriorityChange={handlePriorityChange}
|
| 324 |
+
controllingTaskId={controllingTaskId}
|
| 325 |
+
/>
|
| 326 |
+
) : null}
|
| 327 |
+
</DragOverlay>
|
| 328 |
+
</DndContext>
|
| 329 |
+
)}
|
| 330 |
+
</div>
|
| 331 |
+
);
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
// Separate Column Component to handle dropping
|
| 335 |
+
interface TaskColumnProps {
|
| 336 |
+
id: string;
|
| 337 |
+
title: string;
|
| 338 |
+
icon: React.ReactNode;
|
| 339 |
+
tasks: Task[];
|
| 340 |
+
count: number;
|
| 341 |
+
priorityColors: Record<string, string>;
|
| 342 |
+
onControl?: (jobId: string, action: "pause" | "resume" | "stop") => void;
|
| 343 |
+
onPriorityChange?: (taskId: string, priority: "low" | "medium" | "high") => void;
|
| 344 |
+
controllingTaskId?: string | null;
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
function TaskColumn({ id, title, icon, tasks, count, priorityColors, onControl, onPriorityChange, controllingTaskId }: TaskColumnProps) {
|
| 348 |
+
const { setNodeRef } = useSortable({ id });
|
| 349 |
+
|
| 350 |
+
return (
|
| 351 |
+
<div ref={setNodeRef} className="space-y-4 bg-muted/50 p-4 rounded-lg min-h-[500px]">
|
| 352 |
+
<h3 className="font-semibold text-lg flex items-center gap-2">
|
| 353 |
+
{icon}
|
| 354 |
+
{title} ({count})
|
| 355 |
+
</h3>
|
| 356 |
+
<div className="space-y-3">
|
| 357 |
+
{tasks.map((task: Task) => (
|
| 358 |
+
<SortableTaskCard
|
| 359 |
+
key={task.id}
|
| 360 |
+
task={task}
|
| 361 |
+
priorityColors={priorityColors}
|
| 362 |
+
onControl={onControl}
|
| 363 |
+
onPriorityChange={onPriorityChange}
|
| 364 |
+
controllingTaskId={controllingTaskId}
|
| 365 |
+
/>
|
| 366 |
+
))}
|
| 367 |
+
{tasks.length === 0 && (
|
| 368 |
+
<p className="text-sm text-muted-foreground text-center py-8">
|
| 369 |
+
No tasks
|
| 370 |
+
</p>
|
| 371 |
+
)}
|
| 372 |
+
</div>
|
| 373 |
+
</div>
|
| 374 |
+
);
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
interface SortableTaskCardProps {
|
| 378 |
+
task: Task;
|
| 379 |
+
priorityColors: Record<string, string>;
|
| 380 |
+
onControl?: (jobId: string, action: "pause" | "resume" | "stop") => void;
|
| 381 |
+
onPriorityChange?: (taskId: string, priority: "low" | "medium" | "high") => void;
|
| 382 |
+
controllingTaskId?: string | null;
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
function SortableTaskCard(props: SortableTaskCardProps) {
|
| 386 |
+
const {
|
| 387 |
+
attributes,
|
| 388 |
+
listeners,
|
| 389 |
+
setNodeRef,
|
| 390 |
+
transform,
|
| 391 |
+
transition,
|
| 392 |
+
} = useSortable({ id: props.task.id });
|
| 393 |
+
|
| 394 |
+
const style = {
|
| 395 |
+
transform: CSS.Transform.toString(transform),
|
| 396 |
+
transition,
|
| 397 |
+
};
|
| 398 |
+
|
| 399 |
+
return (
|
| 400 |
+
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
| 401 |
+
<TaskCard {...props} />
|
| 402 |
+
</div>
|
| 403 |
+
);
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
function TaskCard({
|
| 407 |
+
task,
|
| 408 |
+
priorityColors,
|
| 409 |
+
onControl,
|
| 410 |
+
onPriorityChange,
|
| 411 |
+
controllingTaskId,
|
| 412 |
+
}: {
|
| 413 |
+
task: Task;
|
| 414 |
+
priorityColors: Record<string, string>;
|
| 415 |
+
onControl?: (jobId: string, action: "pause" | "resume" | "stop") => void;
|
| 416 |
+
onPriorityChange?: (taskId: string, priority: "low" | "medium" | "high") => void;
|
| 417 |
+
controllingTaskId?: string | null;
|
| 418 |
+
}) {
|
| 419 |
+
const showControls = ["running", "processing", "paused", "pending", "in-progress"].includes(task.status);
|
| 420 |
+
const isControlling = controllingTaskId === task.id;
|
| 421 |
+
|
| 422 |
+
return (
|
| 423 |
+
<Card className="hover:shadow-md transition-shadow">
|
| 424 |
+
<CardContent className="p-4 space-y-3">
|
| 425 |
+
<div className="flex items-start justify-between">
|
| 426 |
+
<div className="flex-1">
|
| 427 |
+
<h4 className="font-medium flex items-center gap-2">
|
| 428 |
+
{task.title}
|
| 429 |
+
{task.type === "workflow" && (
|
| 430 |
+
<Badge variant="secondary" className="text-[10px] h-5">Workflow</Badge>
|
| 431 |
+
)}
|
| 432 |
+
</h4>
|
| 433 |
+
{task.description && (
|
| 434 |
+
<p className="text-sm text-muted-foreground mt-1">
|
| 435 |
+
{task.description}
|
| 436 |
+
</p>
|
| 437 |
+
)}
|
| 438 |
+
</div>
|
| 439 |
+
</div>
|
| 440 |
+
|
| 441 |
+
<div className="flex items-center gap-2 flex-wrap">
|
| 442 |
+
<DropdownMenu>
|
| 443 |
+
<DropdownMenuTrigger asChild>
|
| 444 |
+
<Badge variant="outline" className="text-xs cursor-pointer hover:bg-accent transition-colors">
|
| 445 |
+
<div className={`h-2 w-2 rounded-full ${priorityColors[task.priority]} mr-1`} />
|
| 446 |
+
{task.priority || "medium"}
|
| 447 |
+
</Badge>
|
| 448 |
+
</DropdownMenuTrigger>
|
| 449 |
+
<DropdownMenuContent align="start">
|
| 450 |
+
<DropdownMenuItem onClick={() => onPriorityChange?.(task.id, "low")}>
|
| 451 |
+
<div className={`h-2 w-2 rounded-full ${priorityColors.low} mr-2`} />
|
| 452 |
+
Low
|
| 453 |
+
</DropdownMenuItem>
|
| 454 |
+
<DropdownMenuItem onClick={() => onPriorityChange?.(task.id, "medium")}>
|
| 455 |
+
<div className={`h-2 w-2 rounded-full ${priorityColors.medium} mr-2`} />
|
| 456 |
+
Medium
|
| 457 |
+
</DropdownMenuItem>
|
| 458 |
+
<DropdownMenuItem onClick={() => onPriorityChange?.(task.id, "high")}>
|
| 459 |
+
<div className={`h-2 w-2 rounded-full ${priorityColors.high} mr-2`} />
|
| 460 |
+
High
|
| 461 |
+
</DropdownMenuItem>
|
| 462 |
+
</DropdownMenuContent>
|
| 463 |
+
</DropdownMenu>
|
| 464 |
+
|
| 465 |
+
<div className="text-xs text-muted-foreground">
|
| 466 |
+
{new Date(task.createdAt).toLocaleDateString()}
|
| 467 |
+
</div>
|
| 468 |
+
|
| 469 |
+
{showControls && onControl && (
|
| 470 |
+
<div className="ml-auto flex gap-2">
|
| 471 |
+
{(task.status === "in-progress" || task.status === "processing" || task.status === "running") && (
|
| 472 |
+
<Button
|
| 473 |
+
size="sm"
|
| 474 |
+
variant="outline"
|
| 475 |
+
onClick={() => onControl(task.id, "pause")}
|
| 476 |
+
disabled={isControlling}
|
| 477 |
+
className="h-7 w-7 p-0"
|
| 478 |
+
>
|
| 479 |
+
<Pause className="h-4 w-4" />
|
| 480 |
+
</Button>
|
| 481 |
+
)}
|
| 482 |
+
|
| 483 |
+
{task.status === "paused" && (
|
| 484 |
+
<Button
|
| 485 |
+
size="sm"
|
| 486 |
+
variant="outline"
|
| 487 |
+
onClick={() => onControl(task.id, "resume")}
|
| 488 |
+
disabled={isControlling}
|
| 489 |
+
className="h-7 w-7 p-0"
|
| 490 |
+
>
|
| 491 |
+
<Play className="h-4 w-4" />
|
| 492 |
+
</Button>
|
| 493 |
+
)}
|
| 494 |
+
|
| 495 |
+
{/* Stop Button */}
|
| 496 |
+
<Button
|
| 497 |
+
size="sm"
|
| 498 |
+
variant="outline"
|
| 499 |
+
onClick={() => onControl(task.id, "stop")}
|
| 500 |
+
disabled={isControlling}
|
| 501 |
+
className="text-red-500 hover:text-red-600 h-7 w-7 p-0"
|
| 502 |
+
>
|
| 503 |
+
<StopCircle className="h-4 w-4" />
|
| 504 |
+
</Button>
|
| 505 |
+
|
| 506 |
+
|
| 507 |
+
</div>
|
| 508 |
+
)}
|
| 509 |
+
</div>
|
| 510 |
+
</CardContent>
|
| 511 |
+
</Card>
|
| 512 |
+
);
|
| 513 |
+
}
|
app/dashboard/templates/page.tsx
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react";
|
| 4 |
+
import { EmailEditor } from "@/components/email/email-editor";
|
| 5 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 6 |
+
import { Button } from "@/components/ui/button";
|
| 7 |
+
import { Badge } from "@/components/ui/badge";
|
| 8 |
+
import { EmailTemplate, UserProfile } from "@/types";
|
| 9 |
+
import { Plus, Trash2, Star, Loader2 } from "lucide-react";
|
| 10 |
+
|
| 11 |
+
import { useTemplates } from "@/hooks/use-templates";
|
| 12 |
+
import { useToast } from "@/hooks/use-toast";
|
| 13 |
+
import { useApi } from "@/hooks/use-api";
|
| 14 |
+
|
| 15 |
+
export default function TemplatesPage() {
|
| 16 |
+
const { templates, saveTemplate, deleteTemplate, loading } = useTemplates();
|
| 17 |
+
const [selectedTemplate, setSelectedTemplate] = useState<Partial<EmailTemplate>>({});
|
| 18 |
+
const [isCreating, setIsCreating] = useState(false);
|
| 19 |
+
const [isGenerating, setIsGenerating] = useState(false);
|
| 20 |
+
const { toast } = useToast();
|
| 21 |
+
const [userProfile, setUserProfile] = useState<UserProfile>({});
|
| 22 |
+
|
| 23 |
+
const { get: getUser } = useApi<{ user: UserProfile }>();
|
| 24 |
+
const { post: generateAiTemplate } = useApi<{ subject: string; body: string }>();
|
| 25 |
+
|
| 26 |
+
useEffect(() => {
|
| 27 |
+
// Fetch user profile for variables
|
| 28 |
+
const fetchProfile = async () => {
|
| 29 |
+
const data = await getUser("/api/settings");
|
| 30 |
+
if (data?.user) {
|
| 31 |
+
setUserProfile(data.user);
|
| 32 |
+
}
|
| 33 |
+
};
|
| 34 |
+
fetchProfile();
|
| 35 |
+
}, [getUser]);
|
| 36 |
+
|
| 37 |
+
const handleSave = async () => {
|
| 38 |
+
const success = await saveTemplate(selectedTemplate);
|
| 39 |
+
if (success) {
|
| 40 |
+
setIsCreating(false);
|
| 41 |
+
setSelectedTemplate({});
|
| 42 |
+
toast({
|
| 43 |
+
title: "Success",
|
| 44 |
+
description: "Template saved successfully",
|
| 45 |
+
});
|
| 46 |
+
} else {
|
| 47 |
+
toast({
|
| 48 |
+
title: "Error",
|
| 49 |
+
description: "Failed to save template",
|
| 50 |
+
variant: "destructive",
|
| 51 |
+
});
|
| 52 |
+
}
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
| 56 |
+
e.stopPropagation();
|
| 57 |
+
if (!confirm("Are you sure you want to delete this template?")) return;
|
| 58 |
+
|
| 59 |
+
const success = await deleteTemplate(id);
|
| 60 |
+
if (success) {
|
| 61 |
+
toast({
|
| 62 |
+
title: "Success",
|
| 63 |
+
description: "Template deleted successfully",
|
| 64 |
+
});
|
| 65 |
+
} else {
|
| 66 |
+
toast({
|
| 67 |
+
title: "Error",
|
| 68 |
+
description: "Failed to delete template",
|
| 69 |
+
variant: "destructive",
|
| 70 |
+
});
|
| 71 |
+
}
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
const handleGenerateWithAI = async (prompt: string) => {
|
| 75 |
+
setIsGenerating(true);
|
| 76 |
+
const generated = await generateAiTemplate("/api/templates/generate", {
|
| 77 |
+
businessType: "businesses", // Generic fallback
|
| 78 |
+
purpose: prompt
|
| 79 |
+
});
|
| 80 |
+
|
| 81 |
+
if (generated) {
|
| 82 |
+
setSelectedTemplate({
|
| 83 |
+
...selectedTemplate,
|
| 84 |
+
subject: generated.subject,
|
| 85 |
+
body: generated.body,
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
toast({
|
| 89 |
+
title: "Generated!",
|
| 90 |
+
description: "Your email template has been created with AI.",
|
| 91 |
+
});
|
| 92 |
+
} else {
|
| 93 |
+
toast({
|
| 94 |
+
title: "Error",
|
| 95 |
+
description: "Failed to generate template",
|
| 96 |
+
variant: "destructive",
|
| 97 |
+
});
|
| 98 |
+
}
|
| 99 |
+
setIsGenerating(false);
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
+
return (
|
| 103 |
+
<div className="space-y-6">
|
| 104 |
+
{/* Header */}
|
| 105 |
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
| 106 |
+
<div>
|
| 107 |
+
<h1 className="text-3xl font-bold">Email Templates</h1>
|
| 108 |
+
<p className="text-muted-foreground">
|
| 109 |
+
Create and manage your email templates
|
| 110 |
+
</p>
|
| 111 |
+
</div>
|
| 112 |
+
<Button onClick={() => setIsCreating(true)} className="w-full sm:w-auto">
|
| 113 |
+
<Plus className="mr-2 h-4 w-4" />
|
| 114 |
+
New Template
|
| 115 |
+
</Button>
|
| 116 |
+
</div>
|
| 117 |
+
|
| 118 |
+
{isCreating ? (
|
| 119 |
+
<div className="space-y-4">
|
| 120 |
+
<EmailEditor
|
| 121 |
+
template={selectedTemplate}
|
| 122 |
+
onChange={setSelectedTemplate}
|
| 123 |
+
onGenerateWithAI={handleGenerateWithAI}
|
| 124 |
+
isGenerating={isGenerating}
|
| 125 |
+
userProfile={userProfile}
|
| 126 |
+
/>
|
| 127 |
+
<div className="flex flex-col gap-3 sm:flex-row">
|
| 128 |
+
<Button onClick={handleSave} disabled={loading}>
|
| 129 |
+
{loading ? (
|
| 130 |
+
<>
|
| 131 |
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
| 132 |
+
Saving...
|
| 133 |
+
</>
|
| 134 |
+
) : (
|
| 135 |
+
"Save Template"
|
| 136 |
+
)}
|
| 137 |
+
</Button>
|
| 138 |
+
<Button
|
| 139 |
+
variant="outline"
|
| 140 |
+
onClick={() => {
|
| 141 |
+
setIsCreating(false);
|
| 142 |
+
setSelectedTemplate({});
|
| 143 |
+
}}
|
| 144 |
+
>
|
| 145 |
+
Cancel
|
| 146 |
+
</Button>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
) : (
|
| 150 |
+
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
| 151 |
+
{templates.map((template) => (
|
| 152 |
+
<Card key={template.id} className="group cursor-pointer hover:shadow-lg transition-all">
|
| 153 |
+
<CardHeader>
|
| 154 |
+
<div className="flex items-start justify-between">
|
| 155 |
+
<CardTitle className="line-clamp-1">{template.name}</CardTitle>
|
| 156 |
+
{template.isDefault && (
|
| 157 |
+
<Badge variant="default">
|
| 158 |
+
<Star className="mr-1 h-3 w-3 fill-current" />
|
| 159 |
+
Default
|
| 160 |
+
</Badge>
|
| 161 |
+
)}
|
| 162 |
+
</div>
|
| 163 |
+
</CardHeader>
|
| 164 |
+
<CardContent>
|
| 165 |
+
<div
|
| 166 |
+
className="text-sm text-muted-foreground line-clamp-3 mb-4 prose prose-sm dark:prose-invert max-h-18 overflow-hidden"
|
| 167 |
+
dangerouslySetInnerHTML={{ __html: template.body || template.subject || "No content" }}
|
| 168 |
+
/>
|
| 169 |
+
<div className="flex gap-2 transition-all">
|
| 170 |
+
<Button
|
| 171 |
+
size="sm"
|
| 172 |
+
variant="outline"
|
| 173 |
+
onClick={() => {
|
| 174 |
+
setSelectedTemplate(template);
|
| 175 |
+
setIsCreating(true);
|
| 176 |
+
}}
|
| 177 |
+
className="hover:scale-105 transition-transform"
|
| 178 |
+
>
|
| 179 |
+
Edit
|
| 180 |
+
</Button>
|
| 181 |
+
<Button
|
| 182 |
+
size="sm"
|
| 183 |
+
variant="outline"
|
| 184 |
+
onClick={(e) => handleDelete(template.id, e)}
|
| 185 |
+
disabled={template.isDefault}
|
| 186 |
+
className="hover:scale-105 transition-transform"
|
| 187 |
+
>
|
| 188 |
+
<Trash2 className="h-4 w-4" />
|
| 189 |
+
</Button>
|
| 190 |
+
</div>
|
| 191 |
+
</CardContent>
|
| 192 |
+
</Card>
|
| 193 |
+
))}
|
| 194 |
+
|
| 195 |
+
{templates.length === 0 && (
|
| 196 |
+
<Card className="col-span-full border-2 border-dashed">
|
| 197 |
+
<CardContent className="flex flex-col items-center justify-center py-12">
|
| 198 |
+
<p className="text-muted-foreground mb-4">No templates yet</p>
|
| 199 |
+
<Button onClick={() => setIsCreating(true)}>
|
| 200 |
+
Create Your First Template
|
| 201 |
+
</Button>
|
| 202 |
+
</CardContent>
|
| 203 |
+
</Card>
|
| 204 |
+
)}
|
| 205 |
+
</div>
|
| 206 |
+
)}
|
| 207 |
+
</div>
|
| 208 |
+
);
|
| 209 |
+
}
|
app/dashboard/workflows/page.tsx
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { Node, Edge } from "reactflow";
|
| 5 |
+
import { NodeEditor, NodeData } from "@/components/node-editor/node-editor";
|
| 6 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 7 |
+
import { Button } from "@/components/ui/button";
|
| 8 |
+
import { Input } from "@/components/ui/input";
|
| 9 |
+
import { Badge } from "@/components/ui/badge";
|
| 10 |
+
import { AutomationWorkflow } from "@/types";
|
| 11 |
+
import { Plus, Play, Trash2, ArrowLeft, Loader2 } from "lucide-react";
|
| 12 |
+
import { useWorkflows } from "@/hooks/useWorkflows";
|
| 13 |
+
import { useToast } from "@/hooks/use-toast";
|
| 14 |
+
import { useApi } from "@/hooks/use-api";
|
| 15 |
+
|
| 16 |
+
export default function WorkflowsPage() {
|
| 17 |
+
const { workflows, refetch } = useWorkflows();
|
| 18 |
+
const [selectedWorkflow, setSelectedWorkflow] = useState<Partial<AutomationWorkflow> | null>(null);
|
| 19 |
+
const [isCreating, setIsCreating] = useState(false);
|
| 20 |
+
const { toast } = useToast();
|
| 21 |
+
|
| 22 |
+
const { post: createWf, patch: updateWf, del: deleteWfFn, loading } = useApi();
|
| 23 |
+
|
| 24 |
+
const handleSave = async (nodes?: Node<NodeData>[], edges?: Edge[]) => {
|
| 25 |
+
console.log("🚀 Saving workflow");
|
| 26 |
+
if (!selectedWorkflow) return;
|
| 27 |
+
|
| 28 |
+
const workflowData = {
|
| 29 |
+
...selectedWorkflow,
|
| 30 |
+
nodes: nodes || selectedWorkflow.nodes,
|
| 31 |
+
edges: edges || selectedWorkflow.edges,
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
const isUpdate = !!selectedWorkflow.id;
|
| 35 |
+
let success = false;
|
| 36 |
+
|
| 37 |
+
if (isUpdate && selectedWorkflow.id) {
|
| 38 |
+
const result = await updateWf(`/api/workflows/${selectedWorkflow.id}`, workflowData);
|
| 39 |
+
success = !!result;
|
| 40 |
+
} else {
|
| 41 |
+
const result = await createWf("/api/workflows", workflowData);
|
| 42 |
+
success = !!result;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
if (success) {
|
| 46 |
+
await refetch();
|
| 47 |
+
toast({
|
| 48 |
+
title: "Success",
|
| 49 |
+
description: `Workflow ${isUpdate ? "updated" : "created"} successfully`,
|
| 50 |
+
});
|
| 51 |
+
if (!isUpdate) {
|
| 52 |
+
setIsCreating(false);
|
| 53 |
+
setSelectedWorkflow(null);
|
| 54 |
+
}
|
| 55 |
+
} else {
|
| 56 |
+
toast({
|
| 57 |
+
title: "Error",
|
| 58 |
+
description: "Failed to save workflow",
|
| 59 |
+
variant: "destructive",
|
| 60 |
+
});
|
| 61 |
+
}
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
return (
|
| 65 |
+
<div className="space-y-6">
|
| 66 |
+
{/* Header */}
|
| 67 |
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
| 68 |
+
<div>
|
| 69 |
+
<h1 className="text-3xl font-bold">Automation Workflows</h1>
|
| 70 |
+
<p className="text-muted-foreground">
|
| 71 |
+
Build visual workflows for your email automation
|
| 72 |
+
</p>
|
| 73 |
+
</div>
|
| 74 |
+
{!isCreating && (
|
| 75 |
+
<Button
|
| 76 |
+
onClick={() => {
|
| 77 |
+
setSelectedWorkflow({ nodes: [], edges: [] });
|
| 78 |
+
setIsCreating(true);
|
| 79 |
+
}}
|
| 80 |
+
className="w-full sm:w-auto"
|
| 81 |
+
>
|
| 82 |
+
<Plus className="mr-2 h-4 w-4" />
|
| 83 |
+
New Workflow
|
| 84 |
+
</Button>
|
| 85 |
+
)}
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
{isCreating ? (
|
| 89 |
+
<div className="space-y-4">
|
| 90 |
+
<div className="flex flex-col gap-3 sm:flex-row">
|
| 91 |
+
<Button onClick={() => handleSave()} disabled={loading}>
|
| 92 |
+
{loading ? (
|
| 93 |
+
<>
|
| 94 |
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
| 95 |
+
Saving...
|
| 96 |
+
</>
|
| 97 |
+
) : (
|
| 98 |
+
"Save Workflow"
|
| 99 |
+
)}
|
| 100 |
+
</Button>
|
| 101 |
+
<Button
|
| 102 |
+
variant="outline"
|
| 103 |
+
onClick={() => {
|
| 104 |
+
setIsCreating(false);
|
| 105 |
+
setSelectedWorkflow(null);
|
| 106 |
+
}}
|
| 107 |
+
>
|
| 108 |
+
<ArrowLeft className="mr-2 h-4 w-4" />
|
| 109 |
+
Back to List
|
| 110 |
+
</Button>
|
| 111 |
+
</div>
|
| 112 |
+
<Card>
|
| 113 |
+
<CardHeader>
|
| 114 |
+
<CardTitle>Workflow Details</CardTitle>
|
| 115 |
+
</CardHeader>
|
| 116 |
+
<CardContent className="space-y-4">
|
| 117 |
+
<div>
|
| 118 |
+
<label className="text-sm font-medium">Workflow Name</label>
|
| 119 |
+
<Input
|
| 120 |
+
placeholder="My Workflow"
|
| 121 |
+
value={selectedWorkflow?.name || ""}
|
| 122 |
+
onChange={(e) =>
|
| 123 |
+
setSelectedWorkflow({
|
| 124 |
+
...selectedWorkflow,
|
| 125 |
+
name: e.target.value,
|
| 126 |
+
})
|
| 127 |
+
}
|
| 128 |
+
className="mt-1"
|
| 129 |
+
/>
|
| 130 |
+
</div>
|
| 131 |
+
<div className="grid gap-4 md:grid-cols-2">
|
| 132 |
+
<div>
|
| 133 |
+
<label className="text-sm font-medium">Target Business Type</label>
|
| 134 |
+
<Input
|
| 135 |
+
placeholder="e.g., Restaurants"
|
| 136 |
+
value={selectedWorkflow?.targetBusinessType || ""}
|
| 137 |
+
onChange={(e) =>
|
| 138 |
+
setSelectedWorkflow({
|
| 139 |
+
...selectedWorkflow,
|
| 140 |
+
targetBusinessType: e.target.value,
|
| 141 |
+
})
|
| 142 |
+
}
|
| 143 |
+
className="mt-1"
|
| 144 |
+
/>
|
| 145 |
+
</div>
|
| 146 |
+
<div>
|
| 147 |
+
<label className="text-sm font-medium">Keywords</label>
|
| 148 |
+
<Input
|
| 149 |
+
placeholder="restaurant, cafe, bar"
|
| 150 |
+
value={selectedWorkflow?.keywords?.join(", ") || ""}
|
| 151 |
+
onChange={(e) =>
|
| 152 |
+
setSelectedWorkflow({
|
| 153 |
+
...selectedWorkflow,
|
| 154 |
+
keywords: e.target.value.split(",").map((k) => k.trim()),
|
| 155 |
+
})
|
| 156 |
+
}
|
| 157 |
+
className="mt-1"
|
| 158 |
+
/>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
</CardContent>
|
| 162 |
+
</Card>
|
| 163 |
+
|
| 164 |
+
<div className="h-[600px] border rounded-lg overflow-hidden">
|
| 165 |
+
<NodeEditor
|
| 166 |
+
initialNodes={(selectedWorkflow?.nodes as Node<NodeData>[]) || []}
|
| 167 |
+
initialEdges={(selectedWorkflow?.edges as Edge[]) || []}
|
| 168 |
+
onSave={(nodes: Node<NodeData>[], edges: Edge[]) => {
|
| 169 |
+
handleSave(nodes, edges);
|
| 170 |
+
}}
|
| 171 |
+
isSaving={loading}
|
| 172 |
+
/>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
) : (
|
| 176 |
+
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
| 177 |
+
{loading ? (
|
| 178 |
+
// Show skeleton cards while loading
|
| 179 |
+
Array.from({ length: 6 }).map((_, i) => (
|
| 180 |
+
<Card key={i} className="animate-pulse">
|
| 181 |
+
<CardContent className="p-6 space-y-4">
|
| 182 |
+
<div className="space-y-2">
|
| 183 |
+
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
| 184 |
+
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
|
| 185 |
+
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
|
| 186 |
+
</div>
|
| 187 |
+
<div className="flex gap-2 justify-between">
|
| 188 |
+
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-20"></div>
|
| 189 |
+
<div className="flex gap-2">
|
| 190 |
+
<div className="h-8 w-8 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
| 191 |
+
<div className="h-8 w-8 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
| 192 |
+
<div className="h-8 w-8 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
</CardContent>
|
| 196 |
+
</Card>
|
| 197 |
+
))
|
| 198 |
+
) : (
|
| 199 |
+
workflows.map((workflow) => (
|
| 200 |
+
<Card key={workflow.id} className="group cursor-pointer hover:shadow-lg transition-all">
|
| 201 |
+
<CardHeader>
|
| 202 |
+
<div className="flex items-start justify-between">
|
| 203 |
+
<CardTitle className="line-clamp-1">{workflow.name}</CardTitle>
|
| 204 |
+
<Badge variant={workflow.isActive ? "success" : "secondary"}>
|
| 205 |
+
{workflow.isActive ? "Running" : "Paused"}
|
| 206 |
+
</Badge>
|
| 207 |
+
</div>
|
| 208 |
+
</CardHeader>
|
| 209 |
+
<CardContent>
|
| 210 |
+
<p className="text-sm text-muted-foreground mb-2">
|
| 211 |
+
{workflow.targetBusinessType}
|
| 212 |
+
</p>
|
| 213 |
+
<div className="flex flex-wrap gap-1 mb-4">
|
| 214 |
+
{workflow.keywords.slice(0, 3).map((keyword, i) => (
|
| 215 |
+
<Badge key={i} variant="outline" className="text-xs">
|
| 216 |
+
{keyword}
|
| 217 |
+
</Badge>
|
| 218 |
+
))}
|
| 219 |
+
{workflow.keywords.length > 3 && (
|
| 220 |
+
<Badge variant="outline" className="text-xs">
|
| 221 |
+
+{workflow.keywords.length - 3}
|
| 222 |
+
</Badge>
|
| 223 |
+
)}
|
| 224 |
+
</div>
|
| 225 |
+
<div className="flex gap-2 opacity-50 group-hover:opacity-100 transition-opacity justify-end">
|
| 226 |
+
<Button
|
| 227 |
+
size="icon"
|
| 228 |
+
variant="outline"
|
| 229 |
+
className="h-8 w-8"
|
| 230 |
+
onClick={(e) => {
|
| 231 |
+
e.stopPropagation();
|
| 232 |
+
const toggleStatus = async () => {
|
| 233 |
+
const result = await updateWf(`/api/workflows/${workflow.id}`, { isActive: !workflow.isActive });
|
| 234 |
+
|
| 235 |
+
if (result) {
|
| 236 |
+
refetch();
|
| 237 |
+
toast({
|
| 238 |
+
title: "Status Updated",
|
| 239 |
+
description: `Workflow ${!workflow.isActive ? "resumed" : "paused"}`,
|
| 240 |
+
})
|
| 241 |
+
} else {
|
| 242 |
+
toast({
|
| 243 |
+
title: "Error",
|
| 244 |
+
description: "Failed to update status",
|
| 245 |
+
variant: "destructive",
|
| 246 |
+
})
|
| 247 |
+
}
|
| 248 |
+
};
|
| 249 |
+
toggleStatus();
|
| 250 |
+
}}
|
| 251 |
+
>
|
| 252 |
+
{workflow.isActive ? (
|
| 253 |
+
<div className="h-3 w-3 bg-red-500 rounded-sm" />
|
| 254 |
+
) : (
|
| 255 |
+
<Play className="h-4 w-4 text-green-500 fill-green-500" />
|
| 256 |
+
)}
|
| 257 |
+
</Button>
|
| 258 |
+
<Button
|
| 259 |
+
size="sm"
|
| 260 |
+
variant="outline"
|
| 261 |
+
onClick={(e) => {
|
| 262 |
+
e.stopPropagation();
|
| 263 |
+
setSelectedWorkflow(workflow);
|
| 264 |
+
setIsCreating(true);
|
| 265 |
+
}}
|
| 266 |
+
className="hover:scale-105 transition-transform"
|
| 267 |
+
>
|
| 268 |
+
Edit
|
| 269 |
+
</Button>
|
| 270 |
+
<Button
|
| 271 |
+
size="icon"
|
| 272 |
+
variant="destructive"
|
| 273 |
+
className="h-8 w-8 hover:scale-105 transition-transform"
|
| 274 |
+
onClick={(e) => {
|
| 275 |
+
e.stopPropagation();
|
| 276 |
+
if (!confirm("Are you sure you want to delete this workflow?")) return;
|
| 277 |
+
|
| 278 |
+
const deleteWorkflow = async () => {
|
| 279 |
+
const result = await deleteWfFn(`/api/workflows/${workflow.id}`);
|
| 280 |
+
|
| 281 |
+
if (result !== null) {
|
| 282 |
+
refetch();
|
| 283 |
+
toast({ title: "Workflow Deleted", description: "Workflow deleted successfully" })
|
| 284 |
+
} else {
|
| 285 |
+
toast({ title: "Error", description: "Failed to delete workflow", variant: "destructive" })
|
| 286 |
+
}
|
| 287 |
+
};
|
| 288 |
+
deleteWorkflow();
|
| 289 |
+
}}
|
| 290 |
+
>
|
| 291 |
+
<Trash2 className="h-4 w-4" />
|
| 292 |
+
</Button>
|
| 293 |
+
</div>
|
| 294 |
+
</CardContent>
|
| 295 |
+
</Card>
|
| 296 |
+
)))}
|
| 297 |
+
{workflows.length === 0 && (
|
| 298 |
+
<Card className="col-span-full border-2 border-dashed">
|
| 299 |
+
<CardContent className="flex flex-col items-center justify-center py-12">
|
| 300 |
+
<p className="text-muted-foreground mb-4">No workflows yet</p>
|
| 301 |
+
<Button
|
| 302 |
+
onClick={() => {
|
| 303 |
+
setSelectedWorkflow({ nodes: [], edges: [] });
|
| 304 |
+
setIsCreating(true);
|
| 305 |
+
}}
|
| 306 |
+
>
|
| 307 |
+
Create Your First Workflow
|
| 308 |
+
</Button>
|
| 309 |
+
</CardContent>
|
| 310 |
+
</Card>
|
| 311 |
+
)}
|
| 312 |
+
</div>
|
| 313 |
+
)}
|
| 314 |
+
</div>
|
| 315 |
+
);
|
| 316 |
+
}
|
app/error.tsx
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { Button } from "@/components/ui/button";
|
| 4 |
+
import Link from "next/link";
|
| 5 |
+
import { useEffect } from "react";
|
| 6 |
+
|
| 7 |
+
export default function Error({
|
| 8 |
+
error,
|
| 9 |
+
reset,
|
| 10 |
+
}: {
|
| 11 |
+
error: Error & { digest?: string };
|
| 12 |
+
reset: () => void;
|
| 13 |
+
}) {
|
| 14 |
+
useEffect(() => {
|
| 15 |
+
console.error(error);
|
| 16 |
+
}, [error]);
|
| 17 |
+
|
| 18 |
+
return (
|
| 19 |
+
<div className="flex min-h-screen flex-col items-center justify-center bg-linear-to-br from-blue-50 via-purple-50 to-pink-50 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 p-4">
|
| 20 |
+
<div className="mx-auto max-w-md text-center">
|
| 21 |
+
<div className="mb-8">
|
| 22 |
+
<div className="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/20">
|
| 23 |
+
<svg
|
| 24 |
+
className="h-10 w-10 text-red-600 dark:text-red-400"
|
| 25 |
+
fill="none"
|
| 26 |
+
viewBox="0 0 24 24"
|
| 27 |
+
stroke="currentColor"
|
| 28 |
+
>
|
| 29 |
+
<path
|
| 30 |
+
strokeLinecap="round"
|
| 31 |
+
strokeLinejoin="round"
|
| 32 |
+
strokeWidth={2}
|
| 33 |
+
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
| 34 |
+
/>
|
| 35 |
+
</svg>
|
| 36 |
+
</div>
|
| 37 |
+
<h1 className="mb-2 text-4xl font-bold">Oops! Something went wrong</h1>
|
| 38 |
+
<p className="text-muted-foreground">
|
| 39 |
+
We're sorry for the inconvenience. An error occurred while processing your
|
| 40 |
+
request.
|
| 41 |
+
</p>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<div className="flex flex-col gap-3 sm:flex-row sm:justify-center">
|
| 45 |
+
<Button onClick={reset} size="lg">
|
| 46 |
+
Try Again
|
| 47 |
+
</Button>
|
| 48 |
+
<Link href="/dashboard">
|
| 49 |
+
<Button variant="outline" size="lg">
|
| 50 |
+
Go to Dashboard
|
| 51 |
+
</Button>
|
| 52 |
+
</Link>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
{process.env.NODE_ENV === "development" && (
|
| 56 |
+
<div className="mt-8 rounded-lg bg-red-50 dark:bg-red-900/10 p-4 text-left">
|
| 57 |
+
<p className="text-sm font-medium text-red-800 dark:text-red-400 mb-2">
|
| 58 |
+
Error Details (Development Only)
|
| 59 |
+
</p>
|
| 60 |
+
<pre className="text-xs text-red-700 dark:text-red-300 overflow-auto">
|
| 61 |
+
{error.message}
|
| 62 |
+
</pre>
|
| 63 |
+
</div>
|
| 64 |
+
)}
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
);
|
| 68 |
+
}
|
app/favicon.ico
ADDED
|
|
app/feedback/page.tsx
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import Link from "next/link";
|
| 5 |
+
import { Button } from "@/components/ui/button";
|
| 6 |
+
import { Input } from "@/components/ui/input";
|
| 7 |
+
import { Textarea } from "@/components/ui/textarea";
|
| 8 |
+
import { Label } from "@/components/ui/label";
|
| 9 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 10 |
+
import { Mail, MessageSquare, Send } from "lucide-react";
|
| 11 |
+
|
| 12 |
+
export default function FeedbackPage() {
|
| 13 |
+
const [formData, setFormData] = useState({
|
| 14 |
+
name: "",
|
| 15 |
+
email: "",
|
| 16 |
+
subject: "",
|
| 17 |
+
message: "",
|
| 18 |
+
});
|
| 19 |
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
| 20 |
+
const [submitted, setSubmitted] = useState(false);
|
| 21 |
+
|
| 22 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 23 |
+
e.preventDefault();
|
| 24 |
+
setIsSubmitting(true);
|
| 25 |
+
|
| 26 |
+
try {
|
| 27 |
+
const res = await fetch("/api/feedback", {
|
| 28 |
+
method: "POST",
|
| 29 |
+
headers: { "Content-Type": "application/json" },
|
| 30 |
+
body: JSON.stringify({
|
| 31 |
+
...formData,
|
| 32 |
+
type: "contact"
|
| 33 |
+
})
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
if (!res.ok) throw new Error("Failed to send message");
|
| 37 |
+
|
| 38 |
+
setSubmitted(true);
|
| 39 |
+
setFormData({ name: "", email: "", subject: "", message: "" });
|
| 40 |
+
|
| 41 |
+
// Reset success message after 5 seconds
|
| 42 |
+
//setTimeout(() => setSubmitted(false), 5000);
|
| 43 |
+
} catch (error) {
|
| 44 |
+
console.error("Failed to submit feedback", error);
|
| 45 |
+
// Optional: show error toast or state
|
| 46 |
+
} finally {
|
| 47 |
+
setIsSubmitting(false);
|
| 48 |
+
}
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
| 52 |
+
setFormData(prev => ({
|
| 53 |
+
...prev,
|
| 54 |
+
[e.target.name]: e.target.value
|
| 55 |
+
}));
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
return (
|
| 59 |
+
<div className="min-h-screen bg-background">
|
| 60 |
+
<div className="max-w-5xl mx-auto px-4 py-16 sm:px-6 lg:px-8">
|
| 61 |
+
<div className="space-y-8">
|
| 62 |
+
{/* Header */}
|
| 63 |
+
<div className="space-y-4 text-center">
|
| 64 |
+
<Link href="/" className="text-sm text-primary hover:underline inline-block">
|
| 65 |
+
← Back to Home
|
| 66 |
+
</Link>
|
| 67 |
+
<h1 className="text-4xl font-bold tracking-tight">Contact & Feedback</h1>
|
| 68 |
+
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
| 69 |
+
We'd love to hear from you! Whether you have a question, feedback, or need support, feel free to reach out.
|
| 70 |
+
</p>
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
<div className="grid gap-8 md:grid-cols-2">
|
| 74 |
+
{/* Contact Information */}
|
| 75 |
+
<div className="space-y-6">
|
| 76 |
+
<Card>
|
| 77 |
+
<CardHeader>
|
| 78 |
+
<CardTitle className="flex items-center gap-2">
|
| 79 |
+
<Mail className="h-5 w-5" />
|
| 80 |
+
Get in Touch
|
| 81 |
+
</CardTitle>
|
| 82 |
+
<CardDescription>
|
| 83 |
+
Choose your preferred way to contact us
|
| 84 |
+
</CardDescription>
|
| 85 |
+
</CardHeader>
|
| 86 |
+
<CardContent className="space-y-4">
|
| 87 |
+
<div className="space-y-2">
|
| 88 |
+
<h3 className="font-semibold">General Inquiries</h3>
|
| 89 |
+
<p className="text-sm text-muted-foreground">
|
| 90 |
+
Email: <a href="mailto:hello@autoloop.com" className="text-primary hover:underline">hello@autoloop.com</a>
|
| 91 |
+
</p>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<div className="space-y-2">
|
| 95 |
+
<h3 className="font-semibold">Support</h3>
|
| 96 |
+
<p className="text-sm text-muted-foreground">
|
| 97 |
+
Email: <a href="mailto:support@autoloop.com" className="text-primary hover:underline">support@autoloop.com</a>
|
| 98 |
+
</p>
|
| 99 |
+
<p className="text-sm text-muted-foreground">
|
| 100 |
+
Response time: Within 24 hours
|
| 101 |
+
</p>
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
+
<div className="space-y-2">
|
| 105 |
+
<h3 className="font-semibold">Sales</h3>
|
| 106 |
+
<p className="text-sm text-muted-foreground">
|
| 107 |
+
Email: <a href="mailto:sales@autoloop.com" className="text-primary hover:underline">sales@autoloop.com</a>
|
| 108 |
+
</p>
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
<div className="space-y-2">
|
| 112 |
+
<h3 className="font-semibold">Privacy & Legal</h3>
|
| 113 |
+
<p className="text-sm text-muted-foreground">
|
| 114 |
+
Email: <a href="mailto:legal@autoloop.com" className="text-primary hover:underline">legal@autoloop.com</a>
|
| 115 |
+
</p>
|
| 116 |
+
</div>
|
| 117 |
+
</CardContent>
|
| 118 |
+
</Card>
|
| 119 |
+
|
| 120 |
+
<Card>
|
| 121 |
+
<CardHeader>
|
| 122 |
+
<CardTitle className="flex items-center gap-2">
|
| 123 |
+
<MessageSquare className="h-5 w-5" />
|
| 124 |
+
What We Value in Feedback
|
| 125 |
+
</CardTitle>
|
| 126 |
+
</CardHeader>
|
| 127 |
+
<CardContent>
|
| 128 |
+
<ul className="space-y-2 text-sm text-muted-foreground">
|
| 129 |
+
<li className="flex items-start gap-2">
|
| 130 |
+
<span className="text-primary mt-1">•</span>
|
| 131 |
+
<span>Feature requests and suggestions</span>
|
| 132 |
+
</li>
|
| 133 |
+
<li className="flex items-start gap-2">
|
| 134 |
+
<span className="text-primary mt-1">•</span>
|
| 135 |
+
<span>Bug reports and technical issues</span>
|
| 136 |
+
</li>
|
| 137 |
+
<li className="flex items-start gap-2">
|
| 138 |
+
<span className="text-primary mt-1">•</span>
|
| 139 |
+
<span>User experience improvements</span>
|
| 140 |
+
</li>
|
| 141 |
+
<li className="flex items-start gap-2">
|
| 142 |
+
<span className="text-primary mt-1">•</span>
|
| 143 |
+
<span>Integration requests</span>
|
| 144 |
+
</li>
|
| 145 |
+
<li className="flex items-start gap-2">
|
| 146 |
+
<span className="text-primary mt-1">•</span>
|
| 147 |
+
<span>General feedback and testimonials</span>
|
| 148 |
+
</li>
|
| 149 |
+
</ul>
|
| 150 |
+
</CardContent>
|
| 151 |
+
</Card>
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
{/* Contact Form */}
|
| 155 |
+
<Card>
|
| 156 |
+
<CardHeader>
|
| 157 |
+
<CardTitle>Send us a Message</CardTitle>
|
| 158 |
+
<CardDescription>
|
| 159 |
+
Fill out the form below and we'll get back to you soon
|
| 160 |
+
</CardDescription>
|
| 161 |
+
</CardHeader>
|
| 162 |
+
<CardContent>
|
| 163 |
+
{submitted ? (
|
| 164 |
+
<div className="bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800 rounded-lg p-6 text-center">
|
| 165 |
+
<div className="flex justify-center mb-3">
|
| 166 |
+
<div className="h-12 w-12 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
| 167 |
+
<Send className="h-6 w-6 text-green-600 dark:text-green-400" />
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
<h3 className="font-semibold text-green-900 dark:text-green-100 mb-2">
|
| 171 |
+
Message Sent!
|
| 172 |
+
</h3>
|
| 173 |
+
<p className="text-sm text-green-700 dark:text-green-300">
|
| 174 |
+
Thank you for your feedback. We'll get back to you as soon as possible.
|
| 175 |
+
</p>
|
| 176 |
+
</div>
|
| 177 |
+
) : (
|
| 178 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 179 |
+
<div className="space-y-2">
|
| 180 |
+
<Label htmlFor="name">Name</Label>
|
| 181 |
+
<Input
|
| 182 |
+
id="name"
|
| 183 |
+
name="name"
|
| 184 |
+
placeholder="Your name"
|
| 185 |
+
value={formData.name}
|
| 186 |
+
onChange={handleChange}
|
| 187 |
+
required
|
| 188 |
+
/>
|
| 189 |
+
</div>
|
| 190 |
+
|
| 191 |
+
<div className="space-y-2">
|
| 192 |
+
<Label htmlFor="email">Email</Label>
|
| 193 |
+
<Input
|
| 194 |
+
id="email"
|
| 195 |
+
name="email"
|
| 196 |
+
type="email"
|
| 197 |
+
placeholder="your@email.com"
|
| 198 |
+
value={formData.email}
|
| 199 |
+
onChange={handleChange}
|
| 200 |
+
required
|
| 201 |
+
/>
|
| 202 |
+
</div>
|
| 203 |
+
|
| 204 |
+
<div className="space-y-2">
|
| 205 |
+
<Label htmlFor="subject">Subject</Label>
|
| 206 |
+
<Input
|
| 207 |
+
id="subject"
|
| 208 |
+
name="subject"
|
| 209 |
+
placeholder="What is this about?"
|
| 210 |
+
value={formData.subject}
|
| 211 |
+
onChange={handleChange}
|
| 212 |
+
required
|
| 213 |
+
/>
|
| 214 |
+
</div>
|
| 215 |
+
|
| 216 |
+
<div className="space-y-2">
|
| 217 |
+
<Label htmlFor="message">Message</Label>
|
| 218 |
+
<Textarea
|
| 219 |
+
id="message"
|
| 220 |
+
name="message"
|
| 221 |
+
placeholder="Tell us more..."
|
| 222 |
+
rows={6}
|
| 223 |
+
value={formData.message}
|
| 224 |
+
onChange={handleChange}
|
| 225 |
+
required
|
| 226 |
+
/>
|
| 227 |
+
</div>
|
| 228 |
+
|
| 229 |
+
<Button
|
| 230 |
+
type="submit"
|
| 231 |
+
className="w-full"
|
| 232 |
+
disabled={isSubmitting}
|
| 233 |
+
>
|
| 234 |
+
{isSubmitting ? (
|
| 235 |
+
<>
|
| 236 |
+
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-background border-t-transparent" />
|
| 237 |
+
Sending...
|
| 238 |
+
</>
|
| 239 |
+
) : (
|
| 240 |
+
<>
|
| 241 |
+
<Send className="mr-2 h-4 w-4" />
|
| 242 |
+
Send Message
|
| 243 |
+
</>
|
| 244 |
+
)}
|
| 245 |
+
</Button>
|
| 246 |
+
</form>
|
| 247 |
+
)}
|
| 248 |
+
</CardContent>
|
| 249 |
+
</Card>
|
| 250 |
+
</div>
|
| 251 |
+
|
| 252 |
+
{/* FAQ Section */}
|
| 253 |
+
<div className="mt-12">
|
| 254 |
+
<h2 className="text-2xl font-bold mb-6 text-center">Frequently Asked Questions</h2>
|
| 255 |
+
<div className="grid gap-6 md:grid-cols-2">
|
| 256 |
+
<Card>
|
| 257 |
+
<CardHeader>
|
| 258 |
+
<CardTitle className="text-lg">How quickly will I get a response?</CardTitle>
|
| 259 |
+
</CardHeader>
|
| 260 |
+
<CardContent>
|
| 261 |
+
<p className="text-sm text-muted-foreground">
|
| 262 |
+
We aim to respond to all inquiries within 24 hours during business days. For urgent matters, please mark your email subject with "URGENT".
|
| 263 |
+
</p>
|
| 264 |
+
</CardContent>
|
| 265 |
+
</Card>
|
| 266 |
+
|
| 267 |
+
<Card>
|
| 268 |
+
<CardHeader>
|
| 269 |
+
<CardTitle className="text-lg">Can I request a feature?</CardTitle>
|
| 270 |
+
</CardHeader>
|
| 271 |
+
<CardContent>
|
| 272 |
+
<p className="text-sm text-muted-foreground">
|
| 273 |
+
Absolutely! We love hearing feature requests from our users. Please provide as much detail as possible about what you'd like to see.
|
| 274 |
+
</p>
|
| 275 |
+
</CardContent>
|
| 276 |
+
</Card>
|
| 277 |
+
|
| 278 |
+
<Card>
|
| 279 |
+
<CardHeader>
|
| 280 |
+
<CardTitle className="text-lg">Do you offer phone support?</CardTitle>
|
| 281 |
+
</CardHeader>
|
| 282 |
+
<CardContent>
|
| 283 |
+
<p className="text-sm text-muted-foreground">
|
| 284 |
+
Currently, we provide support via email. For enterprise customers, we offer dedicated account managers with phone support.
|
| 285 |
+
</p>
|
| 286 |
+
</CardContent>
|
| 287 |
+
</Card>
|
| 288 |
+
|
| 289 |
+
<Card>
|
| 290 |
+
<CardHeader>
|
| 291 |
+
<CardTitle className="text-lg">How do I report a bug?</CardTitle>
|
| 292 |
+
</CardHeader>
|
| 293 |
+
<CardContent>
|
| 294 |
+
<p className="text-sm text-muted-foreground">
|
| 295 |
+
Please email us at support@autoloop.com with details about the bug, steps to reproduce it, and any screenshots if possible.
|
| 296 |
+
</p>
|
| 297 |
+
</CardContent>
|
| 298 |
+
</Card>
|
| 299 |
+
</div>
|
| 300 |
+
</div>
|
| 301 |
+
</div>
|
| 302 |
+
</div>
|
| 303 |
+
</div>
|
| 304 |
+
);
|
| 305 |
+
}
|
app/globals.css
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
@import './animations.css';
|
| 4 |
+
|
| 5 |
+
@theme {
|
| 6 |
+
--color-background: hsl(var(--background));
|
| 7 |
+
--color-foreground: hsl(var(--foreground));
|
| 8 |
+
|
| 9 |
+
--color-card: hsl(var(--card));
|
| 10 |
+
--color-card-foreground: hsl(var(--card-foreground));
|
| 11 |
+
|
| 12 |
+
--color-popover: hsl(var(--popover));
|
| 13 |
+
--color-popover-foreground: hsl(var(--popover-foreground));
|
| 14 |
+
|
| 15 |
+
--color-primary: hsl(var(--primary));
|
| 16 |
+
--color-primary-foreground: hsl(var(--primary-foreground));
|
| 17 |
+
|
| 18 |
+
--color-secondary: hsl(var(--secondary));
|
| 19 |
+
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
| 20 |
+
|
| 21 |
+
--color-muted: hsl(var(--muted));
|
| 22 |
+
--color-muted-foreground: hsl(var(--muted-foreground));
|
| 23 |
+
|
| 24 |
+
--color-accent: hsl(var(--accent));
|
| 25 |
+
--color-accent-foreground: hsl(var(--accent-foreground));
|
| 26 |
+
|
| 27 |
+
--color-destructive: hsl(var(--destructive));
|
| 28 |
+
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
| 29 |
+
|
| 30 |
+
--color-border: hsl(var(--border));
|
| 31 |
+
--color-input: hsl(var(--input));
|
| 32 |
+
--color-ring: hsl(var(--ring));
|
| 33 |
+
|
| 34 |
+
--radius-lg: var(--radius);
|
| 35 |
+
--radius-md: calc(var(--radius) - 2px);
|
| 36 |
+
--radius-sm: calc(var(--radius) - 4px);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
@layer base {
|
| 40 |
+
:root {
|
| 41 |
+
--background: 0 0% 100%;
|
| 42 |
+
--foreground: 222.2 84% 4.9%;
|
| 43 |
+
--card: 0 0% 100%;
|
| 44 |
+
--card-foreground: 222.2 84% 4.9%;
|
| 45 |
+
--popover: 0 0% 100%;
|
| 46 |
+
--popover-foreground: 222.2 84% 4.9%;
|
| 47 |
+
--primary: 221.2 83.2% 53.3%;
|
| 48 |
+
--primary-foreground: 210 40% 98%;
|
| 49 |
+
--secondary: 210 40% 96.1%;
|
| 50 |
+
--secondary-foreground: 222.2 47.4% 11.2%;
|
| 51 |
+
--muted: 210 40% 96.1%;
|
| 52 |
+
--muted-foreground: 215.4 16.3% 46.9%;
|
| 53 |
+
--accent: 210 40% 96.1%;
|
| 54 |
+
--accent-foreground: 222.2 47.4% 11.2%;
|
| 55 |
+
--destructive: 0 84.2% 60.2%;
|
| 56 |
+
--destructive-foreground: 210 40% 98%;
|
| 57 |
+
--border: 214.3 31.8% 91.4%;
|
| 58 |
+
--input: 214.3 31.8% 91.4%;
|
| 59 |
+
--ring: 221.2 83.2% 53.3%;
|
| 60 |
+
--radius: 0.5rem;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.dark {
|
| 64 |
+
--background: 222.2 84% 4.9%;
|
| 65 |
+
--foreground: 210 40% 98%;
|
| 66 |
+
--card: 222.2 84% 4.9%;
|
| 67 |
+
--card-foreground: 210 40% 98%;
|
| 68 |
+
--popover: 222.2 84% 4.9%;
|
| 69 |
+
--popover-foreground: 210 40% 98%;
|
| 70 |
+
--primary: 217.2 91.2% 59.8%;
|
| 71 |
+
--primary-foreground: 222.2 47.4% 11.2%;
|
| 72 |
+
--secondary: 217.2 32.6% 17.5%;
|
| 73 |
+
--secondary-foreground: 210 40% 98%;
|
| 74 |
+
--muted: 217.2 32.6% 17.5%;
|
| 75 |
+
--muted-foreground: 215 20.2% 65.1%;
|
| 76 |
+
--accent: 217.2 32.6% 17.5%;
|
| 77 |
+
--accent-foreground: 210 40% 98%;
|
| 78 |
+
--destructive: 0 62.8% 30.6%;
|
| 79 |
+
--destructive-foreground: 210 40% 98%;
|
| 80 |
+
--border: 217.2 32.6% 17.5%;
|
| 81 |
+
--input: 217.2 32.6% 17.5%;
|
| 82 |
+
--ring: 224.3 76.3% 48%;
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
@layer base {
|
| 87 |
+
* {
|
| 88 |
+
@apply border-border;
|
| 89 |
+
}
|
| 90 |
+
body {
|
| 91 |
+
@apply bg-background text-foreground;
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
/* Custom scrollbar */
|
| 96 |
+
::-webkit-scrollbar {
|
| 97 |
+
width: 10px;
|
| 98 |
+
height: 10px;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
::-webkit-scrollbar-track {
|
| 102 |
+
background: hsl(var(--muted));
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
::-webkit-scrollbar-thumb {
|
| 106 |
+
background: hsl(var(--muted-foreground) / 0.3);
|
| 107 |
+
border-radius: 5px;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
::-webkit-scrollbar-thumb:hover {
|
| 111 |
+
background: hsl(var(--muted-foreground) / 0.5);
|
| 112 |
+
}
|