Compare commits
22 Commits
feature/ac
...
33e07fe64f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33e07fe64f | ||
|
|
18eeda62e8 | ||
|
|
7b380fa903 | ||
|
|
ac3115a5a1 | ||
|
|
99f8271003 | ||
|
|
8564b1deba | ||
|
|
7baf110235 | ||
|
|
c88b77a804 | ||
|
|
6d7d1607b2 | ||
|
|
0f47f118f7 | ||
|
|
f8d8419622 | ||
|
|
2a33e4cf57 | ||
|
|
ab87a4b621 | ||
|
|
07f49cb457 | ||
|
|
e93a7a305d | ||
|
|
a8d176b4ec | ||
|
|
30701cddfb | ||
|
|
fe7b93c7ff | ||
|
|
2d382fd1d4 | ||
|
|
b2c6979338 | ||
|
|
2417bb8313 | ||
|
|
f3e1b8f8bf |
712
DEPLOYMENT.md
@@ -1,322 +1,381 @@
|
||||
# SmoothSchedule Production Deployment Guide
|
||||
|
||||
This guide covers deploying SmoothSchedule to a production server.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Prerequisites](#prerequisites)
|
||||
2. [Quick Reference](#quick-reference)
|
||||
3. [Initial Server Setup](#initial-server-setup-first-time-only)
|
||||
4. [Regular Deployments](#regular-deployments)
|
||||
5. [Activepieces Updates](#activepieces-updates)
|
||||
6. [Troubleshooting](#troubleshooting)
|
||||
7. [Maintenance](#maintenance)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Server Requirements
|
||||
- Ubuntu/Debian Linux server
|
||||
- Minimum 2GB RAM, 20GB disk space
|
||||
- Docker and Docker Compose installed
|
||||
- Domain name pointed to server IP: `smoothschedule.com`
|
||||
- DNS configured with wildcard subdomain: `*.smoothschedule.com`
|
||||
- Ubuntu 20.04+ or Debian 11+
|
||||
- 4GB RAM minimum (2GB works but cannot build Activepieces image)
|
||||
- 40GB disk space
|
||||
- Docker and Docker Compose v2 installed
|
||||
- Domain with wildcard DNS configured
|
||||
|
||||
### Local Requirements (for deployment)
|
||||
- Git access to the repository
|
||||
- SSH access to the production server
|
||||
- Docker (for building Activepieces image)
|
||||
|
||||
### Required Accounts/Services
|
||||
- [x] DigitalOcean Spaces (already configured)
|
||||
- Access Key: DO801P4R8QXYMY4CE8WZ
|
||||
- Bucket: smoothschedule
|
||||
- Region: nyc3
|
||||
- [ ] Email service (optional - Mailgun or SMTP)
|
||||
- [ ] Sentry (optional - error tracking)
|
||||
- DigitalOcean Spaces (for static/media files)
|
||||
- Stripe (for payments)
|
||||
- Twilio (for SMS/phone features)
|
||||
- OpenAI API (optional, for Activepieces AI copilot)
|
||||
|
||||
## Pre-Deployment Checklist
|
||||
|
||||
### 1. DigitalOcean Spaces Setup
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Create the bucket (if not already created)
|
||||
aws --profile do-tor1 s3 mb s3://smoothschedule
|
||||
# Regular deployment (after initial setup)
|
||||
./deploy.sh
|
||||
|
||||
# Set bucket to public-read for static/media files
|
||||
aws --profile do-tor1 s3api put-bucket-acl \
|
||||
--bucket smoothschedule \
|
||||
--acl public-read
|
||||
# Deploy with Activepieces image rebuild
|
||||
./deploy.sh --deploy-ap
|
||||
|
||||
# Configure CORS (for frontend uploads)
|
||||
cat > cors.json <<EOF
|
||||
{
|
||||
"CORSRules": [
|
||||
{
|
||||
"AllowedOrigins": ["https://smoothschedule.com", "https://*.smoothschedule.com"],
|
||||
"AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
|
||||
"AllowedHeaders": ["*"],
|
||||
"MaxAgeSeconds": 3000
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
# Deploy specific services only
|
||||
./deploy.sh django nginx
|
||||
|
||||
aws --profile do-tor1 s3api put-bucket-cors \
|
||||
--bucket smoothschedule \
|
||||
--cors-configuration file://cors.json
|
||||
# Skip migrations (config changes only)
|
||||
./deploy.sh --no-migrate
|
||||
```
|
||||
|
||||
### 2. DNS Configuration
|
||||
## Initial Server Setup (First Time Only)
|
||||
|
||||
Configure these DNS records at your domain registrar:
|
||||
|
||||
```
|
||||
Type Name Value TTL
|
||||
A smoothschedule.com YOUR_SERVER_IP 300
|
||||
A *.smoothschedule.com YOUR_SERVER_IP 300
|
||||
CNAME www smoothschedule.com 300
|
||||
```
|
||||
|
||||
### 3. Environment Variables Review
|
||||
|
||||
**Backend** (`.envs/.production/.django`):
|
||||
- [x] DJANGO_SECRET_KEY - Set
|
||||
- [x] DJANGO_ALLOWED_HOSTS - Set to `.smoothschedule.com`
|
||||
- [x] DJANGO_AWS_ACCESS_KEY_ID - Set
|
||||
- [x] DJANGO_AWS_SECRET_ACCESS_KEY - Set
|
||||
- [x] DJANGO_AWS_STORAGE_BUCKET_NAME - Set to `smoothschedule`
|
||||
- [x] DJANGO_AWS_S3_ENDPOINT_URL - Set to `https://nyc3.digitaloceanspaces.com`
|
||||
- [x] DJANGO_AWS_S3_REGION_NAME - Set to `nyc3`
|
||||
- [ ] MAILGUN_API_KEY - Optional (for email)
|
||||
- [ ] MAILGUN_DOMAIN - Optional (for email)
|
||||
- [ ] SENTRY_DSN - Optional (for error tracking)
|
||||
|
||||
**Frontend** (`.env.production`):
|
||||
- [x] VITE_API_URL - Set to `https://smoothschedule.com/api`
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
### Step 1: Server Preparation
|
||||
### 1. Server Preparation
|
||||
|
||||
```bash
|
||||
# SSH into production server
|
||||
ssh poduck@smoothschedule.com
|
||||
ssh your-user@your-server
|
||||
|
||||
# Install Docker (if not already installed)
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sudo sh get-docker.sh
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# Install Docker Compose
|
||||
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
|
||||
# Logout and login again for group changes to take effect
|
||||
# Logout and login again for group changes
|
||||
exit
|
||||
ssh poduck@smoothschedule.com
|
||||
ssh your-user@your-server
|
||||
```
|
||||
|
||||
### Step 2: Deploy Backend (Django)
|
||||
### 2. Clone Repository
|
||||
|
||||
```bash
|
||||
# Create deployment directory
|
||||
mkdir -p ~/smoothschedule
|
||||
git clone https://your-repo-url ~/smoothschedule
|
||||
cd ~/smoothschedule/smoothschedule
|
||||
```
|
||||
|
||||
### 3. Create Environment Files
|
||||
|
||||
Copy the template files and fill in your values:
|
||||
|
||||
```bash
|
||||
mkdir -p .envs/.production
|
||||
cp .envs.example/.django .envs/.production/.django
|
||||
cp .envs.example/.postgres .envs/.production/.postgres
|
||||
cp .envs.example/.activepieces .envs/.production/.activepieces
|
||||
```
|
||||
|
||||
Edit each file with your production values:
|
||||
|
||||
```bash
|
||||
nano .envs/.production/.django
|
||||
nano .envs/.production/.postgres
|
||||
nano .envs/.production/.activepieces
|
||||
```
|
||||
|
||||
**Key values to configure:**
|
||||
|
||||
| File | Variable | Description |
|
||||
|------|----------|-------------|
|
||||
| `.django` | `DJANGO_SECRET_KEY` | Generate: `openssl rand -hex 32` |
|
||||
| `.django` | `DJANGO_ALLOWED_HOSTS` | `.yourdomain.com` |
|
||||
| `.django` | `STRIPE_*` | Your Stripe keys (live keys for production) |
|
||||
| `.django` | `TWILIO_*` | Your Twilio credentials |
|
||||
| `.django` | `AWS_*` | DigitalOcean Spaces credentials |
|
||||
| `.postgres` | `POSTGRES_USER` | Generate random username |
|
||||
| `.postgres` | `POSTGRES_PASSWORD` | Generate: `openssl rand -hex 32` |
|
||||
| `.activepieces` | `AP_JWT_SECRET` | Generate: `openssl rand -hex 32` |
|
||||
| `.activepieces` | `AP_ENCRYPTION_KEY` | Generate: `openssl rand -hex 16` |
|
||||
| `.activepieces` | `AP_POSTGRES_USERNAME` | Generate random username |
|
||||
| `.activepieces` | `AP_POSTGRES_PASSWORD` | Generate: `openssl rand -hex 32` |
|
||||
|
||||
**Important:** `AP_JWT_SECRET` must be copied to `.django` as well!
|
||||
|
||||
### 4. DNS Configuration
|
||||
|
||||
Configure these DNS records:
|
||||
|
||||
```
|
||||
Type Name Value TTL
|
||||
A yourdomain.com YOUR_SERVER_IP 300
|
||||
A *.yourdomain.com YOUR_SERVER_IP 300
|
||||
CNAME www yourdomain.com 300
|
||||
```
|
||||
|
||||
### 5. Build Activepieces Image (on your local machine)
|
||||
|
||||
The production server typically cannot build this image (requires 4GB+ RAM):
|
||||
|
||||
```bash
|
||||
# On your LOCAL machine, not the server
|
||||
cd ~/smoothschedule
|
||||
|
||||
# Clone the repository (or upload files via rsync/git)
|
||||
# Option A: Clone from Git
|
||||
git clone <your-repo-url> .
|
||||
git checkout main
|
||||
|
||||
# Option B: Copy from local machine
|
||||
# From your local machine:
|
||||
# rsync -avz --exclude 'node_modules' --exclude '.venv' --exclude '__pycache__' \
|
||||
# /home/poduck/Desktop/smoothschedule2/ poduck@smoothschedule.com:~/smoothschedule/
|
||||
|
||||
# Navigate to backend
|
||||
cd smoothschedule
|
||||
|
||||
# Build and start containers
|
||||
docker compose -f docker-compose.production.yml build
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
|
||||
# Wait for containers to start
|
||||
sleep 10
|
||||
|
||||
# Check logs
|
||||
docker compose -f docker-compose.production.yml logs -f
|
||||
./scripts/build-activepieces.sh deploy
|
||||
```
|
||||
|
||||
### Step 3: Database Initialization
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
# Run migrations
|
||||
docker compose -f docker-compose.production.yml exec django python manage.py migrate
|
||||
|
||||
# Create public schema (for multi-tenancy)
|
||||
docker compose -f docker-compose.production.yml exec django python manage.py migrate_schemas --shared
|
||||
|
||||
# Create superuser
|
||||
docker compose -f docker-compose.production.yml exec django python manage.py createsuperuser
|
||||
|
||||
# Collect static files (uploads to DigitalOcean Spaces)
|
||||
docker compose -f docker-compose.production.yml exec django python manage.py collectstatic --noinput
|
||||
cd activepieces-fork
|
||||
docker build -t smoothschedule_production_activepieces .
|
||||
docker save smoothschedule_production_activepieces | gzip > /tmp/ap.tar.gz
|
||||
scp /tmp/ap.tar.gz your-user@your-server:/tmp/
|
||||
ssh your-user@your-server 'gunzip -c /tmp/ap.tar.gz | docker load'
|
||||
```
|
||||
|
||||
### Step 4: Create Initial Tenant
|
||||
### 6. Run Initialization Script
|
||||
|
||||
```bash
|
||||
# On the server
|
||||
cd ~/smoothschedule/smoothschedule
|
||||
chmod +x scripts/init-production.sh
|
||||
./scripts/init-production.sh
|
||||
```
|
||||
|
||||
This script will:
|
||||
1. Verify environment files
|
||||
2. Generate any missing security keys
|
||||
3. Start PostgreSQL and Redis
|
||||
4. Create the Activepieces database
|
||||
5. Start all services
|
||||
6. Run Django migrations
|
||||
7. Guide you through Activepieces platform setup
|
||||
|
||||
### 7. Complete Activepieces Platform Setup
|
||||
|
||||
After the init script completes:
|
||||
|
||||
1. Visit `https://automations.yourdomain.com`
|
||||
2. Create an admin account (this creates the platform)
|
||||
3. Get the platform ID:
|
||||
```bash
|
||||
docker compose -f docker-compose.production.yml exec postgres \
|
||||
psql -U <ap_db_user> -d activepieces -c "SELECT id FROM platform"
|
||||
```
|
||||
4. Update `AP_PLATFORM_ID` in both:
|
||||
- `.envs/.production/.activepieces`
|
||||
- `.envs/.production/.django`
|
||||
5. Restart services:
|
||||
```bash
|
||||
docker compose -f docker-compose.production.yml restart
|
||||
```
|
||||
|
||||
### 8. Create First Tenant
|
||||
|
||||
```bash
|
||||
# Access Django shell
|
||||
docker compose -f docker-compose.production.yml exec django python manage.py shell
|
||||
|
||||
# In the shell, create your first business tenant:
|
||||
```
|
||||
|
||||
```python
|
||||
from core.models import Business
|
||||
from django.contrib.auth import get_user_model
|
||||
from smoothschedule.identity.core.models import Tenant, Domain
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
# Create a business
|
||||
business = Business.objects.create(
|
||||
# Create tenant
|
||||
tenant = Tenant.objects.create(
|
||||
name="Demo Business",
|
||||
subdomain="demo",
|
||||
schema_name="demo",
|
||||
schema_name="demo"
|
||||
)
|
||||
|
||||
# Verify it was created
|
||||
print(f"Created business: {business.name} at {business.subdomain}.smoothschedule.com")
|
||||
|
||||
# Create a business owner
|
||||
owner = User.objects.create_user(
|
||||
username="demo_owner",
|
||||
email="owner@demo.com",
|
||||
password="your_password_here",
|
||||
role="owner",
|
||||
business_subdomain="demo"
|
||||
# Create domain
|
||||
Domain.objects.create(
|
||||
tenant=tenant,
|
||||
domain="demo.yourdomain.com",
|
||||
is_primary=True
|
||||
)
|
||||
|
||||
print(f"Created owner: {owner.username}")
|
||||
exit()
|
||||
```
|
||||
|
||||
### Step 5: Deploy Frontend
|
||||
### 9. Provision Activepieces Connection
|
||||
|
||||
```bash
|
||||
# On your local machine
|
||||
cd /home/poduck/Desktop/smoothschedule2/frontend
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Upload build files to server
|
||||
rsync -avz dist/ poduck@smoothschedule.com:~/smoothschedule-frontend/
|
||||
|
||||
# On the server, set up nginx or serve via backend
|
||||
docker compose -f docker-compose.production.yml exec django \
|
||||
python manage.py provision_ap_connections --tenant demo
|
||||
```
|
||||
|
||||
**Option A: Serve via Django (simpler)**
|
||||
|
||||
The Django `collectstatic` command already handles static files. For serving the frontend:
|
||||
|
||||
1. Copy frontend build to Django static folder
|
||||
2. Django will serve it via Traefik
|
||||
|
||||
**Option B: Separate Nginx (recommended for production)**
|
||||
|
||||
```bash
|
||||
# Install nginx
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y nginx
|
||||
|
||||
# Create nginx config
|
||||
sudo nano /etc/nginx/sites-available/smoothschedule
|
||||
```
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name smoothschedule.com *.smoothschedule.com;
|
||||
|
||||
# Frontend (React)
|
||||
location / {
|
||||
root /home/poduck/smoothschedule-frontend;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Backend API (proxy to Traefik)
|
||||
location /api {
|
||||
proxy_pass http://localhost:80;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /admin {
|
||||
proxy_pass http://localhost:80;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# Enable site
|
||||
sudo ln -s /etc/nginx/sites-available/smoothschedule /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### Step 6: SSL/HTTPS Setup
|
||||
|
||||
Traefik is configured to automatically obtain Let's Encrypt SSL certificates. Ensure:
|
||||
|
||||
1. DNS is pointed to your server
|
||||
2. Ports 80 and 443 are accessible
|
||||
3. Wait for Traefik to obtain certificates (check logs)
|
||||
|
||||
```bash
|
||||
# Monitor Traefik logs
|
||||
docker compose -f docker-compose.production.yml logs -f traefik
|
||||
|
||||
# You should see:
|
||||
# "Certificate obtained for domain smoothschedule.com"
|
||||
```
|
||||
|
||||
### Step 7: Verify Deployment
|
||||
### 10. Verify Deployment
|
||||
|
||||
```bash
|
||||
# Check all containers are running
|
||||
docker compose -f docker-compose.production.yml ps
|
||||
|
||||
# Should show:
|
||||
# - django (running)
|
||||
# - postgres (running)
|
||||
# - redis (running)
|
||||
# - traefik (running)
|
||||
# - celeryworker (running)
|
||||
# - celerybeat (running)
|
||||
# - flower (running)
|
||||
|
||||
# Test API endpoint
|
||||
curl https://smoothschedule.com/api/
|
||||
|
||||
# Test admin
|
||||
curl https://smoothschedule.com/admin/
|
||||
|
||||
# Access in browser:
|
||||
# https://smoothschedule.com - Main site
|
||||
# https://platform.smoothschedule.com - Platform dashboard
|
||||
# https://demo.smoothschedule.com - Demo business
|
||||
# https://smoothschedule.com:5555 - Flower (Celery monitoring)
|
||||
# Test endpoints
|
||||
curl https://yourdomain.com/api/
|
||||
curl https://platform.yourdomain.com/
|
||||
curl https://automations.yourdomain.com/api/v1/health
|
||||
```
|
||||
|
||||
## Post-Deployment
|
||||
## Regular Deployments
|
||||
|
||||
### 1. Monitoring
|
||||
After initial setup, deployments are simple:
|
||||
|
||||
```bash
|
||||
# View logs
|
||||
docker compose -f docker-compose.production.yml logs -f
|
||||
# From your local machine
|
||||
cd ~/smoothschedule
|
||||
|
||||
# View specific service logs
|
||||
docker compose -f docker-compose.production.yml logs -f django
|
||||
docker compose -f docker-compose.production.yml logs -f postgres
|
||||
# Commit and push your changes
|
||||
git add .
|
||||
git commit -m "Your changes"
|
||||
git push
|
||||
|
||||
# Monitor Celery tasks via Flower
|
||||
# Access: https://smoothschedule.com:5555
|
||||
# Login with credentials from .envs/.production/.django
|
||||
# Deploy
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
### 2. Backups
|
||||
### Deployment Options
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `./deploy.sh` | Full deployment with migrations |
|
||||
| `./deploy.sh --no-migrate` | Deploy without running migrations |
|
||||
| `./deploy.sh --deploy-ap` | Rebuild and deploy Activepieces image |
|
||||
| `./deploy.sh django` | Rebuild only Django container |
|
||||
| `./deploy.sh nginx traefik` | Rebuild specific services |
|
||||
|
||||
### What the Deploy Script Does
|
||||
|
||||
1. Checks for uncommitted changes
|
||||
2. Verifies changes are pushed to remote
|
||||
3. (If `--deploy-ap`) Builds and transfers Activepieces image
|
||||
4. SSHs to server and pulls latest code
|
||||
5. Backs up and restores `.envs` directory
|
||||
6. Builds Docker images
|
||||
7. Starts containers
|
||||
8. Sets up Activepieces database (if needed)
|
||||
9. Runs Django migrations (unless `--no-migrate`)
|
||||
10. Seeds platform plugins for all tenants
|
||||
|
||||
## Activepieces Updates
|
||||
|
||||
When you modify custom pieces (in `activepieces-fork/`):
|
||||
|
||||
1. Make your changes to piece code
|
||||
2. Commit and push
|
||||
3. Deploy with the image flag:
|
||||
```bash
|
||||
./deploy.sh --deploy-ap
|
||||
```
|
||||
|
||||
The Activepieces container will:
|
||||
1. Start with the new image
|
||||
2. Run `publish-pieces.sh` to register custom pieces
|
||||
3. Insert piece metadata into the database
|
||||
|
||||
### Custom Pieces
|
||||
|
||||
Custom pieces are located in:
|
||||
- `activepieces-fork/packages/pieces/community/smoothschedule/` - Main SmoothSchedule piece
|
||||
- `activepieces-fork/packages/pieces/community/python-code/` - Python code execution
|
||||
- `activepieces-fork/packages/pieces/community/ruby-code/` - Ruby code execution
|
||||
|
||||
Piece metadata is registered via:
|
||||
- `activepieces-fork/custom-pieces-metadata.sql` - Database registration
|
||||
- `activepieces-fork/publish-pieces.sh` - Container startup script
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### View Logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker compose -f docker-compose.production.yml logs -f
|
||||
|
||||
# Specific service
|
||||
docker compose -f docker-compose.production.yml logs -f django
|
||||
docker compose -f docker-compose.production.yml logs -f activepieces
|
||||
docker compose -f docker-compose.production.yml logs -f traefik
|
||||
```
|
||||
|
||||
### Restart Services
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker compose -f docker-compose.production.yml restart
|
||||
|
||||
# Specific service
|
||||
docker compose -f docker-compose.production.yml restart django
|
||||
docker compose -f docker-compose.production.yml restart activepieces
|
||||
```
|
||||
|
||||
### Django Shell
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.production.yml exec django python manage.py shell
|
||||
```
|
||||
|
||||
### Database Access
|
||||
|
||||
```bash
|
||||
# SmoothSchedule database
|
||||
docker compose -f docker-compose.production.yml exec postgres \
|
||||
psql -U <postgres_user> -d smoothschedule
|
||||
|
||||
# Activepieces database
|
||||
docker compose -f docker-compose.production.yml exec postgres \
|
||||
psql -U <ap_user> -d activepieces
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
**1. Activepieces pieces not showing up**
|
||||
```bash
|
||||
# Check if platform exists
|
||||
docker compose -f docker-compose.production.yml exec postgres \
|
||||
psql -U <ap_user> -d activepieces -c "SELECT id FROM platform"
|
||||
|
||||
# Restart to re-run piece registration
|
||||
docker compose -f docker-compose.production.yml restart activepieces
|
||||
|
||||
# Check logs for errors
|
||||
docker compose -f docker-compose.production.yml logs activepieces | grep -i error
|
||||
```
|
||||
|
||||
**2. 502 Bad Gateway**
|
||||
- Service is still starting, wait a moment
|
||||
- Check container health: `docker compose ps`
|
||||
- Check logs for errors
|
||||
|
||||
**3. Database connection errors**
|
||||
- Verify credentials in `.envs/.production/`
|
||||
- Ensure PostgreSQL is running: `docker compose ps postgres`
|
||||
|
||||
**4. Activepieces embedding not working**
|
||||
- Verify `AP_JWT_SECRET` matches in both `.django` and `.activepieces`
|
||||
- Verify `AP_PLATFORM_ID` is set correctly in both files
|
||||
- Check `AP_EMBEDDING_ENABLED=true` in `.activepieces`
|
||||
|
||||
**5. SSL certificate issues**
|
||||
```bash
|
||||
# Check Traefik logs
|
||||
docker compose -f docker-compose.production.yml logs traefik
|
||||
|
||||
# Verify DNS is pointing to server
|
||||
dig yourdomain.com +short
|
||||
|
||||
# Ensure ports 80 and 443 are open
|
||||
sudo ufw allow 80
|
||||
sudo ufw allow 443
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Backups
|
||||
|
||||
```bash
|
||||
# Database backup
|
||||
@@ -329,121 +388,50 @@ docker compose -f docker-compose.production.yml exec postgres backups
|
||||
docker compose -f docker-compose.production.yml exec postgres restore backup_filename.sql.gz
|
||||
```
|
||||
|
||||
### 3. Updates
|
||||
### Monitoring
|
||||
|
||||
```bash
|
||||
# Pull latest code
|
||||
cd ~/smoothschedule/smoothschedule
|
||||
git pull origin main
|
||||
- **Flower Dashboard**: `https://yourdomain.com:5555` - Celery task monitoring
|
||||
- **Container Status**: `docker compose ps`
|
||||
- **Resource Usage**: `docker stats`
|
||||
|
||||
# Rebuild and restart
|
||||
docker compose -f docker-compose.production.yml build
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
### Security Checklist
|
||||
|
||||
# Run migrations
|
||||
docker compose -f docker-compose.production.yml exec django python manage.py migrate
|
||||
|
||||
# Collect static files
|
||||
docker compose -f docker-compose.production.yml exec django python manage.py collectstatic --noinput
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### SSL Certificate Issues
|
||||
```bash
|
||||
# Check Traefik logs
|
||||
docker compose -f docker-compose.production.yml logs traefik
|
||||
|
||||
# Verify DNS is pointing to server
|
||||
dig smoothschedule.com +short
|
||||
|
||||
# Ensure ports are open
|
||||
sudo ufw allow 80
|
||||
sudo ufw allow 443
|
||||
```
|
||||
|
||||
### Database Connection Issues
|
||||
```bash
|
||||
# Check PostgreSQL is running
|
||||
docker compose -f docker-compose.production.yml ps postgres
|
||||
|
||||
# Check database logs
|
||||
docker compose -f docker-compose.production.yml logs postgres
|
||||
|
||||
# Verify connection
|
||||
docker compose -f docker-compose.production.yml exec django python manage.py dbshell
|
||||
```
|
||||
|
||||
### Static Files Not Loading
|
||||
```bash
|
||||
# Verify DigitalOcean Spaces credentials
|
||||
docker compose -f docker-compose.production.yml exec django python manage.py shell
|
||||
>>> from django.conf import settings
|
||||
>>> print(settings.AWS_ACCESS_KEY_ID)
|
||||
>>> print(settings.AWS_STORAGE_BUCKET_NAME)
|
||||
|
||||
# Re-collect static files
|
||||
docker compose -f docker-compose.production.yml exec django python manage.py collectstatic --noinput
|
||||
|
||||
# Check Spaces bucket
|
||||
aws --profile do-tor1 s3 ls s3://smoothschedule/static/
|
||||
aws --profile do-tor1 s3 ls s3://smoothschedule/media/
|
||||
```
|
||||
|
||||
### Celery Not Running Tasks
|
||||
```bash
|
||||
# Check Celery worker logs
|
||||
docker compose -f docker-compose.production.yml logs celeryworker
|
||||
|
||||
# Access Flower dashboard
|
||||
# https://smoothschedule.com:5555
|
||||
|
||||
# Restart Celery
|
||||
docker compose -f docker-compose.production.yml restart celeryworker celerybeat
|
||||
```
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [x] SSL/HTTPS enabled via Let's Encrypt
|
||||
- [x] DJANGO_SECRET_KEY set to random value
|
||||
- [x] Database password set to random value
|
||||
- [x] Flower dashboard password protected
|
||||
- [ ] Firewall configured (UFW or iptables)
|
||||
- [ ] SSH key-based authentication enabled
|
||||
- [ ] Fail2ban installed for brute-force protection
|
||||
- [x] SSL/HTTPS enabled via Let's Encrypt (automatic with Traefik)
|
||||
- [x] All secret keys are unique random values
|
||||
- [x] Database passwords are strong
|
||||
- [x] Flower dashboard is password protected
|
||||
- [ ] Firewall configured (UFW)
|
||||
- [ ] SSH key-based authentication only
|
||||
- [ ] Regular backups configured
|
||||
- [ ] Sentry error monitoring (optional)
|
||||
- [ ] Monitoring/alerting set up
|
||||
|
||||
## Performance Optimization
|
||||
## File Structure
|
||||
|
||||
1. **Enable CDN for DigitalOcean Spaces**
|
||||
- In Spaces settings, enable CDN
|
||||
- Update `DJANGO_AWS_S3_CUSTOM_DOMAIN=smoothschedule.nyc3.cdn.digitaloceanspaces.com`
|
||||
|
||||
2. **Scale Gunicorn Workers**
|
||||
- Adjust `WEB_CONCURRENCY` in `.envs/.production/.django`
|
||||
- Formula: (2 x CPU cores) + 1
|
||||
|
||||
3. **Add Redis Persistence**
|
||||
- Update docker-compose.production.yml redis config
|
||||
- Enable AOF persistence
|
||||
|
||||
4. **Database Connection Pooling**
|
||||
- Already configured via `CONN_MAX_AGE=60`
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Weekly
|
||||
- Review error logs
|
||||
- Check disk space: `df -h`
|
||||
- Monitor Flower dashboard for failed tasks
|
||||
|
||||
### Monthly
|
||||
- Update Docker images: `docker compose pull`
|
||||
- Update dependencies: `uv sync`
|
||||
- Review backups
|
||||
|
||||
### As Needed
|
||||
- Scale resources (CPU/RAM)
|
||||
- Add more Celery workers
|
||||
- Optimize database queries
|
||||
```
|
||||
smoothschedule/
|
||||
├── deploy.sh # Main deployment script
|
||||
├── DEPLOYMENT.md # This file
|
||||
├── scripts/
|
||||
│ └── build-activepieces.sh # Activepieces image builder
|
||||
├── smoothschedule/
|
||||
│ ├── docker-compose.production.yml
|
||||
│ ├── scripts/
|
||||
│ │ └── init-production.sh # One-time initialization
|
||||
│ ├── .envs/
|
||||
│ │ └── .production/ # Production secrets (NOT in git)
|
||||
│ │ ├── .django
|
||||
│ │ ├── .postgres
|
||||
│ │ └── .activepieces
|
||||
│ └── .envs.example/ # Template files (in git)
|
||||
│ ├── .django
|
||||
│ ├── .postgres
|
||||
│ └── .activepieces
|
||||
└── activepieces-fork/
|
||||
├── Dockerfile
|
||||
├── custom-pieces-metadata.sql
|
||||
├── publish-pieces.sh
|
||||
└── packages/pieces/community/
|
||||
├── smoothschedule/ # Main custom piece
|
||||
├── python-code/
|
||||
└── ruby-code/
|
||||
```
|
||||
|
||||
5
activepieces-fork/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.nx/cache/
|
||||
.nx/workspace-data/
|
||||
dist/
|
||||
package-lock.json
|
||||
@@ -1 +1 @@
|
||||
1766103708902
|
||||
1766280110308
|
||||
@@ -1,11 +1,15 @@
|
||||
FROM node:20.19-bullseye-slim AS base
|
||||
|
||||
# Set environment variables early for better layer caching
|
||||
# Memory optimizations for low-RAM servers (2GB):
|
||||
# - Limit Node.js heap to 1536MB to leave room for system
|
||||
# - Disable NX daemon and cloud to reduce overhead
|
||||
ENV LANG=en_US.UTF-8 \
|
||||
LANGUAGE=en_US:en \
|
||||
LC_ALL=en_US.UTF-8 \
|
||||
NX_DAEMON=false \
|
||||
NX_NO_CLOUD=true
|
||||
NX_NO_CLOUD=true \
|
||||
NODE_OPTIONS="--max-old-space-size=1536"
|
||||
|
||||
# Install all system dependencies in a single layer with cache mounts
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
@@ -14,6 +18,8 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
apt-get install -y --no-install-recommends \
|
||||
openssh-client \
|
||||
python3 \
|
||||
python3-pip \
|
||||
ruby \
|
||||
g++ \
|
||||
build-essential \
|
||||
git \
|
||||
@@ -28,17 +34,8 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
libcap-dev && \
|
||||
yarn config set python /usr/bin/python3
|
||||
|
||||
RUN export ARCH=$(uname -m) && \
|
||||
if [ "$ARCH" = "x86_64" ]; then \
|
||||
curl -fSL https://github.com/oven-sh/bun/releases/download/bun-v1.3.1/bun-linux-x64-baseline.zip -o bun.zip; \
|
||||
elif [ "$ARCH" = "aarch64" ]; then \
|
||||
curl -fSL https://github.com/oven-sh/bun/releases/download/bun-v1.3.1/bun-linux-aarch64.zip -o bun.zip; \
|
||||
fi
|
||||
|
||||
RUN unzip bun.zip \
|
||||
&& mv bun-*/bun /usr/local/bin/bun \
|
||||
&& chmod +x /usr/local/bin/bun \
|
||||
&& rm -rf bun.zip bun-*
|
||||
# Install Bun using npm (more reliable than GitHub downloads)
|
||||
RUN npm install -g bun@1.3.1
|
||||
|
||||
RUN bun --version
|
||||
|
||||
@@ -62,24 +59,30 @@ WORKDIR /usr/src/app
|
||||
# Copy only dependency files first for better layer caching
|
||||
COPY .npmrc package.json bun.lock ./
|
||||
|
||||
# Install all dependencies with frozen lockfile
|
||||
# Install all dependencies
|
||||
RUN --mount=type=cache,target=/root/.bun/install/cache \
|
||||
bun install --frozen-lockfile
|
||||
bun install
|
||||
|
||||
# Copy source code after dependency installation
|
||||
COPY . .
|
||||
|
||||
# Build all projects including the SmoothSchedule piece
|
||||
RUN npx nx run-many --target=build --projects=react-ui,server-api,pieces-smoothschedule --configuration production --parallel=2 --skip-nx-cache
|
||||
# Build all projects including custom pieces
|
||||
RUN npx nx run-many --target=build --projects=react-ui,server-api,pieces-smoothschedule,pieces-python-code,pieces-ruby-code,pieces-interfaces --configuration production --parallel=2 --skip-nx-cache
|
||||
|
||||
# Install production dependencies only for the backend API
|
||||
RUN --mount=type=cache,target=/root/.bun/install/cache \
|
||||
cd dist/packages/server/api && \
|
||||
bun install --production --frozen-lockfile
|
||||
|
||||
# Install dependencies for the SmoothSchedule piece
|
||||
# Install dependencies for custom pieces
|
||||
RUN --mount=type=cache,target=/root/.bun/install/cache \
|
||||
cd dist/packages/pieces/community/smoothschedule && \
|
||||
bun install --production && \
|
||||
cd ../python-code && \
|
||||
bun install --production && \
|
||||
cd ../ruby-code && \
|
||||
bun install --production && \
|
||||
cd ../interfaces && \
|
||||
bun install --production
|
||||
|
||||
### STAGE 2: Run ###
|
||||
@@ -87,24 +90,30 @@ FROM base AS run
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Install Nginx and gettext in a single layer with cache mount
|
||||
# Install Nginx, gettext, and PostgreSQL client in a single layer with cache mount
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends nginx gettext
|
||||
apt-get install -y --no-install-recommends nginx gettext postgresql-client
|
||||
|
||||
# Copy static configuration files first (better layer caching)
|
||||
COPY nginx.react.conf /etc/nginx/nginx.conf
|
||||
COPY --from=build /usr/src/app/packages/server/api/src/assets/default.cf /usr/local/etc/isolate
|
||||
COPY docker-entrypoint.sh .
|
||||
COPY custom-pieces-metadata.sql .
|
||||
COPY publish-pieces.sh .
|
||||
|
||||
# Create all necessary directories in one layer
|
||||
# Also create symlink for AP_DEV_PIECES to find pieces in dist folder
|
||||
# Structure: /packages/pieces/community -> /dist/packages/pieces/community
|
||||
RUN mkdir -p \
|
||||
/usr/src/app/dist/packages/server \
|
||||
/usr/src/app/dist/packages/engine \
|
||||
/usr/src/app/dist/packages/shared \
|
||||
/usr/src/app/dist/packages/pieces && \
|
||||
chmod +x docker-entrypoint.sh
|
||||
/usr/src/app/dist/packages/pieces \
|
||||
/usr/src/app/packages/pieces && \
|
||||
ln -sf /usr/src/app/dist/packages/pieces/community /usr/src/app/packages/pieces/community && \
|
||||
chmod +x docker-entrypoint.sh publish-pieces.sh
|
||||
|
||||
# Copy built artifacts from build stage
|
||||
COPY --from=build /usr/src/app/LICENSE .
|
||||
@@ -112,7 +121,8 @@ COPY --from=build /usr/src/app/dist/packages/engine/ ./dist/packages/engine/
|
||||
COPY --from=build /usr/src/app/dist/packages/server/ ./dist/packages/server/
|
||||
COPY --from=build /usr/src/app/dist/packages/shared/ ./dist/packages/shared/
|
||||
COPY --from=build /usr/src/app/dist/packages/pieces/ ./dist/packages/pieces/
|
||||
COPY --from=build /usr/src/app/packages ./packages
|
||||
# Note: Don't copy /packages folder - it triggers dev piece auto-detection
|
||||
# The pre-built pieces in dist/packages/pieces/ are sufficient for production
|
||||
|
||||
# Copy frontend files to Nginx document root
|
||||
COPY --from=build /usr/src/app/dist/packages/react-ui /usr/share/nginx/html/
|
||||
|
||||
57
activepieces-fork/custom-pieces-metadata.sql
Normal file
@@ -0,0 +1,57 @@
|
||||
-- ==============================================================================
|
||||
-- Custom SmoothSchedule Pieces Configuration
|
||||
-- ==============================================================================
|
||||
-- This script configures pinned pieces for the Activepieces platform.
|
||||
-- It runs on container startup via docker-entrypoint.sh.
|
||||
--
|
||||
-- NOTE: We do NOT insert pieces into piece_metadata because they are already
|
||||
-- built into the Docker image in packages/pieces/community/. Activepieces
|
||||
-- auto-discovers these as OFFICIAL pieces. Adding them to piece_metadata
|
||||
-- would create duplicates in the UI.
|
||||
--
|
||||
-- We ONLY set pinnedPieces to make our pieces appear first in Highlights.
|
||||
-- ==============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
platform_id varchar(21);
|
||||
platform_count integer;
|
||||
BEGIN
|
||||
-- Check if platform table exists and has data
|
||||
SELECT COUNT(*) INTO platform_count FROM platform;
|
||||
|
||||
IF platform_count = 0 THEN
|
||||
RAISE NOTICE 'No platform found yet - skipping piece configuration';
|
||||
RAISE NOTICE 'Pieces will be configured on next container restart after platform is created';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
SELECT id INTO platform_id FROM platform LIMIT 1;
|
||||
RAISE NOTICE 'Configuring pieces for platform: %', platform_id;
|
||||
|
||||
-- Remove any duplicate CUSTOM entries for pieces that are built into the image
|
||||
-- These cause duplicates in the UI since they're also discovered from filesystem
|
||||
DELETE FROM piece_metadata WHERE name IN (
|
||||
'@activepieces/piece-smoothschedule',
|
||||
'@activepieces/piece-python-code',
|
||||
'@activepieces/piece-ruby-code',
|
||||
'@activepieces/piece-interfaces'
|
||||
) AND "pieceType" = 'CUSTOM';
|
||||
|
||||
IF FOUND THEN
|
||||
RAISE NOTICE 'Removed duplicate CUSTOM piece entries';
|
||||
END IF;
|
||||
|
||||
-- Pin our pieces in the platform so they appear first in Highlights
|
||||
-- This works with pieces auto-discovered from the filesystem
|
||||
UPDATE platform
|
||||
SET "pinnedPieces" = ARRAY[
|
||||
'@activepieces/piece-smoothschedule',
|
||||
'@activepieces/piece-python-code',
|
||||
'@activepieces/piece-ruby-code'
|
||||
]::varchar[]
|
||||
WHERE id = platform_id
|
||||
AND ("pinnedPieces" = '{}' OR "pinnedPieces" IS NULL OR NOT '@activepieces/piece-smoothschedule' = ANY("pinnedPieces"));
|
||||
|
||||
RAISE NOTICE 'Piece configuration complete';
|
||||
END $$;
|
||||
@@ -12,6 +12,10 @@ echo "AP_FAVICON_URL: $AP_FAVICON_URL"
|
||||
envsubst '${AP_APP_TITLE} ${AP_FAVICON_URL}' < /usr/share/nginx/html/index.html > /usr/share/nginx/html/index.html.tmp && \
|
||||
mv /usr/share/nginx/html/index.html.tmp /usr/share/nginx/html/index.html
|
||||
|
||||
# Register custom pieces (publish to Verdaccio and insert metadata)
|
||||
if [ -f /usr/src/app/publish-pieces.sh ]; then
|
||||
/usr/src/app/publish-pieces.sh || echo "Warning: Custom pieces registration had issues"
|
||||
fi
|
||||
|
||||
# Start Nginx server
|
||||
nginx -g "daemon off;" &
|
||||
|
||||
@@ -31,7 +31,7 @@ http {
|
||||
proxy_send_timeout 900s;
|
||||
}
|
||||
|
||||
location ~* ^/(?!api/).*.(css|js|jpg|jpeg|png|gif|ico|svg)$ {
|
||||
location ~* ^/(?!api/).*\.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Expires "0";
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
|
||||
@@ -354,6 +354,7 @@
|
||||
"@vitest/ui": "1.6.1",
|
||||
"autoprefixer": "10.4.15",
|
||||
"babel-jest": "30.0.5",
|
||||
"bun": "1.3.5",
|
||||
"chalk": "4.1.2",
|
||||
"concurrently": "8.2.1",
|
||||
"esbuild": "0.25.0",
|
||||
|
||||
@@ -93,7 +93,7 @@ export const STANDARD_CLOUD_PLAN: PlatformPlanWithOnlyLimits = {
|
||||
}
|
||||
|
||||
export const OPEN_SOURCE_PLAN: PlatformPlanWithOnlyLimits = {
|
||||
embeddingEnabled: false,
|
||||
embeddingEnabled: true,
|
||||
globalConnectionsEnabled: false,
|
||||
customRolesEnabled: false,
|
||||
mcpsEnabled: true,
|
||||
@@ -107,9 +107,9 @@ export const OPEN_SOURCE_PLAN: PlatformPlanWithOnlyLimits = {
|
||||
analyticsEnabled: true,
|
||||
showPoweredBy: false,
|
||||
auditLogEnabled: false,
|
||||
managePiecesEnabled: false,
|
||||
manageTemplatesEnabled: false,
|
||||
customAppearanceEnabled: false,
|
||||
managePiecesEnabled: true,
|
||||
manageTemplatesEnabled: true,
|
||||
customAppearanceEnabled: true,
|
||||
teamProjectsLimit: TeamProjectsLimit.NONE,
|
||||
projectRolesEnabled: false,
|
||||
customDomainsEnabled: false,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "@activepieces/piece-interfaces",
|
||||
"version": "0.0.1"
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "pieces-interfaces",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "packages/pieces/community/interfaces/src",
|
||||
"projectType": "library",
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/js:tsc",
|
||||
"outputs": [
|
||||
"{options.outputPath}"
|
||||
],
|
||||
"options": {
|
||||
"outputPath": "dist/packages/pieces/community/interfaces",
|
||||
"tsConfig": "packages/pieces/community/interfaces/tsconfig.lib.json",
|
||||
"packageJson": "packages/pieces/community/interfaces/package.json",
|
||||
"main": "packages/pieces/community/interfaces/src/index.ts",
|
||||
"assets": [],
|
||||
"buildableProjectDepsInPackageJsonType": "dependencies",
|
||||
"updateBuildableProjectDepsInPackageJson": true
|
||||
},
|
||||
"dependsOn": [
|
||||
"^build",
|
||||
"prebuild"
|
||||
]
|
||||
},
|
||||
"publish": {
|
||||
"command": "node tools/scripts/publish.mjs pieces-interfaces {args.ver} {args.tag}",
|
||||
"dependsOn": [
|
||||
"build"
|
||||
]
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"outputs": [
|
||||
"{options.outputFile}"
|
||||
]
|
||||
},
|
||||
"prebuild": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"cwd": "packages/pieces/community/interfaces",
|
||||
"command": "bun install --no-save --silent"
|
||||
},
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createPiece, PieceAuth } from '@activepieces/pieces-framework';
|
||||
import { PieceCategory } from '@activepieces/shared';
|
||||
|
||||
export const interfaces = createPiece({
|
||||
displayName: 'Interfaces',
|
||||
description: 'Create custom forms and interfaces for your workflows.',
|
||||
auth: PieceAuth.None(),
|
||||
categories: [PieceCategory.CORE],
|
||||
minimumSupportedRelease: '0.52.0',
|
||||
logoUrl: 'https://cdn.activepieces.com/pieces/interfaces.svg',
|
||||
authors: ['activepieces'],
|
||||
actions: [],
|
||||
triggers: [],
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"],
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "@activepieces/piece-python-code",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "pieces-python-code",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "packages/pieces/community/python-code/src",
|
||||
"projectType": "library",
|
||||
"release": {
|
||||
"version": {
|
||||
"currentVersionResolver": "git-tag",
|
||||
"preserveLocalDependencyProtocols": false,
|
||||
"manifestRootsToUpdate": [
|
||||
"dist/{projectRoot}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/js:tsc",
|
||||
"outputs": [
|
||||
"{options.outputPath}"
|
||||
],
|
||||
"options": {
|
||||
"outputPath": "dist/packages/pieces/community/python-code",
|
||||
"tsConfig": "packages/pieces/community/python-code/tsconfig.lib.json",
|
||||
"packageJson": "packages/pieces/community/python-code/package.json",
|
||||
"main": "packages/pieces/community/python-code/src/index.ts",
|
||||
"assets": [
|
||||
"packages/pieces/community/python-code/*.md"
|
||||
],
|
||||
"buildableProjectDepsInPackageJsonType": "dependencies",
|
||||
"updateBuildableProjectDepsInPackageJson": true
|
||||
},
|
||||
"dependsOn": [
|
||||
"^build",
|
||||
"prebuild"
|
||||
]
|
||||
},
|
||||
"nx-release-publish": {
|
||||
"options": {
|
||||
"packageRoot": "dist/{projectRoot}"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"outputs": [
|
||||
"{options.outputFile}"
|
||||
]
|
||||
},
|
||||
"prebuild": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"cwd": "packages/pieces/community/python-code",
|
||||
"command": "bun install --no-save --silent"
|
||||
},
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createPiece, PieceAuth } from '@activepieces/pieces-framework';
|
||||
import { PieceCategory } from '@activepieces/shared';
|
||||
import { runPythonCode } from './lib/run-python-code';
|
||||
|
||||
// Python logo - icon only (from SVGRepo)
|
||||
const PYTHON_LOGO = 'https://www.svgrepo.com/show/452091/python.svg';
|
||||
|
||||
export const pythonCode = createPiece({
|
||||
displayName: 'Python Code',
|
||||
description: 'Execute Python code in your automations',
|
||||
auth: PieceAuth.None(),
|
||||
minimumSupportedRelease: '0.36.1',
|
||||
logoUrl: PYTHON_LOGO,
|
||||
categories: [PieceCategory.CORE, PieceCategory.DEVELOPER_TOOLS],
|
||||
authors: ['smoothschedule'],
|
||||
actions: [runPythonCode],
|
||||
triggers: [],
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createAction, Property } from '@activepieces/pieces-framework';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export const runPythonCode = createAction({
|
||||
name: 'run_python_code',
|
||||
displayName: 'Run Python Code',
|
||||
description: 'Execute Python code and return the output. Use print() to output results.',
|
||||
props: {
|
||||
code: Property.LongText({
|
||||
displayName: 'Python Code',
|
||||
description: 'The Python code to execute. Use print() to output results that will be captured.',
|
||||
required: true,
|
||||
defaultValue: `# Example: Process input data
|
||||
import json
|
||||
|
||||
# Access inputs via the 'inputs' variable (parsed from JSON)
|
||||
# Example: name = inputs.get('name', 'World')
|
||||
|
||||
# Your code here
|
||||
result = "Hello from Python!"
|
||||
|
||||
# Print your output (will be captured as the action result)
|
||||
print(result)`,
|
||||
}),
|
||||
inputs: Property.Object({
|
||||
displayName: 'Inputs',
|
||||
description: 'Input data to pass to the Python code. Available as the `inputs` variable (dict).',
|
||||
required: false,
|
||||
defaultValue: {},
|
||||
}),
|
||||
timeout: Property.Number({
|
||||
displayName: 'Timeout (seconds)',
|
||||
description: 'Maximum execution time in seconds',
|
||||
required: false,
|
||||
defaultValue: 30,
|
||||
}),
|
||||
},
|
||||
async run(context) {
|
||||
const { code, inputs, timeout } = context.propsValue;
|
||||
const timeoutMs = (timeout || 30) * 1000;
|
||||
|
||||
// Create a temporary file for the Python code
|
||||
const tmpDir = os.tmpdir();
|
||||
const scriptPath = path.join(tmpDir, `ap_python_${Date.now()}.py`);
|
||||
const inputPath = path.join(tmpDir, `ap_python_input_${Date.now()}.json`);
|
||||
|
||||
try {
|
||||
// Write inputs to a JSON file
|
||||
await fs.promises.writeFile(inputPath, JSON.stringify(inputs || {}));
|
||||
|
||||
// Wrap the user code to load inputs
|
||||
const wrappedCode = `
|
||||
import json
|
||||
import sys
|
||||
|
||||
# Load inputs from JSON file
|
||||
with open('${inputPath.replace(/\\/g, '\\\\')}', 'r') as f:
|
||||
inputs = json.load(f)
|
||||
|
||||
# User code starts here
|
||||
${code}
|
||||
`;
|
||||
|
||||
// Write the script to a temp file
|
||||
await fs.promises.writeFile(scriptPath, wrappedCode);
|
||||
|
||||
// Execute Python
|
||||
const { stdout, stderr } = await execAsync(`python3 "${scriptPath}"`, {
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 10 * 1024 * 1024, // 10MB buffer
|
||||
});
|
||||
|
||||
// Clean up temp files
|
||||
await fs.promises.unlink(scriptPath).catch(() => {});
|
||||
await fs.promises.unlink(inputPath).catch(() => {});
|
||||
|
||||
// Try to parse output as JSON, otherwise return as string
|
||||
const output = stdout.trim();
|
||||
let result: unknown;
|
||||
try {
|
||||
result = JSON.parse(output);
|
||||
} catch {
|
||||
result = output;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: result,
|
||||
stdout: stdout,
|
||||
stderr: stderr || null,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
// Clean up temp files on error
|
||||
await fs.promises.unlink(scriptPath).catch(() => {});
|
||||
await fs.promises.unlink(inputPath).catch(() => {});
|
||||
|
||||
const execError = error as { stderr?: string; message?: string; killed?: boolean };
|
||||
|
||||
if (execError.killed) {
|
||||
throw new Error(`Python script timed out after ${timeout} seconds`);
|
||||
}
|
||||
|
||||
throw new Error(`Python execution failed: ${execError.stderr || execError.message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"],
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "@activepieces/piece-ruby-code",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "pieces-ruby-code",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "packages/pieces/community/ruby-code/src",
|
||||
"projectType": "library",
|
||||
"release": {
|
||||
"version": {
|
||||
"currentVersionResolver": "git-tag",
|
||||
"preserveLocalDependencyProtocols": false,
|
||||
"manifestRootsToUpdate": [
|
||||
"dist/{projectRoot}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/js:tsc",
|
||||
"outputs": [
|
||||
"{options.outputPath}"
|
||||
],
|
||||
"options": {
|
||||
"outputPath": "dist/packages/pieces/community/ruby-code",
|
||||
"tsConfig": "packages/pieces/community/ruby-code/tsconfig.lib.json",
|
||||
"packageJson": "packages/pieces/community/ruby-code/package.json",
|
||||
"main": "packages/pieces/community/ruby-code/src/index.ts",
|
||||
"assets": [
|
||||
"packages/pieces/community/ruby-code/*.md"
|
||||
],
|
||||
"buildableProjectDepsInPackageJsonType": "dependencies",
|
||||
"updateBuildableProjectDepsInPackageJson": true
|
||||
},
|
||||
"dependsOn": [
|
||||
"^build",
|
||||
"prebuild"
|
||||
]
|
||||
},
|
||||
"nx-release-publish": {
|
||||
"options": {
|
||||
"packageRoot": "dist/{projectRoot}"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"outputs": [
|
||||
"{options.outputFile}"
|
||||
]
|
||||
},
|
||||
"prebuild": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"cwd": "packages/pieces/community/ruby-code",
|
||||
"command": "bun install --no-save --silent"
|
||||
},
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createPiece, PieceAuth } from '@activepieces/pieces-framework';
|
||||
import { PieceCategory } from '@activepieces/shared';
|
||||
import { runRubyCode } from './lib/run-ruby-code';
|
||||
|
||||
// Ruby logo - use official Ruby logo from ruby-lang.org
|
||||
const RUBY_LOGO = 'https://www.ruby-lang.org/images/header-ruby-logo.png';
|
||||
|
||||
export const rubyCode = createPiece({
|
||||
displayName: 'Ruby Code',
|
||||
description: 'Execute Ruby code in your automations',
|
||||
auth: PieceAuth.None(),
|
||||
minimumSupportedRelease: '0.36.1',
|
||||
logoUrl: RUBY_LOGO,
|
||||
categories: [PieceCategory.CORE, PieceCategory.DEVELOPER_TOOLS],
|
||||
authors: ['smoothschedule'],
|
||||
actions: [runRubyCode],
|
||||
triggers: [],
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { createAction, Property } from '@activepieces/pieces-framework';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export const runRubyCode = createAction({
|
||||
name: 'run_ruby_code',
|
||||
displayName: 'Run Ruby Code',
|
||||
description: 'Execute Ruby code and return the output. Use puts or print to output results.',
|
||||
props: {
|
||||
code: Property.LongText({
|
||||
displayName: 'Ruby Code',
|
||||
description: 'The Ruby code to execute. Use puts or print to output results that will be captured.',
|
||||
required: true,
|
||||
defaultValue: `# Example: Process input data
|
||||
require 'json'
|
||||
|
||||
# Access inputs via the 'inputs' variable (parsed from JSON)
|
||||
# Example: name = inputs['name'] || 'World'
|
||||
|
||||
# Your code here
|
||||
result = "Hello from Ruby!"
|
||||
|
||||
# Print your output (will be captured as the action result)
|
||||
puts result`,
|
||||
}),
|
||||
inputs: Property.Object({
|
||||
displayName: 'Inputs',
|
||||
description: 'Input data to pass to the Ruby code. Available as the `inputs` variable (Hash).',
|
||||
required: false,
|
||||
defaultValue: {},
|
||||
}),
|
||||
timeout: Property.Number({
|
||||
displayName: 'Timeout (seconds)',
|
||||
description: 'Maximum execution time in seconds',
|
||||
required: false,
|
||||
defaultValue: 30,
|
||||
}),
|
||||
},
|
||||
async run(context) {
|
||||
const { code, inputs, timeout } = context.propsValue;
|
||||
const timeoutMs = (timeout || 30) * 1000;
|
||||
|
||||
// Create a temporary file for the Ruby code
|
||||
const tmpDir = os.tmpdir();
|
||||
const scriptPath = path.join(tmpDir, `ap_ruby_${Date.now()}.rb`);
|
||||
const inputPath = path.join(tmpDir, `ap_ruby_input_${Date.now()}.json`);
|
||||
|
||||
try {
|
||||
// Write inputs to a JSON file
|
||||
await fs.promises.writeFile(inputPath, JSON.stringify(inputs || {}));
|
||||
|
||||
// Escape the input path for Ruby
|
||||
const escapedInputPath = inputPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
|
||||
// Wrap the user code to load inputs
|
||||
const wrappedCode = `
|
||||
require 'json'
|
||||
|
||||
# Load inputs from JSON file
|
||||
inputs = JSON.parse(File.read('${escapedInputPath}'))
|
||||
|
||||
# User code starts here
|
||||
${code}
|
||||
`;
|
||||
|
||||
// Write the script to a temp file
|
||||
await fs.promises.writeFile(scriptPath, wrappedCode);
|
||||
|
||||
// Execute Ruby
|
||||
const { stdout, stderr } = await execAsync(`ruby "${scriptPath}"`, {
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 10 * 1024 * 1024, // 10MB buffer
|
||||
});
|
||||
|
||||
// Clean up temp files
|
||||
await fs.promises.unlink(scriptPath).catch(() => {});
|
||||
await fs.promises.unlink(inputPath).catch(() => {});
|
||||
|
||||
// Try to parse output as JSON, otherwise return as string
|
||||
const output = stdout.trim();
|
||||
let result: unknown;
|
||||
try {
|
||||
result = JSON.parse(output);
|
||||
} catch {
|
||||
result = output;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: result,
|
||||
stdout: stdout,
|
||||
stderr: stderr || null,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
// Clean up temp files on error
|
||||
await fs.promises.unlink(scriptPath).catch(() => {});
|
||||
await fs.promises.unlink(inputPath).catch(() => {});
|
||||
|
||||
const execError = error as { stderr?: string; message?: string; killed?: boolean };
|
||||
|
||||
if (execError.killed) {
|
||||
throw new Error(`Ruby script timed out after ${timeout} seconds`);
|
||||
}
|
||||
|
||||
throw new Error(`Ruby execution failed: ${execError.stderr || execError.message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"],
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -5,3 +5,4 @@ export * from './find-events';
|
||||
export * from './list-resources';
|
||||
export * from './list-services';
|
||||
export * from './list-inactive-customers';
|
||||
export * from './list-customers';
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Property, createAction } from '@activepieces/pieces-framework';
|
||||
import { HttpMethod } from '@activepieces/pieces-common';
|
||||
import { smoothScheduleAuth, SmoothScheduleAuth } from '../../index';
|
||||
import { makeRequest } from '../common';
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
results: T[];
|
||||
count: number;
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
}
|
||||
|
||||
interface Customer {
|
||||
id: number;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
phone: string;
|
||||
notes: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const listCustomersAction = createAction({
|
||||
auth: smoothScheduleAuth,
|
||||
name: 'list_customers',
|
||||
displayName: 'List Customers',
|
||||
description: 'Get a list of customers from SmoothSchedule. Useful for customer lookup and bulk operations.',
|
||||
props: {
|
||||
search: Property.ShortText({
|
||||
displayName: 'Search',
|
||||
description: 'Search by name, email, or phone number',
|
||||
required: false,
|
||||
}),
|
||||
limit: Property.Number({
|
||||
displayName: 'Limit',
|
||||
description: 'Maximum number of customers to return (default: 50, max: 500)',
|
||||
required: false,
|
||||
defaultValue: 50,
|
||||
}),
|
||||
offset: Property.Number({
|
||||
displayName: 'Offset',
|
||||
description: 'Number of customers to skip (for pagination)',
|
||||
required: false,
|
||||
defaultValue: 0,
|
||||
}),
|
||||
orderBy: Property.StaticDropdown({
|
||||
displayName: 'Order By',
|
||||
description: 'Sort order for results',
|
||||
required: false,
|
||||
options: {
|
||||
options: [
|
||||
{ label: 'Newest First', value: '-created_at' },
|
||||
{ label: 'Oldest First', value: 'created_at' },
|
||||
{ label: 'Name (A-Z)', value: 'first_name' },
|
||||
{ label: 'Name (Z-A)', value: '-first_name' },
|
||||
{ label: 'Email (A-Z)', value: 'email' },
|
||||
{ label: 'Last Updated', value: '-updated_at' },
|
||||
],
|
||||
},
|
||||
defaultValue: '-created_at',
|
||||
}),
|
||||
},
|
||||
async run(context) {
|
||||
const auth = context.auth as SmoothScheduleAuth;
|
||||
const props = context.propsValue;
|
||||
|
||||
const queryParams: Record<string, string> = {};
|
||||
|
||||
if (props.search) {
|
||||
queryParams['search'] = props.search;
|
||||
}
|
||||
|
||||
// Clamp limit to reasonable range
|
||||
const limit = Math.min(Math.max(props.limit || 50, 1), 500);
|
||||
queryParams['limit'] = limit.toString();
|
||||
|
||||
if (props.offset && props.offset > 0) {
|
||||
queryParams['offset'] = props.offset.toString();
|
||||
}
|
||||
|
||||
if (props.orderBy) {
|
||||
queryParams['ordering'] = props.orderBy;
|
||||
}
|
||||
|
||||
const response = await makeRequest<PaginatedResponse<Customer>>(
|
||||
auth,
|
||||
HttpMethod.GET,
|
||||
'/customers/',
|
||||
undefined,
|
||||
queryParams
|
||||
);
|
||||
|
||||
return {
|
||||
customers: response.results || [],
|
||||
total_count: response.count || 0,
|
||||
has_more: response.next !== null,
|
||||
limit: limit,
|
||||
offset: props.offset || 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createAction } from '@activepieces/pieces-framework';
|
||||
import { HttpMethod } from '@activepieces/pieces-common';
|
||||
import { smoothScheduleAuth, SmoothScheduleAuth } from '../../index';
|
||||
import { makeRequest } from '../common';
|
||||
|
||||
export const listEmailTemplatesAction = createAction({
|
||||
auth: smoothScheduleAuth,
|
||||
name: 'list_email_templates',
|
||||
displayName: 'List Email Templates',
|
||||
description: 'Get all available email templates (system and custom)',
|
||||
props: {},
|
||||
async run(context) {
|
||||
const auth = context.auth as SmoothScheduleAuth;
|
||||
|
||||
const response = await makeRequest(
|
||||
auth,
|
||||
HttpMethod.GET,
|
||||
'/emails/templates/'
|
||||
);
|
||||
|
||||
return response;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Property, createAction } from '@activepieces/pieces-framework';
|
||||
import { HttpMethod } from '@activepieces/pieces-common';
|
||||
import { smoothScheduleAuth, SmoothScheduleAuth } from '../../index';
|
||||
import { makeRequest } from '../common';
|
||||
|
||||
export const sendEmailAction = createAction({
|
||||
auth: smoothScheduleAuth,
|
||||
name: 'send_email',
|
||||
displayName: 'Send Email',
|
||||
description: 'Send an email using a SmoothSchedule email template',
|
||||
props: {
|
||||
template_type: Property.StaticDropdown({
|
||||
displayName: 'Template Type',
|
||||
description: 'Choose whether to use a system template or a custom template',
|
||||
required: true,
|
||||
options: {
|
||||
options: [
|
||||
{ label: 'System Template', value: 'system' },
|
||||
{ label: 'Custom Template', value: 'custom' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
email_type: Property.StaticDropdown({
|
||||
displayName: 'System Email Type',
|
||||
description: 'Select a system email template',
|
||||
required: false,
|
||||
options: {
|
||||
options: [
|
||||
{ label: 'Appointment Confirmation', value: 'appointment_confirmation' },
|
||||
{ label: 'Appointment Reminder', value: 'appointment_reminder' },
|
||||
{ label: 'Appointment Rescheduled', value: 'appointment_rescheduled' },
|
||||
{ label: 'Appointment Cancelled', value: 'appointment_cancelled' },
|
||||
{ label: 'Welcome Email', value: 'welcome_email' },
|
||||
{ label: 'Password Reset', value: 'password_reset' },
|
||||
{ label: 'Invoice', value: 'invoice' },
|
||||
{ label: 'Payment Receipt', value: 'payment_receipt' },
|
||||
{ label: 'Staff Invitation', value: 'staff_invitation' },
|
||||
{ label: 'Customer Winback', value: 'customer_winback' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
template_slug: Property.ShortText({
|
||||
displayName: 'Custom Template Slug',
|
||||
description: 'The slug/identifier of your custom email template',
|
||||
required: false,
|
||||
}),
|
||||
to_email: Property.ShortText({
|
||||
displayName: 'Recipient Email',
|
||||
description: 'The email address to send to',
|
||||
required: true,
|
||||
}),
|
||||
subject_override: Property.ShortText({
|
||||
displayName: 'Subject Override',
|
||||
description: 'Override the template subject (optional)',
|
||||
required: false,
|
||||
}),
|
||||
reply_to: Property.ShortText({
|
||||
displayName: 'Reply-To Email',
|
||||
description: 'Reply-to email address (optional)',
|
||||
required: false,
|
||||
}),
|
||||
context: Property.Object({
|
||||
displayName: 'Template Variables',
|
||||
description: 'Variables to replace in the template (e.g., customer_name, appointment_date)',
|
||||
required: false,
|
||||
}),
|
||||
},
|
||||
async run(context) {
|
||||
const { template_type, email_type, template_slug, to_email, subject_override, reply_to, context: templateContext } = context.propsValue;
|
||||
const auth = context.auth as SmoothScheduleAuth;
|
||||
|
||||
// Validate that the right template identifier is provided based on type
|
||||
if (template_type === 'system' && !email_type) {
|
||||
throw new Error('System Email Type is required when using System Template');
|
||||
}
|
||||
if (template_type === 'custom' && !template_slug) {
|
||||
throw new Error('Custom Template Slug is required when using Custom Template');
|
||||
}
|
||||
|
||||
// Build the request body
|
||||
const requestBody: Record<string, unknown> = {
|
||||
to_email,
|
||||
};
|
||||
|
||||
if (template_type === 'system') {
|
||||
requestBody['email_type'] = email_type;
|
||||
} else {
|
||||
requestBody['template_slug'] = template_slug;
|
||||
}
|
||||
|
||||
if (subject_override) {
|
||||
requestBody['subject_override'] = subject_override;
|
||||
}
|
||||
|
||||
if (reply_to) {
|
||||
requestBody['reply_to'] = reply_to;
|
||||
}
|
||||
|
||||
if (templateContext && Object.keys(templateContext).length > 0) {
|
||||
requestBody['context'] = templateContext;
|
||||
}
|
||||
|
||||
const response = await makeRequest(
|
||||
auth,
|
||||
HttpMethod.POST,
|
||||
'/emails/send/',
|
||||
requestBody
|
||||
);
|
||||
|
||||
return response;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { createTrigger, TriggerStrategy, Property } from '@activepieces/pieces-framework';
|
||||
import { HttpMethod } from '@activepieces/pieces-common';
|
||||
import { smoothScheduleAuth, SmoothScheduleAuth } from '../../index';
|
||||
import { makeRequest } from '../common';
|
||||
|
||||
const TRIGGER_KEY = 'last_status_change_at';
|
||||
|
||||
// Event status options from SmoothSchedule backend
|
||||
const EVENT_STATUSES = [
|
||||
{ label: 'Any Status', value: '' },
|
||||
{ label: 'Scheduled', value: 'SCHEDULED' },
|
||||
{ label: 'En Route', value: 'EN_ROUTE' },
|
||||
{ label: 'In Progress', value: 'IN_PROGRESS' },
|
||||
{ label: 'Canceled', value: 'CANCELED' },
|
||||
{ label: 'Completed', value: 'COMPLETED' },
|
||||
{ label: 'Awaiting Payment', value: 'AWAITING_PAYMENT' },
|
||||
{ label: 'Paid', value: 'PAID' },
|
||||
{ label: 'No Show', value: 'NOSHOW' },
|
||||
];
|
||||
|
||||
export const eventStatusChangedTrigger = createTrigger({
|
||||
auth: smoothScheduleAuth,
|
||||
name: 'event_status_changed',
|
||||
displayName: 'Event Status Changed',
|
||||
description: 'Triggers when an event status changes (e.g., Scheduled → In Progress).',
|
||||
props: {
|
||||
oldStatus: Property.StaticDropdown({
|
||||
displayName: 'Previous Status (From)',
|
||||
description: 'Only trigger when changing from this status (optional)',
|
||||
required: false,
|
||||
options: {
|
||||
options: EVENT_STATUSES,
|
||||
},
|
||||
}),
|
||||
newStatus: Property.StaticDropdown({
|
||||
displayName: 'New Status (To)',
|
||||
description: 'Only trigger when changing to this status (optional)',
|
||||
required: false,
|
||||
options: {
|
||||
options: EVENT_STATUSES,
|
||||
},
|
||||
}),
|
||||
},
|
||||
type: TriggerStrategy.POLLING,
|
||||
async onEnable(context) {
|
||||
// Store the current timestamp as the starting point
|
||||
await context.store.put(TRIGGER_KEY, new Date().toISOString());
|
||||
},
|
||||
async onDisable(context) {
|
||||
await context.store.delete(TRIGGER_KEY);
|
||||
},
|
||||
async test(context) {
|
||||
const auth = context.auth as SmoothScheduleAuth;
|
||||
const { oldStatus, newStatus } = context.propsValue;
|
||||
|
||||
const queryParams: Record<string, string> = {
|
||||
limit: '5',
|
||||
};
|
||||
|
||||
if (oldStatus) {
|
||||
queryParams['old_status'] = oldStatus;
|
||||
}
|
||||
if (newStatus) {
|
||||
queryParams['new_status'] = newStatus;
|
||||
}
|
||||
|
||||
const statusChanges = await makeRequest<Array<Record<string, unknown>>>(
|
||||
auth,
|
||||
HttpMethod.GET,
|
||||
'/events/status_changes/',
|
||||
undefined,
|
||||
queryParams
|
||||
);
|
||||
|
||||
return statusChanges;
|
||||
},
|
||||
async run(context) {
|
||||
const auth = context.auth as SmoothScheduleAuth;
|
||||
const { oldStatus, newStatus } = context.propsValue;
|
||||
|
||||
const lastChangeAt = await context.store.get<string>(TRIGGER_KEY) || new Date(0).toISOString();
|
||||
|
||||
const queryParams: Record<string, string> = {
|
||||
changed_at__gt: lastChangeAt,
|
||||
};
|
||||
|
||||
if (oldStatus) {
|
||||
queryParams['old_status'] = oldStatus;
|
||||
}
|
||||
if (newStatus) {
|
||||
queryParams['new_status'] = newStatus;
|
||||
}
|
||||
|
||||
const statusChanges = await makeRequest<Array<{ changed_at: string } & Record<string, unknown>>>(
|
||||
auth,
|
||||
HttpMethod.GET,
|
||||
'/events/status_changes/',
|
||||
undefined,
|
||||
queryParams
|
||||
);
|
||||
|
||||
if (statusChanges.length > 0) {
|
||||
// Update the last change timestamp
|
||||
const maxChangedAt = statusChanges.reduce((max, c) =>
|
||||
c.changed_at > max ? c.changed_at : max,
|
||||
lastChangeAt
|
||||
);
|
||||
await context.store.put(TRIGGER_KEY, maxChangedAt);
|
||||
}
|
||||
|
||||
return statusChanges;
|
||||
},
|
||||
sampleData: {
|
||||
id: 1,
|
||||
event_id: 12345,
|
||||
event: {
|
||||
id: 12345,
|
||||
title: 'Consultation',
|
||||
start_time: '2024-12-01T10:00:00Z',
|
||||
end_time: '2024-12-01T11:00:00Z',
|
||||
status: 'IN_PROGRESS',
|
||||
service: {
|
||||
id: 1,
|
||||
name: 'Consultation',
|
||||
},
|
||||
customer: {
|
||||
id: 100,
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@example.com',
|
||||
},
|
||||
resources: [
|
||||
{ id: 1, name: 'Dr. Smith', type: 'STAFF' },
|
||||
],
|
||||
},
|
||||
old_status: 'SCHEDULED',
|
||||
old_status_display: 'Scheduled',
|
||||
new_status: 'IN_PROGRESS',
|
||||
new_status_display: 'In Progress',
|
||||
changed_by: 'John Smith',
|
||||
changed_by_email: 'john@example.com',
|
||||
changed_at: '2024-12-01T10:05:00Z',
|
||||
notes: 'Started working on the job',
|
||||
source: 'mobile_app',
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
},
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
export * from './event-created';
|
||||
export * from './event-updated';
|
||||
export * from './event-cancelled';
|
||||
export * from './event-status-changed';
|
||||
export * from './payment-received';
|
||||
export * from './upcoming-events';
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { createTrigger, TriggerStrategy, Property } from '@activepieces/pieces-framework';
|
||||
import { HttpMethod } from '@activepieces/pieces-common';
|
||||
import { smoothScheduleAuth, SmoothScheduleAuth } from '../../index';
|
||||
import { makeRequest } from '../common';
|
||||
|
||||
const TRIGGER_KEY = 'last_payment_check_timestamp';
|
||||
|
||||
interface PaymentData {
|
||||
id: number;
|
||||
payment_intent_id: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
type: 'deposit' | 'final';
|
||||
status: string;
|
||||
created_at: string;
|
||||
completed_at: string;
|
||||
event: {
|
||||
id: number;
|
||||
title: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
status: string;
|
||||
deposit_amount: string | null;
|
||||
final_price: string | null;
|
||||
remaining_balance: string | null;
|
||||
};
|
||||
service: {
|
||||
id: number;
|
||||
name: string;
|
||||
price: string;
|
||||
} | null;
|
||||
customer: {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const SAMPLE_PAYMENT_DATA: PaymentData = {
|
||||
id: 12345,
|
||||
payment_intent_id: 'pi_3QDEr5GvIfP3a7s90bcd1234',
|
||||
amount: '50.00',
|
||||
currency: 'usd',
|
||||
type: 'deposit',
|
||||
status: 'SUCCEEDED',
|
||||
created_at: '2024-12-01T10:00:00Z',
|
||||
completed_at: '2024-12-01T10:00:05Z',
|
||||
event: {
|
||||
id: 100,
|
||||
title: 'Consultation with John Doe',
|
||||
start_time: '2024-12-15T14:00:00Z',
|
||||
end_time: '2024-12-15T15:00:00Z',
|
||||
status: 'SCHEDULED',
|
||||
deposit_amount: '50.00',
|
||||
final_price: '200.00',
|
||||
remaining_balance: '150.00',
|
||||
},
|
||||
service: {
|
||||
id: 1,
|
||||
name: 'Consultation',
|
||||
price: '200.00',
|
||||
},
|
||||
customer: {
|
||||
id: 50,
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@example.com',
|
||||
phone: '+1-555-0100',
|
||||
},
|
||||
};
|
||||
|
||||
export const paymentReceivedTrigger = createTrigger({
|
||||
auth: smoothScheduleAuth,
|
||||
name: 'payment_received',
|
||||
displayName: 'Payment Received',
|
||||
description: 'Triggers when a payment is successfully completed in SmoothSchedule.',
|
||||
props: {
|
||||
paymentType: Property.StaticDropdown({
|
||||
displayName: 'Payment Type',
|
||||
description: 'Only trigger for specific payment types',
|
||||
required: false,
|
||||
options: {
|
||||
options: [
|
||||
{ label: 'All Payments', value: 'all' },
|
||||
{ label: 'Deposit Payments', value: 'deposit' },
|
||||
{ label: 'Final Payments', value: 'final' },
|
||||
],
|
||||
},
|
||||
defaultValue: 'all',
|
||||
}),
|
||||
},
|
||||
type: TriggerStrategy.POLLING,
|
||||
async onEnable(context) {
|
||||
// Store current timestamp as starting point
|
||||
await context.store.put(TRIGGER_KEY, new Date().toISOString());
|
||||
},
|
||||
async onDisable(context) {
|
||||
await context.store.delete(TRIGGER_KEY);
|
||||
},
|
||||
async test(context) {
|
||||
const auth = context.auth as SmoothScheduleAuth;
|
||||
const { paymentType } = context.propsValue;
|
||||
|
||||
const queryParams: Record<string, string> = {
|
||||
limit: '5',
|
||||
};
|
||||
|
||||
if (paymentType && paymentType !== 'all') {
|
||||
queryParams['type'] = paymentType;
|
||||
}
|
||||
|
||||
try {
|
||||
const payments = await makeRequest<PaymentData[]>(
|
||||
auth,
|
||||
HttpMethod.GET,
|
||||
'/payments/',
|
||||
undefined,
|
||||
queryParams
|
||||
);
|
||||
|
||||
// Return real data if available, otherwise return sample data
|
||||
if (payments && payments.length > 0) {
|
||||
return payments;
|
||||
}
|
||||
} catch (error) {
|
||||
// Fall through to sample data on error
|
||||
console.error('Error fetching payments for sample data:', error);
|
||||
}
|
||||
|
||||
// Return static sample data if no real payments exist
|
||||
return [SAMPLE_PAYMENT_DATA];
|
||||
},
|
||||
async run(context) {
|
||||
const auth = context.auth as SmoothScheduleAuth;
|
||||
const { paymentType } = context.propsValue;
|
||||
|
||||
const lastCheck = await context.store.get<string>(TRIGGER_KEY) || new Date(0).toISOString();
|
||||
|
||||
const queryParams: Record<string, string> = {
|
||||
'created_at__gt': lastCheck,
|
||||
limit: '100',
|
||||
};
|
||||
|
||||
if (paymentType && paymentType !== 'all') {
|
||||
queryParams['type'] = paymentType;
|
||||
}
|
||||
|
||||
const payments = await makeRequest<PaymentData[]>(
|
||||
auth,
|
||||
HttpMethod.GET,
|
||||
'/payments/',
|
||||
undefined,
|
||||
queryParams
|
||||
);
|
||||
|
||||
if (payments.length > 0) {
|
||||
// Update the last check timestamp to the most recent payment
|
||||
const mostRecent = payments.reduce((latest, p) =>
|
||||
new Date(p.completed_at) > new Date(latest.completed_at) ? p : latest
|
||||
);
|
||||
await context.store.put(TRIGGER_KEY, mostRecent.completed_at);
|
||||
}
|
||||
|
||||
return payments;
|
||||
},
|
||||
sampleData: SAMPLE_PAYMENT_DATA,
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { createTrigger, TriggerStrategy, Property } from '@activepieces/pieces-framework';
|
||||
import { HttpMethod } from '@activepieces/pieces-common';
|
||||
import { smoothScheduleAuth, SmoothScheduleAuth } from '../../index';
|
||||
import { makeRequest } from '../common';
|
||||
|
||||
const TRIGGER_KEY_PREFIX = 'reminder_sent_event_ids';
|
||||
|
||||
interface UpcomingEventData {
|
||||
id: number;
|
||||
title: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
status: string;
|
||||
hours_until_start: number;
|
||||
reminder_hours_before: number;
|
||||
should_send_reminder: boolean;
|
||||
service: {
|
||||
id: number;
|
||||
name: string;
|
||||
duration: number;
|
||||
price: string;
|
||||
reminder_enabled: boolean;
|
||||
reminder_hours_before: number;
|
||||
} | null;
|
||||
customer: {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
} | null;
|
||||
resources: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
}>;
|
||||
notes: string | null;
|
||||
location: {
|
||||
id: number;
|
||||
name: string;
|
||||
address: string;
|
||||
} | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const upcomingEventsTrigger = createTrigger({
|
||||
auth: smoothScheduleAuth,
|
||||
name: 'upcoming_events',
|
||||
displayName: 'Upcoming Event (Reminder)',
|
||||
description: 'Triggers for events starting soon. Use for sending appointment reminders.',
|
||||
props: {
|
||||
hoursAhead: Property.Number({
|
||||
displayName: 'Hours Ahead',
|
||||
description: 'Trigger for events starting within this many hours (matches service reminder settings)',
|
||||
required: false,
|
||||
defaultValue: 24,
|
||||
}),
|
||||
onlyIfReminderEnabled: Property.Checkbox({
|
||||
displayName: 'Only if Reminder Enabled',
|
||||
description: 'Only trigger for events where the service has reminders enabled',
|
||||
required: false,
|
||||
defaultValue: true,
|
||||
}),
|
||||
},
|
||||
type: TriggerStrategy.POLLING,
|
||||
async onEnable(context) {
|
||||
// Initialize with empty set of processed event IDs
|
||||
await context.store.put(TRIGGER_KEY_PREFIX, JSON.stringify([]));
|
||||
},
|
||||
async onDisable(context) {
|
||||
await context.store.delete(TRIGGER_KEY_PREFIX);
|
||||
},
|
||||
async test(context) {
|
||||
const auth = context.auth as SmoothScheduleAuth;
|
||||
const { hoursAhead } = context.propsValue;
|
||||
|
||||
const queryParams: Record<string, string> = {
|
||||
hours_ahead: String(hoursAhead || 24),
|
||||
limit: '5',
|
||||
};
|
||||
|
||||
const events = await makeRequest<UpcomingEventData[]>(
|
||||
auth,
|
||||
HttpMethod.GET,
|
||||
'/events/upcoming/',
|
||||
undefined,
|
||||
queryParams
|
||||
);
|
||||
|
||||
return events;
|
||||
},
|
||||
async run(context) {
|
||||
const auth = context.auth as SmoothScheduleAuth;
|
||||
const { hoursAhead, onlyIfReminderEnabled } = context.propsValue;
|
||||
|
||||
// Get list of event IDs we've already processed
|
||||
const processedIdsJson = await context.store.get<string>(TRIGGER_KEY_PREFIX) || '[]';
|
||||
let processedIds: number[] = [];
|
||||
try {
|
||||
processedIds = JSON.parse(processedIdsJson);
|
||||
} catch {
|
||||
processedIds = [];
|
||||
}
|
||||
|
||||
const queryParams: Record<string, string> = {
|
||||
hours_ahead: String(hoursAhead || 24),
|
||||
limit: '100',
|
||||
};
|
||||
|
||||
const events = await makeRequest<UpcomingEventData[]>(
|
||||
auth,
|
||||
HttpMethod.GET,
|
||||
'/events/upcoming/',
|
||||
undefined,
|
||||
queryParams
|
||||
);
|
||||
|
||||
// Filter to only events that should trigger reminders
|
||||
let filteredEvents = events.filter((event) => {
|
||||
// Skip if already processed
|
||||
if (processedIds.includes(event.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if reminder is appropriate based on service settings
|
||||
if (!event.should_send_reminder) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if service has reminders enabled
|
||||
if (onlyIfReminderEnabled && event.service && !event.service.reminder_enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Update the processed IDs list
|
||||
if (filteredEvents.length > 0) {
|
||||
const newProcessedIds = [...processedIds, ...filteredEvents.map((e) => e.id)];
|
||||
// Keep only last 1000 IDs to prevent unbounded growth
|
||||
const trimmedIds = newProcessedIds.slice(-1000);
|
||||
await context.store.put(TRIGGER_KEY_PREFIX, JSON.stringify(trimmedIds));
|
||||
}
|
||||
|
||||
// Also clean up old IDs (events that have already passed)
|
||||
// This runs periodically to keep the list manageable
|
||||
if (Math.random() < 0.1) { // 10% of runs
|
||||
const currentIds = events.map((e) => e.id);
|
||||
const activeProcessedIds = processedIds.filter((id) => currentIds.includes(id));
|
||||
await context.store.put(TRIGGER_KEY_PREFIX, JSON.stringify(activeProcessedIds));
|
||||
}
|
||||
|
||||
return filteredEvents;
|
||||
},
|
||||
sampleData: {
|
||||
id: 12345,
|
||||
title: 'Consultation with John Doe',
|
||||
start_time: '2024-12-15T14:00:00Z',
|
||||
end_time: '2024-12-15T15:00:00Z',
|
||||
status: 'SCHEDULED',
|
||||
hours_until_start: 23.5,
|
||||
reminder_hours_before: 24,
|
||||
should_send_reminder: true,
|
||||
service: {
|
||||
id: 1,
|
||||
name: 'Consultation',
|
||||
duration: 60,
|
||||
price: '200.00',
|
||||
reminder_enabled: true,
|
||||
reminder_hours_before: 24,
|
||||
},
|
||||
customer: {
|
||||
id: 50,
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@example.com',
|
||||
phone: '+1-555-0100',
|
||||
},
|
||||
resources: [
|
||||
{ id: 1, name: 'Dr. Smith' },
|
||||
],
|
||||
notes: 'First-time client',
|
||||
location: {
|
||||
id: 1,
|
||||
name: 'Main Office',
|
||||
address: '123 Business St',
|
||||
},
|
||||
created_at: '2024-12-01T10:00:00Z',
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { t } from 'i18next';
|
||||
import { Plus, Globe } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { AutoFormFieldWrapper } from '@/app/builder/piece-properties/auto-form-field-wrapper';
|
||||
@@ -80,6 +80,27 @@ function ConnectionSelect(params: ConnectionSelectProps) {
|
||||
PropertyExecutionType.DYNAMIC;
|
||||
const isPLatformAdmin = useIsPlatformAdmin();
|
||||
|
||||
// Auto-select connection with autoSelect metadata if no connection is selected
|
||||
useEffect(() => {
|
||||
if (isLoadingConnections || !connections?.data) return;
|
||||
|
||||
const currentAuth = form.getValues().settings.input.auth;
|
||||
// Only auto-select if no connection is currently selected
|
||||
if (currentAuth && removeBrackets(currentAuth)) return;
|
||||
|
||||
// Find a connection with autoSelect metadata
|
||||
const autoSelectConnection = connections.data.find(
|
||||
(connection) => (connection as any).metadata?.autoSelect === true
|
||||
);
|
||||
|
||||
if (autoSelectConnection) {
|
||||
form.setValue('settings.input.auth', addBrackets(autoSelectConnection.externalId), {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
}, [connections?.data, isLoadingConnections, form]);
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t } from 'i18next';
|
||||
import { ArrowLeft, Search, SearchX } from 'lucide-react';
|
||||
import { ArrowLeft, Search, SearchX, Sparkles, Building2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
@@ -24,14 +24,19 @@ import {
|
||||
import { LoadingSpinner } from '@/components/ui/spinner';
|
||||
import { TemplateCard } from '@/features/templates/components/template-card';
|
||||
import { TemplateDetailsView } from '@/features/templates/components/template-details-view';
|
||||
import { useTemplates } from '@/features/templates/hooks/templates-hook';
|
||||
import { useAllTemplates } from '@/features/templates/hooks/templates-hook';
|
||||
import { userHooks } from '@/hooks/user-hooks';
|
||||
import { PlatformRole, Template, TemplateType } from '@activepieces/shared';
|
||||
import { PlatformRole, Template } from '@activepieces/shared';
|
||||
|
||||
export const ExplorePage = () => {
|
||||
const { filteredTemplates, isLoading, search, setSearch } = useTemplates({
|
||||
type: TemplateType.OFFICIAL,
|
||||
});
|
||||
const {
|
||||
filteredCustomTemplates,
|
||||
filteredOfficialTemplates,
|
||||
filteredTemplates,
|
||||
isLoading,
|
||||
search,
|
||||
setSearch,
|
||||
} = useAllTemplates();
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
|
||||
null,
|
||||
);
|
||||
@@ -47,6 +52,20 @@ export const ExplorePage = () => {
|
||||
setSelectedTemplate(null);
|
||||
};
|
||||
|
||||
const renderTemplateGrid = (templates: Template[]) => (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{templates.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
onSelectTemplate={(template) => {
|
||||
setSelectedTemplate(template);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ProjectDashboardPageHeader title={t('Explore Templates')} />
|
||||
@@ -67,7 +86,7 @@ export const ExplorePage = () => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredTemplates?.length === 0 && (
|
||||
{filteredTemplates.length === 0 && (
|
||||
<Empty className="min-h-[300px]">
|
||||
<EmptyHeader className="max-w-xl">
|
||||
<EmptyMedia variant="icon">
|
||||
@@ -93,17 +112,38 @@ export const ExplorePage = () => {
|
||||
)}
|
||||
</Empty>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-4">
|
||||
{filteredTemplates?.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
onSelectTemplate={(template) => {
|
||||
setSelectedTemplate(template);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom Templates Section (SmoothSchedule-specific) */}
|
||||
{filteredCustomTemplates.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Building2 className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t('SmoothSchedule Templates')}
|
||||
</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({filteredCustomTemplates.length})
|
||||
</span>
|
||||
</div>
|
||||
{renderTemplateGrid(filteredCustomTemplates)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Official Templates Section (from Activepieces cloud) */}
|
||||
{filteredOfficialTemplates.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Sparkles className="w-5 h-5 text-amber-500" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t('Community Templates')}
|
||||
</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({filteredOfficialTemplates.length})
|
||||
</span>
|
||||
</div>
|
||||
{renderTemplateGrid(filteredOfficialTemplates)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,64 @@
|
||||
import { t } from 'i18next';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { flagsHooks } from '@/hooks/flags-hooks';
|
||||
import { useTheme } from '@/components/theme-provider';
|
||||
|
||||
const FullLogo = () => {
|
||||
const branding = flagsHooks.useWebsiteBranding();
|
||||
const { theme } = useTheme();
|
||||
|
||||
// Track resolved theme from DOM (handles 'system' theme correctly)
|
||||
const [isDark, setIsDark] = useState(() =>
|
||||
document.documentElement.classList.contains('dark')
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Update when theme changes - check the actual applied class
|
||||
const checkDark = () => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
};
|
||||
checkDark();
|
||||
|
||||
// Observe class changes on documentElement
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.attributeName === 'class') {
|
||||
checkDark();
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [theme]);
|
||||
|
||||
// Support dark mode by switching logo URLs
|
||||
// Light logo (dark text) for light mode, dark logo (light text) for dark mode
|
||||
const baseLogoUrl = branding.logos.fullLogoUrl;
|
||||
|
||||
// Compute the appropriate logo URL based on theme
|
||||
let logoUrl = baseLogoUrl;
|
||||
if (isDark) {
|
||||
// Need dark logo (light text for dark background)
|
||||
if (baseLogoUrl.includes('-light.svg')) {
|
||||
logoUrl = baseLogoUrl.replace('-light.svg', '-dark.svg');
|
||||
} else if (!baseLogoUrl.includes('-dark.svg')) {
|
||||
logoUrl = baseLogoUrl.replace(/\.svg$/, '-dark.svg');
|
||||
}
|
||||
} else {
|
||||
// Need light logo (dark text for light background)
|
||||
if (baseLogoUrl.includes('-dark.svg')) {
|
||||
logoUrl = baseLogoUrl.replace('-dark.svg', '-light.svg');
|
||||
}
|
||||
// Otherwise use base URL as-is (assumed to be light version)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[60px]">
|
||||
<img
|
||||
className="h-full"
|
||||
src={branding.logos.fullLogoUrl}
|
||||
src={logoUrl}
|
||||
alt={t('logo')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -123,7 +123,15 @@ export const billingQueries = {
|
||||
usePlatformSubscription: (platformId: string) => {
|
||||
return useQuery({
|
||||
queryKey: billingKeys.platformSubscription(platformId),
|
||||
queryFn: platformBillingApi.getSubscriptionInfo,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await platformBillingApi.getSubscriptionInfo();
|
||||
} catch {
|
||||
// Return null if endpoint doesn't exist (community edition)
|
||||
return null;
|
||||
}
|
||||
},
|
||||
retry: false, // Don't retry on failure
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,8 +22,8 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { LoadingSpinner } from '@/components/ui/spinner';
|
||||
import { TemplateCard } from '@/features/templates/components/template-card';
|
||||
import { TemplateDetailsView } from '@/features/templates/components/template-details-view';
|
||||
import { useTemplates } from '@/features/templates/hooks/templates-hook';
|
||||
import { Template, TemplateType } from '@activepieces/shared';
|
||||
import { useAllTemplates } from '@/features/templates/hooks/templates-hook';
|
||||
import { Template } from '@activepieces/shared';
|
||||
|
||||
const SelectFlowTemplateDialog = ({
|
||||
children,
|
||||
@@ -32,9 +32,7 @@ const SelectFlowTemplateDialog = ({
|
||||
children: React.ReactNode;
|
||||
folderId: string;
|
||||
}) => {
|
||||
const { filteredTemplates, isLoading, search, setSearch } = useTemplates({
|
||||
type: TemplateType.CUSTOM,
|
||||
});
|
||||
const { filteredTemplates, isLoading, search, setSearch } = useAllTemplates();
|
||||
const carousel = useRef<CarouselApi>();
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
|
||||
null,
|
||||
|
||||
@@ -12,20 +12,26 @@ export const projectMembersHooks = {
|
||||
const query = useQuery<ProjectMemberWithUser[]>({
|
||||
queryKey: ['project-members', authenticationSession.getProjectId()],
|
||||
queryFn: async () => {
|
||||
const projectId = authenticationSession.getProjectId();
|
||||
assertNotNullOrUndefined(projectId, 'Project ID is null');
|
||||
const res = await projectMembersApi.list({
|
||||
projectId: projectId,
|
||||
projectRoleId: undefined,
|
||||
cursor: undefined,
|
||||
limit: 100,
|
||||
});
|
||||
return res.data;
|
||||
try {
|
||||
const projectId = authenticationSession.getProjectId();
|
||||
assertNotNullOrUndefined(projectId, 'Project ID is null');
|
||||
const res = await projectMembersApi.list({
|
||||
projectId: projectId,
|
||||
projectRoleId: undefined,
|
||||
cursor: undefined,
|
||||
limit: 100,
|
||||
});
|
||||
return res.data;
|
||||
} catch {
|
||||
// Return empty array if endpoint doesn't exist (community edition)
|
||||
return [];
|
||||
}
|
||||
},
|
||||
staleTime: Infinity,
|
||||
retry: false, // Don't retry on failure
|
||||
});
|
||||
return {
|
||||
projectMembers: query.data,
|
||||
projectMembers: query.data ?? [],
|
||||
isLoading: query.isLoading,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
|
||||
@@ -79,10 +79,14 @@ export const TemplateCard = ({
|
||||
className="rounded-lg border border-solid border-dividers overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-2 p-4">
|
||||
<PieceIconList
|
||||
trigger={template.flows![0].trigger}
|
||||
maxNumberOfIconsToShow={2}
|
||||
/>
|
||||
{template.flows && template.flows.length > 0 && template.flows[0].trigger ? (
|
||||
<PieceIconList
|
||||
trigger={template.flows[0].trigger}
|
||||
maxNumberOfIconsToShow={2}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded bg-muted" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-medium px-4 min-h-16">{template.name}</div>
|
||||
<div className="py-2 px-4 gap-1 flex items-center">
|
||||
|
||||
@@ -13,11 +13,15 @@ export const TemplateDetailsView = ({ template }: TemplateDetailsViewProps) => {
|
||||
return (
|
||||
<div className="px-2">
|
||||
<div className="mb-4 p-8 flex items-center justify-center gap-2 width-full bg-green-300 rounded-lg">
|
||||
<PieceIconList
|
||||
size="xxl"
|
||||
trigger={template.flows![0].trigger}
|
||||
maxNumberOfIconsToShow={3}
|
||||
/>
|
||||
{template.flows && template.flows.length > 0 && template.flows[0].trigger ? (
|
||||
<PieceIconList
|
||||
size="xxl"
|
||||
trigger={template.flows[0].trigger}
|
||||
maxNumberOfIconsToShow={3}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-16 w-16 rounded bg-muted" />
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea className="px-2 min-h-[156px] h-[calc(70vh-144px)] max-h-[536px]">
|
||||
<div className="mb-4 text-lg font-medium font-black">
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { ListTemplatesRequestQuery, Template } from '@activepieces/shared';
|
||||
import {
|
||||
ListTemplatesRequestQuery,
|
||||
Template,
|
||||
TemplateType,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { templatesApi } from '../lib/templates-api';
|
||||
|
||||
@@ -9,7 +13,7 @@ export const useTemplates = (request: ListTemplatesRequestQuery) => {
|
||||
const [search, setSearch] = useState<string>('');
|
||||
|
||||
const { data: templates, isLoading } = useQuery<Template[], Error>({
|
||||
queryKey: ['templates'],
|
||||
queryKey: ['templates', request.type],
|
||||
queryFn: async () => {
|
||||
const templates = await templatesApi.list(request);
|
||||
return templates.data;
|
||||
@@ -34,3 +38,86 @@ export const useTemplates = (request: ListTemplatesRequestQuery) => {
|
||||
setSearch,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to fetch both custom (platform) and official templates
|
||||
*/
|
||||
export const useAllTemplates = () => {
|
||||
const [search, setSearch] = useState<string>('');
|
||||
|
||||
// Fetch custom templates (platform-specific)
|
||||
const {
|
||||
data: customTemplates,
|
||||
isLoading: isLoadingCustom,
|
||||
} = useQuery<Template[], Error>({
|
||||
queryKey: ['templates', TemplateType.CUSTOM],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const templates = await templatesApi.list({
|
||||
type: TemplateType.CUSTOM,
|
||||
});
|
||||
return templates.data;
|
||||
} catch {
|
||||
// If custom templates fail (e.g., feature not enabled), return empty array
|
||||
return [];
|
||||
}
|
||||
},
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// Fetch official templates from Activepieces cloud
|
||||
const {
|
||||
data: officialTemplates,
|
||||
isLoading: isLoadingOfficial,
|
||||
} = useQuery<Template[], Error>({
|
||||
queryKey: ['templates', TemplateType.OFFICIAL],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const templates = await templatesApi.list({
|
||||
type: TemplateType.OFFICIAL,
|
||||
});
|
||||
return templates.data;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const isLoading = isLoadingCustom || isLoadingOfficial;
|
||||
|
||||
// Combine all templates
|
||||
const allTemplates = [
|
||||
...(customTemplates || []),
|
||||
...(officialTemplates || []),
|
||||
];
|
||||
|
||||
const filteredTemplates = allTemplates.filter((template) => {
|
||||
const templateName = template.name.toLowerCase();
|
||||
const templateDescription = template.description.toLowerCase();
|
||||
return (
|
||||
templateName.includes(search.toLowerCase()) ||
|
||||
templateDescription.includes(search.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
// Separate filtered results by type
|
||||
const filteredCustomTemplates = filteredTemplates.filter(
|
||||
(t) => t.type === TemplateType.CUSTOM,
|
||||
);
|
||||
const filteredOfficialTemplates = filteredTemplates.filter(
|
||||
(t) => t.type === TemplateType.OFFICIAL,
|
||||
);
|
||||
|
||||
return {
|
||||
customTemplates: customTemplates || [],
|
||||
officialTemplates: officialTemplates || [],
|
||||
allTemplates,
|
||||
filteredTemplates,
|
||||
filteredCustomTemplates,
|
||||
filteredOfficialTemplates,
|
||||
isLoading,
|
||||
search,
|
||||
setSearch,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -57,15 +57,21 @@ export const projectHooks = {
|
||||
return useQuery<ProjectWithLimits[], Error>({
|
||||
queryKey: ['projects', params],
|
||||
queryFn: async () => {
|
||||
const results = await projectApi.list({
|
||||
cursor,
|
||||
limit,
|
||||
displayName,
|
||||
...restParams,
|
||||
});
|
||||
return results.data;
|
||||
try {
|
||||
const results = await projectApi.list({
|
||||
cursor,
|
||||
limit,
|
||||
displayName,
|
||||
...restParams,
|
||||
});
|
||||
return results.data;
|
||||
} catch {
|
||||
// Return empty array if endpoint doesn't exist (embedded mode)
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled: !displayName || displayName.length > 0,
|
||||
retry: false,
|
||||
});
|
||||
},
|
||||
useProjectsInfinite: (limit = 20) => {
|
||||
@@ -77,11 +83,18 @@ export const projectHooks = {
|
||||
queryKey: ['projects-infinite', limit],
|
||||
getNextPageParam: (lastPage) => lastPage.next,
|
||||
initialPageParam: undefined,
|
||||
queryFn: ({ pageParam }) =>
|
||||
projectApi.list({
|
||||
cursor: pageParam as string | undefined,
|
||||
limit,
|
||||
}),
|
||||
queryFn: async ({ pageParam }) => {
|
||||
try {
|
||||
return await projectApi.list({
|
||||
cursor: pageParam as string | undefined,
|
||||
limit,
|
||||
});
|
||||
} catch {
|
||||
// Return empty page if endpoint doesn't exist (embedded mode)
|
||||
return { data: [], next: null, previous: null };
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
},
|
||||
useProjectsForPlatforms: () => {
|
||||
|
||||
@@ -247,6 +247,23 @@ export const appConnectionService = (log: FastifyBaseLogger) => ({
|
||||
},
|
||||
|
||||
async delete(params: DeleteParams): Promise<void> {
|
||||
// Check if connection is protected before deleting
|
||||
const connection = await appConnectionsRepo().findOneBy({
|
||||
id: params.id,
|
||||
platformId: params.platformId,
|
||||
scope: params.scope,
|
||||
...(params.projectId ? { projectIds: ArrayContains([params.projectId]) } : {}),
|
||||
})
|
||||
|
||||
if (connection?.metadata?.protected) {
|
||||
throw new ActivepiecesError({
|
||||
code: ErrorCode.VALIDATION,
|
||||
params: {
|
||||
message: 'This connection is protected and cannot be deleted. It is required for SmoothSchedule integration.',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await appConnectionsRepo().delete({
|
||||
id: params.id,
|
||||
platformId: params.platformId,
|
||||
|
||||
@@ -65,8 +65,8 @@ export function generateTheme({
|
||||
|
||||
export const defaultTheme = generateTheme({
|
||||
primaryColor: '#6e41e2',
|
||||
websiteName: 'Activepieces',
|
||||
fullLogoUrl: 'https://cdn.activepieces.com/brand/full-logo.png',
|
||||
websiteName: 'Automation Builder',
|
||||
fullLogoUrl: 'https://smoothschedule.nyc3.digitaloceanspaces.com/static/images/automation-builder-logo-light.svg',
|
||||
favIconUrl: 'https://cdn.activepieces.com/brand/favicon.ico',
|
||||
logoIconUrl: 'https://cdn.activepieces.com/brand/logo.svg',
|
||||
})
|
||||
|
||||
@@ -37,6 +37,19 @@ import { pieceListUtils } from './utils'
|
||||
|
||||
export const pieceRepos = repoFactory(PieceMetadataEntity)
|
||||
|
||||
// Map of old/renamed piece names to their current names
|
||||
// This allows templates with old piece references to still work
|
||||
const PIECE_NAME_ALIASES: Record<string, string> = {
|
||||
'@activepieces/piece-text-ai': '@activepieces/piece-ai',
|
||||
'@activepieces/piece-utility-ai': '@activepieces/piece-ai',
|
||||
'@activepieces/piece-image-ai': '@activepieces/piece-ai',
|
||||
}
|
||||
|
||||
// Cache for dev pieces to avoid reading from disk on every request
|
||||
let devPiecesCache: PieceMetadataSchema[] | null = null
|
||||
let devPiecesCacheTime: number = 0
|
||||
const DEV_PIECES_CACHE_TTL_MS = 60000 // 1 minute cache
|
||||
|
||||
export const pieceMetadataService = (log: FastifyBaseLogger) => {
|
||||
return {
|
||||
async setup(): Promise<void> {
|
||||
@@ -89,13 +102,35 @@ export const pieceMetadataService = (log: FastifyBaseLogger) => {
|
||||
release: undefined,
|
||||
log,
|
||||
})
|
||||
const piece = originalPieces.find((piece) => {
|
||||
let piece = originalPieces.find((piece) => {
|
||||
const strictlyLessThan = (isNil(versionToSearch) || (
|
||||
semVer.compare(piece.version, versionToSearch.nextExcludedVersion) < 0
|
||||
&& semVer.compare(piece.version, versionToSearch.baseVersion) >= 0
|
||||
))
|
||||
return piece.name === name && strictlyLessThan
|
||||
})
|
||||
|
||||
// Fall back to latest version if specific version not found
|
||||
// This allows templates with old piece versions to still work
|
||||
if (isNil(piece) && !isNil(version)) {
|
||||
piece = originalPieces.find((p) => p.name === name)
|
||||
if (!isNil(piece)) {
|
||||
log.info(`Piece ${name} version ${version} not found, falling back to latest version ${piece.version}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Try piece name alias if piece still not found
|
||||
// This handles renamed pieces (e.g., piece-text-ai -> piece-ai)
|
||||
if (isNil(piece)) {
|
||||
const aliasedName = PIECE_NAME_ALIASES[name]
|
||||
if (!isNil(aliasedName)) {
|
||||
piece = originalPieces.find((p) => p.name === aliasedName)
|
||||
if (!isNil(piece)) {
|
||||
log.info(`Piece ${name} not found, using alias ${aliasedName} (version ${piece.version})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isFiltered = !isNil(piece) && await enterpriseFilteringUtils.isFiltered({
|
||||
piece,
|
||||
projectId,
|
||||
@@ -287,10 +322,20 @@ const loadDevPiecesIfEnabled = async (log: FastifyBaseLogger): Promise<PieceMeta
|
||||
if (isNil(devPiecesConfig) || isEmpty(devPiecesConfig)) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Check if cache is still valid
|
||||
const now = Date.now()
|
||||
if (!isNil(devPiecesCache) && (now - devPiecesCacheTime) < DEV_PIECES_CACHE_TTL_MS) {
|
||||
log.debug(`Using cached dev pieces (${devPiecesCache.length} pieces, age: ${now - devPiecesCacheTime}ms)`)
|
||||
return devPiecesCache
|
||||
}
|
||||
|
||||
// Cache expired or doesn't exist, load from disk
|
||||
log.info('Loading dev pieces from disk (cache expired or empty)')
|
||||
const piecesNames = devPiecesConfig.split(',')
|
||||
const pieces = await filePiecesUtils(log).loadDistPiecesMetadata(piecesNames)
|
||||
|
||||
return pieces.map((p): PieceMetadataSchema => ({
|
||||
const result = pieces.map((p): PieceMetadataSchema => ({
|
||||
id: apId(),
|
||||
...p,
|
||||
projectUsage: 0,
|
||||
@@ -299,6 +344,13 @@ const loadDevPiecesIfEnabled = async (log: FastifyBaseLogger): Promise<PieceMeta
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
}))
|
||||
|
||||
// Update cache
|
||||
devPiecesCache = result
|
||||
devPiecesCacheTime = now
|
||||
log.info(`Cached ${result.length} dev pieces`)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const findOldestCreatedDate = async ({ name, platformId }: { name: string, platformId?: string }): Promise<string> => {
|
||||
|
||||
@@ -25,6 +25,29 @@ export const communityTemplates = {
|
||||
const templates = await response.json()
|
||||
return templates
|
||||
},
|
||||
getById: async (id: string): Promise<Template | null> => {
|
||||
const templateSource = system.get(AppSystemProp.TEMPLATES_SOURCE_URL)
|
||||
if (isNil(templateSource)) {
|
||||
return null
|
||||
}
|
||||
// Fetch the template by ID from the cloud templates endpoint
|
||||
const url = `${templateSource}/${id}`
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
return await response.json()
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,14 @@ const edition = system.getEdition()
|
||||
|
||||
export const templateController: FastifyPluginAsyncTypebox = async (app) => {
|
||||
app.get('/:id', GetParams, async (request) => {
|
||||
// For community edition, try to fetch from cloud templates first
|
||||
if (edition !== ApEdition.CLOUD) {
|
||||
const cloudTemplate = await communityTemplates.getById(request.params.id)
|
||||
if (!isNil(cloudTemplate)) {
|
||||
return cloudTemplate
|
||||
}
|
||||
}
|
||||
// Fall back to local database
|
||||
return templateService().getOneOrThrow({ id: request.params.id })
|
||||
})
|
||||
|
||||
|
||||
168
activepieces-fork/publish-pieces.sh
Normal file
@@ -0,0 +1,168 @@
|
||||
#!/bin/sh
|
||||
# Publish custom pieces to Verdaccio and register metadata in database
|
||||
# This script runs on container startup
|
||||
|
||||
set -e
|
||||
|
||||
VERDACCIO_URL="${VERDACCIO_URL:-http://verdaccio:4873}"
|
||||
PIECES_DIR="/usr/src/app/dist/packages/pieces/community"
|
||||
CUSTOM_PIECES="smoothschedule python-code ruby-code interfaces"
|
||||
|
||||
# Wait for Verdaccio to be ready
|
||||
wait_for_verdaccio() {
|
||||
echo "Waiting for Verdaccio to be ready..."
|
||||
max_attempts=30
|
||||
attempt=0
|
||||
while [ $attempt -lt $max_attempts ]; do
|
||||
if curl -sf "$VERDACCIO_URL/-/ping" > /dev/null 2>&1; then
|
||||
echo "Verdaccio is ready!"
|
||||
return 0
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
echo "Attempt $attempt/$max_attempts - Verdaccio not ready yet..."
|
||||
sleep 2
|
||||
done
|
||||
echo "Warning: Verdaccio not available after $max_attempts attempts"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Configure npm/bun to use Verdaccio with authentication
|
||||
configure_registry() {
|
||||
echo "Configuring npm registry to use Verdaccio..."
|
||||
|
||||
# Register user with Verdaccio first
|
||||
echo "Registering npm user with Verdaccio..."
|
||||
RESPONSE=$(curl -sf -X PUT "$VERDACCIO_URL/-/user/org.couchdb.user:publisher" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"publisher","password":"publisher","email":"publisher@smoothschedule.com"}' 2>&1) || true
|
||||
echo "Registration response: $RESPONSE"
|
||||
|
||||
# Extract token from response if available
|
||||
TOKEN=$(echo "$RESPONSE" | node -pe "JSON.parse(require('fs').readFileSync('/dev/stdin').toString()).token" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$TOKEN" ] && [ "$TOKEN" != "undefined" ]; then
|
||||
echo "Using token from registration"
|
||||
cat > ~/.npmrc << EOF
|
||||
registry=$VERDACCIO_URL
|
||||
//verdaccio:4873/:_authToken=$TOKEN
|
||||
EOF
|
||||
else
|
||||
echo "Using basic auth"
|
||||
# Use legacy _auth format (base64 of username:password)
|
||||
AUTH=$(echo -n "publisher:publisher" | base64)
|
||||
cat > ~/.npmrc << EOF
|
||||
registry=$VERDACCIO_URL
|
||||
//verdaccio:4873/:_auth=$AUTH
|
||||
always-auth=true
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Create bunfig.toml for bun
|
||||
mkdir -p ~/.bun
|
||||
cat > ~/.bun/bunfig.toml << EOF
|
||||
[install]
|
||||
registry = "$VERDACCIO_URL"
|
||||
EOF
|
||||
echo "Registry configured: $VERDACCIO_URL"
|
||||
}
|
||||
|
||||
# Publish a piece to Verdaccio
|
||||
publish_piece() {
|
||||
piece_name=$1
|
||||
piece_dir="$PIECES_DIR/$piece_name"
|
||||
|
||||
if [ ! -d "$piece_dir" ]; then
|
||||
echo "Warning: Piece directory not found: $piece_dir"
|
||||
return 1
|
||||
fi
|
||||
|
||||
cd "$piece_dir"
|
||||
|
||||
# Get package name and version
|
||||
pkg_name=$(node -p "require('./package.json').name")
|
||||
pkg_version=$(node -p "require('./package.json').version")
|
||||
|
||||
echo "Publishing $pkg_name@$pkg_version to Verdaccio..."
|
||||
|
||||
# Check if already published
|
||||
if npm view "$pkg_name@$pkg_version" --registry "$VERDACCIO_URL" > /dev/null 2>&1; then
|
||||
echo " $pkg_name@$pkg_version already published, skipping..."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Publish to Verdaccio (--force to allow republishing)
|
||||
if npm publish --registry "$VERDACCIO_URL" 2>&1; then
|
||||
echo " Successfully published $pkg_name@$pkg_version"
|
||||
else
|
||||
echo " Warning: Could not publish $pkg_name (may already exist)"
|
||||
fi
|
||||
|
||||
cd /usr/src/app
|
||||
}
|
||||
|
||||
# Insert piece metadata into database
|
||||
insert_metadata() {
|
||||
if [ -z "$AP_POSTGRES_HOST" ] || [ -z "$AP_POSTGRES_DATABASE" ]; then
|
||||
echo "Warning: Database configuration not available, skipping metadata insertion"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Inserting custom piece metadata into database..."
|
||||
echo " Host: $AP_POSTGRES_HOST"
|
||||
echo " Database: $AP_POSTGRES_DATABASE"
|
||||
echo " User: $AP_POSTGRES_USERNAME"
|
||||
|
||||
# Wait for PostgreSQL to be ready
|
||||
max_attempts=30
|
||||
attempt=0
|
||||
while [ $attempt -lt $max_attempts ]; do
|
||||
if PGPASSWORD="$AP_POSTGRES_PASSWORD" psql -h "$AP_POSTGRES_HOST" -p "${AP_POSTGRES_PORT:-5432}" -U "$AP_POSTGRES_USERNAME" -d "$AP_POSTGRES_DATABASE" -c "SELECT 1" > /dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
echo "Waiting for PostgreSQL... ($attempt/$max_attempts)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ $attempt -eq $max_attempts ]; then
|
||||
echo "Warning: PostgreSQL not available, skipping metadata insertion"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Run the SQL file
|
||||
PGPASSWORD="$AP_POSTGRES_PASSWORD" psql -h "$AP_POSTGRES_HOST" -p "${AP_POSTGRES_PORT:-5432}" -U "$AP_POSTGRES_USERNAME" -d "$AP_POSTGRES_DATABASE" -f /usr/src/app/custom-pieces-metadata.sql
|
||||
|
||||
echo "Piece metadata inserted successfully!"
|
||||
}
|
||||
|
||||
# Main execution
|
||||
main() {
|
||||
echo "============================================"
|
||||
echo "Custom Pieces Registration"
|
||||
echo "============================================"
|
||||
|
||||
# Check if Verdaccio is configured and available
|
||||
if [ -n "$VERDACCIO_URL" ] && [ "$VERDACCIO_URL" != "none" ]; then
|
||||
if wait_for_verdaccio; then
|
||||
configure_registry
|
||||
|
||||
# Publish each custom piece
|
||||
for piece in $CUSTOM_PIECES; do
|
||||
publish_piece "$piece" || true
|
||||
done
|
||||
else
|
||||
echo "Skipping Verdaccio publishing - pieces are pre-built in image"
|
||||
fi
|
||||
else
|
||||
echo "Verdaccio not configured - using pre-built pieces from image"
|
||||
fi
|
||||
|
||||
# Insert metadata into database
|
||||
insert_metadata || true
|
||||
|
||||
echo "============================================"
|
||||
echo "Custom Pieces Registration Complete"
|
||||
echo "============================================"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
153
deploy.sh
@@ -1,15 +1,33 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================
|
||||
# SmoothSchedule Production Deployment Script
|
||||
# Usage: ./deploy.sh [server_user@server_host] [services...]
|
||||
# Example: ./deploy.sh poduck@smoothschedule.com # Build all
|
||||
# Example: ./deploy.sh poduck@smoothschedule.com traefik # Build only traefik
|
||||
# Example: ./deploy.sh poduck@smoothschedule.com django nginx # Build django and nginx
|
||||
# ==============================================================================
|
||||
#
|
||||
# Available services: django, traefik, nginx, postgres, celeryworker, celerybeat, flower, awscli
|
||||
# Use --no-migrate to skip migrations (useful for config-only changes like traefik)
|
||||
# Usage: ./deploy.sh [server] [options] [services...]
|
||||
#
|
||||
# This script deploys from git repository, not local files.
|
||||
# Changes must be committed and pushed before deploying.
|
||||
# Examples:
|
||||
# ./deploy.sh # Deploy all services
|
||||
# ./deploy.sh --no-migrate # Deploy without migrations
|
||||
# ./deploy.sh django nginx # Deploy specific services
|
||||
# ./deploy.sh --deploy-ap # Build & deploy Activepieces image
|
||||
# ./deploy.sh poduck@server.com # Deploy to custom server
|
||||
#
|
||||
# Options:
|
||||
# --no-migrate Skip database migrations
|
||||
# --deploy-ap Build Activepieces image locally and transfer to server
|
||||
#
|
||||
# Available services:
|
||||
# django, traefik, nginx, postgres, celeryworker, celerybeat, flower, awscli, activepieces
|
||||
#
|
||||
# IMPORTANT: Activepieces Image
|
||||
# -----------------------------
|
||||
# The production server cannot build the Activepieces image (requires 4GB+ RAM).
|
||||
# Use --deploy-ap to build locally and transfer, or manually:
|
||||
# ./scripts/build-activepieces.sh deploy
|
||||
#
|
||||
# First-time setup:
|
||||
# Run ./smoothschedule/scripts/init-production.sh on the server
|
||||
# ==============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
@@ -23,12 +41,23 @@ NC='\033[0m' # No Color
|
||||
SERVER=""
|
||||
SERVICES=""
|
||||
SKIP_MIGRATE=false
|
||||
DEPLOY_AP=false
|
||||
|
||||
for arg in "$@"; do
|
||||
if [[ "$arg" == "--no-migrate" ]]; then
|
||||
SKIP_MIGRATE=true
|
||||
elif [[ -z "$SERVER" ]]; then
|
||||
elif [[ "$arg" == "--deploy-ap" ]]; then
|
||||
DEPLOY_AP=true
|
||||
elif [[ "$arg" == *"@"* ]]; then
|
||||
# Looks like user@host
|
||||
SERVER="$arg"
|
||||
elif [[ -z "$SERVER" && ! "$arg" =~ ^- ]]; then
|
||||
# First non-flag argument could be server or service
|
||||
if [[ "$arg" =~ ^(django|traefik|nginx|postgres|celeryworker|celerybeat|flower|awscli|activepieces|redis|verdaccio)$ ]]; then
|
||||
SERVICES="$SERVICES $arg"
|
||||
else
|
||||
SERVER="$arg"
|
||||
fi
|
||||
else
|
||||
SERVICES="$SERVICES $arg"
|
||||
fi
|
||||
@@ -38,6 +67,7 @@ SERVER=${SERVER:-"poduck@smoothschedule.com"}
|
||||
SERVICES=$(echo "$SERVICES" | xargs) # Trim whitespace
|
||||
REPO_URL="https://git.talova.net/poduck/smoothschedule.git"
|
||||
REMOTE_DIR="/home/poduck/smoothschedule"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
echo -e "${GREEN}==================================="
|
||||
echo "SmoothSchedule Deployment"
|
||||
@@ -51,6 +81,9 @@ fi
|
||||
if [[ "$SKIP_MIGRATE" == "true" ]]; then
|
||||
echo "Migrations: SKIPPED"
|
||||
fi
|
||||
if [[ "$DEPLOY_AP" == "true" ]]; then
|
||||
echo "Activepieces: BUILDING AND DEPLOYING"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Function to print status
|
||||
@@ -94,10 +127,45 @@ fi
|
||||
|
||||
print_status "All changes committed and pushed!"
|
||||
|
||||
# Step 2: Deploy on server
|
||||
print_status "Step 2: Deploying on server..."
|
||||
# Step 2: Build and deploy Activepieces image (if requested)
|
||||
if [[ "$DEPLOY_AP" == "true" ]]; then
|
||||
print_status "Step 2: Building and deploying Activepieces image..."
|
||||
|
||||
# Check if the build script exists
|
||||
if [[ -f "$SCRIPT_DIR/scripts/build-activepieces.sh" ]]; then
|
||||
"$SCRIPT_DIR/scripts/build-activepieces.sh" deploy "$SERVER"
|
||||
else
|
||||
print_warning "Build script not found, building manually..."
|
||||
|
||||
# Build the image
|
||||
print_status "Building Activepieces Docker image locally..."
|
||||
cd "$SCRIPT_DIR/activepieces-fork"
|
||||
docker build -t smoothschedule_production_activepieces .
|
||||
|
||||
# Save and transfer
|
||||
print_status "Transferring image to server..."
|
||||
docker save smoothschedule_production_activepieces | gzip > /tmp/ap-image.tar.gz
|
||||
scp /tmp/ap-image.tar.gz "$SERVER:/tmp/"
|
||||
ssh "$SERVER" "gunzip -c /tmp/ap-image.tar.gz | docker load && rm /tmp/ap-image.tar.gz"
|
||||
rm /tmp/ap-image.tar.gz
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
fi
|
||||
|
||||
print_status "Activepieces image deployed!"
|
||||
fi
|
||||
|
||||
# Step 3: Deploy on server
|
||||
print_status "Step 3: Deploying on server..."
|
||||
|
||||
# Set SKIP_AP_BUILD if we already deployed activepieces image
|
||||
SKIP_AP_BUILD_VAL="false"
|
||||
if $DEPLOY_AP; then
|
||||
SKIP_AP_BUILD_VAL="true"
|
||||
fi
|
||||
|
||||
ssh "$SERVER" "bash -s" << ENDSSH
|
||||
SKIP_AP_BUILD="$SKIP_AP_BUILD_VAL"
|
||||
set -e
|
||||
|
||||
echo ">>> Setting up project directory..."
|
||||
@@ -160,9 +228,16 @@ git log -1 --oneline
|
||||
cd smoothschedule
|
||||
|
||||
# Build images (all or specific services)
|
||||
# Note: If activepieces was pre-deployed via --deploy-ap, skip rebuilding it
|
||||
# Use COMPOSE_PARALLEL_LIMIT to reduce memory usage on low-memory servers
|
||||
export COMPOSE_PARALLEL_LIMIT=1
|
||||
if [[ -n "$SERVICES" ]]; then
|
||||
echo ">>> Building Docker images: $SERVICES..."
|
||||
docker compose -f docker-compose.production.yml build $SERVICES
|
||||
elif [[ "$SKIP_AP_BUILD" == "true" ]]; then
|
||||
# Skip activepieces build since we pre-built and transferred it
|
||||
echo ">>> Building Docker images (excluding activepieces - pre-built)..."
|
||||
docker compose -f docker-compose.production.yml build django nginx traefik postgres celeryworker celerybeat flower awscli verdaccio
|
||||
else
|
||||
echo ">>> Building all Docker images..."
|
||||
docker compose -f docker-compose.production.yml build
|
||||
@@ -174,6 +249,61 @@ docker compose -f docker-compose.production.yml up -d
|
||||
echo ">>> Waiting for containers to start..."
|
||||
sleep 5
|
||||
|
||||
# Setup Activepieces database (if not exists)
|
||||
echo ">>> Setting up Activepieces database..."
|
||||
AP_DB_USER=\$(grep AP_POSTGRES_USERNAME .envs/.production/.activepieces | cut -d= -f2)
|
||||
AP_DB_PASS=\$(grep AP_POSTGRES_PASSWORD .envs/.production/.activepieces | cut -d= -f2)
|
||||
AP_DB_NAME=\$(grep AP_POSTGRES_DATABASE .envs/.production/.activepieces | cut -d= -f2)
|
||||
# Get the Django postgres user from env file (this is the superuser for our DB)
|
||||
DJANGO_DB_USER=\$(grep POSTGRES_USER .envs/.production/.postgres | cut -d= -f2)
|
||||
DJANGO_DB_USER=\${DJANGO_DB_USER:-postgres}
|
||||
|
||||
if [ -n "\$AP_DB_USER" ] && [ -n "\$AP_DB_PASS" ] && [ -n "\$AP_DB_NAME" ]; then
|
||||
# Check if user exists, create if not
|
||||
docker compose -f docker-compose.production.yml exec -T postgres psql -U "\$DJANGO_DB_USER" -d postgres -tc "SELECT 1 FROM pg_roles WHERE rolname='\$AP_DB_USER'" | grep -q 1 || {
|
||||
echo " Creating Activepieces database user..."
|
||||
docker compose -f docker-compose.production.yml exec -T postgres psql -U "\$DJANGO_DB_USER" -d postgres -c "CREATE USER \"\$AP_DB_USER\" WITH PASSWORD '\$AP_DB_PASS';"
|
||||
}
|
||||
|
||||
# Check if database exists, create if not
|
||||
docker compose -f docker-compose.production.yml exec -T postgres psql -U "\$DJANGO_DB_USER" -d postgres -tc "SELECT 1 FROM pg_database WHERE datname='\$AP_DB_NAME'" | grep -q 1 || {
|
||||
echo " Creating Activepieces database..."
|
||||
docker compose -f docker-compose.production.yml exec -T postgres psql -U "\$DJANGO_DB_USER" -d postgres -c "CREATE DATABASE \$AP_DB_NAME OWNER \"\$AP_DB_USER\";"
|
||||
}
|
||||
echo " Activepieces database ready."
|
||||
else
|
||||
echo " Warning: Could not read Activepieces database config from .envs/.production/.activepieces"
|
||||
fi
|
||||
|
||||
# Wait for Activepieces to be ready
|
||||
echo ">>> Waiting for Activepieces to be ready..."
|
||||
for i in {1..30}; do
|
||||
if curl -s http://localhost:80/api/v1/health 2>/dev/null | grep -q "ok"; then
|
||||
echo " Activepieces is ready."
|
||||
break
|
||||
fi
|
||||
if [ \$i -eq 30 ]; then
|
||||
echo " Warning: Activepieces health check timed out. It may still be starting."
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Check if Activepieces platform exists
|
||||
echo ">>> Checking Activepieces platform..."
|
||||
AP_PLATFORM_ID=\$(grep AP_PLATFORM_ID .envs/.production/.activepieces | cut -d= -f2)
|
||||
if [ -z "\$AP_PLATFORM_ID" ] || [ "\$AP_PLATFORM_ID" = "" ]; then
|
||||
echo " WARNING: No AP_PLATFORM_ID configured in .envs/.production/.activepieces"
|
||||
echo " To initialize Activepieces for the first time:"
|
||||
echo " 1. Visit https://automations.smoothschedule.com"
|
||||
echo " 2. Create an admin user (this creates the platform)"
|
||||
echo " 3. Get the platform ID from the response or database"
|
||||
echo " 4. Update AP_PLATFORM_ID in .envs/.production/.activepieces"
|
||||
echo " 5. Also update AP_PLATFORM_ID in .envs/.production/.django"
|
||||
echo " 6. Restart Activepieces: docker compose -f docker-compose.production.yml restart activepieces"
|
||||
else
|
||||
echo " Activepieces platform configured: \$AP_PLATFORM_ID"
|
||||
fi
|
||||
|
||||
# Run migrations unless skipped
|
||||
if [[ "$SKIP_MIGRATE" != "true" ]]; then
|
||||
echo ">>> Running database migrations..."
|
||||
@@ -210,6 +340,7 @@ echo "Your application should now be running at:"
|
||||
echo " - https://smoothschedule.com"
|
||||
echo " - https://platform.smoothschedule.com"
|
||||
echo " - https://*.smoothschedule.com (tenant subdomains)"
|
||||
echo " - https://automations.smoothschedule.com (Activepieces)"
|
||||
echo ""
|
||||
echo "To view logs:"
|
||||
echo " ssh $SERVER 'cd ~/smoothschedule/smoothschedule && docker compose -f docker-compose.production.yml logs -f'"
|
||||
|
||||
BIN
frontend/email-page-debug.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,71 @@
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e3]:
|
||||
- generic [ref=e7]:
|
||||
- link "Smooth Schedule" [ref=e9] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e10]
|
||||
- generic [ref=e16]: Smooth Schedule
|
||||
- generic [ref=e17]:
|
||||
- heading "Orchestrate your business with precision." [level=1] [ref=e18]
|
||||
- paragraph [ref=e19]: The all-in-one scheduling platform for businesses of all sizes. Manage resources, staff, and bookings effortlessly.
|
||||
- generic [ref=e24]: © 2025 Smooth Schedule Inc.
|
||||
- generic [ref=e26]:
|
||||
- generic [ref=e27]:
|
||||
- heading "Welcome back" [level=2] [ref=e28]
|
||||
- paragraph [ref=e29]: Please enter your email and password to sign in.
|
||||
- generic [ref=e31]:
|
||||
- img [ref=e33]
|
||||
- generic [ref=e35]:
|
||||
- heading "Authentication Error" [level=3] [ref=e36]
|
||||
- generic [ref=e37]: Invalid credentials
|
||||
- generic [ref=e38]:
|
||||
- generic [ref=e39]:
|
||||
- generic [ref=e40]:
|
||||
- generic [ref=e41]: Email
|
||||
- generic [ref=e42]:
|
||||
- generic:
|
||||
- img
|
||||
- textbox "Email" [ref=e43]:
|
||||
- /placeholder: Enter your email
|
||||
- text: owner@demo.com
|
||||
- generic [ref=e44]:
|
||||
- generic [ref=e45]: Password
|
||||
- generic [ref=e46]:
|
||||
- generic:
|
||||
- img
|
||||
- textbox "Password" [ref=e47]:
|
||||
- /placeholder: ••••••••
|
||||
- text: demopass123
|
||||
- button "Sign in" [ref=e48]:
|
||||
- generic [ref=e49]:
|
||||
- text: Sign in
|
||||
- img [ref=e50]
|
||||
- generic [ref=e57]: Or continue with
|
||||
- button "🇺🇸 English" [ref=e60]:
|
||||
- img [ref=e61]
|
||||
- generic [ref=e64]: 🇺🇸
|
||||
- generic [ref=e65]: English
|
||||
- img [ref=e66]
|
||||
- generic [ref=e68]:
|
||||
- heading "🔓 Quick Login (Dev Only)" [level=3] [ref=e70]:
|
||||
- generic [ref=e71]: 🔓
|
||||
- generic [ref=e72]: Quick Login (Dev Only)
|
||||
- generic [ref=e73]:
|
||||
- button "Business Owner TENANT_OWNER" [ref=e74]:
|
||||
- generic [ref=e75]:
|
||||
- generic [ref=e76]: Business Owner
|
||||
- generic [ref=e77]: TENANT_OWNER
|
||||
- button "Staff (Full Access) TENANT_STAFF" [ref=e78]:
|
||||
- generic [ref=e79]:
|
||||
- generic [ref=e80]: Staff (Full Access)
|
||||
- generic [ref=e81]: TENANT_STAFF
|
||||
- button "Staff (Limited) TENANT_STAFF" [ref=e82]:
|
||||
- generic [ref=e83]:
|
||||
- generic [ref=e84]: Staff (Limited)
|
||||
- generic [ref=e85]: TENANT_STAFF
|
||||
- generic [ref=e86]:
|
||||
- text: "Password for all:"
|
||||
- code [ref=e87]: test123
|
||||
```
|
||||
|
After Width: | Height: | Size: 446 KiB |
@@ -10,7 +10,7 @@ import { useCurrentUser, useMasquerade, useLogout } from './hooks/useAuth';
|
||||
import { useCurrentBusiness } from './hooks/useBusiness';
|
||||
import { useUpdateBusiness } from './hooks/useBusiness';
|
||||
import { usePlanFeatures } from './hooks/usePlanFeatures';
|
||||
import { setCookie } from './utils/cookies';
|
||||
import { setCookie, deleteCookie } from './utils/cookies';
|
||||
|
||||
// Import Login Page
|
||||
const LoginPage = React.lazy(() => import('./pages/LoginPage'));
|
||||
@@ -65,6 +65,7 @@ const PlatformUsers = React.lazy(() => import('./pages/platform/PlatformUsers'))
|
||||
const PlatformStaff = React.lazy(() => import('./pages/platform/PlatformStaff'));
|
||||
const PlatformSettings = React.lazy(() => import('./pages/platform/PlatformSettings'));
|
||||
const BillingManagement = React.lazy(() => import('./pages/platform/BillingManagement'));
|
||||
const PlatformStaffEmail = React.lazy(() => import('./pages/platform/PlatformStaffEmail'));
|
||||
const ProfileSettings = React.lazy(() => import('./pages/ProfileSettings'));
|
||||
const VerifyEmail = React.lazy(() => import('./pages/VerifyEmail'));
|
||||
const EmailVerificationRequired = React.lazy(() => import('./pages/EmailVerificationRequired'));
|
||||
@@ -321,9 +322,37 @@ const AppContent: React.FC = () => {
|
||||
return hostname === 'localhost' || hostname === '127.0.0.1' || parts.length === 2;
|
||||
};
|
||||
|
||||
// On root domain, ALWAYS show marketing site (even if logged in)
|
||||
// Logged-in users will see a "Go to Dashboard" link in the navbar
|
||||
// On root domain, handle logged-in users appropriately
|
||||
if (isRootDomain()) {
|
||||
// If user is logged in as a business user (owner, staff, resource), redirect to their tenant dashboard
|
||||
if (user) {
|
||||
const isBusinessUserOnRoot = ['owner', 'staff', 'resource'].includes(user.role);
|
||||
const isCustomerOnRoot = user.role === 'customer';
|
||||
const hostname = window.location.hostname;
|
||||
const parts = hostname.split('.');
|
||||
const baseDomain = parts.length >= 2 ? parts.slice(-2).join('.') : hostname;
|
||||
const port = window.location.port ? `:${window.location.port}` : '';
|
||||
const protocol = window.location.protocol;
|
||||
|
||||
// Business users on root domain: redirect to their tenant dashboard
|
||||
if (isBusinessUserOnRoot && user.business_subdomain) {
|
||||
window.location.href = `${protocol}//${user.business_subdomain}.${baseDomain}${port}/dashboard`;
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
// Customers on root domain: log them out and show the form
|
||||
// Customers should only access their business subdomain
|
||||
if (isCustomerOnRoot) {
|
||||
deleteCookie('access_token');
|
||||
deleteCookie('refresh_token');
|
||||
localStorage.removeItem('masquerade_stack');
|
||||
// Don't redirect, just let them see the page as unauthenticated
|
||||
window.location.reload();
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
}
|
||||
|
||||
// Show marketing site for unauthenticated users and platform users (who should use platform subdomain)
|
||||
return (
|
||||
<Suspense fallback={<LoadingScreen />}>
|
||||
<Routes>
|
||||
@@ -463,6 +492,16 @@ const AppContent: React.FC = () => {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
// RULE: Non-platform users on platform subdomain should have their session cleared
|
||||
// This handles cases where masquerading changed tokens to a business user
|
||||
if (!isPlatformUser && isPlatformDomain) {
|
||||
deleteCookie('access_token');
|
||||
deleteCookie('refresh_token');
|
||||
localStorage.removeItem('masquerade_stack');
|
||||
window.location.href = '/platform/login';
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
// RULE: Business users must be on their own business subdomain
|
||||
if (isBusinessUser && isBusinessSubdomain && user.business_subdomain && user.business_subdomain !== currentSubdomain) {
|
||||
const port = window.location.port ? `:${window.location.port}` : '';
|
||||
@@ -470,16 +509,23 @@ const AppContent: React.FC = () => {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
// RULE: Customers must be on their business subdomain
|
||||
if (isCustomer && isPlatformDomain && user.business_subdomain) {
|
||||
const port = window.location.port ? `:${window.location.port}` : '';
|
||||
window.location.href = `${protocol}//${user.business_subdomain}.${baseDomain}${port}/`;
|
||||
// RULE: Customers must only access their own business subdomain
|
||||
// If on platform domain or wrong business subdomain, log them out and let them use the form
|
||||
if (isCustomer && isPlatformDomain) {
|
||||
deleteCookie('access_token');
|
||||
deleteCookie('refresh_token');
|
||||
localStorage.removeItem('masquerade_stack');
|
||||
window.location.reload();
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (isCustomer && isBusinessSubdomain && user.business_subdomain && user.business_subdomain !== currentSubdomain) {
|
||||
const port = window.location.port ? `:${window.location.port}` : '';
|
||||
window.location.href = `${protocol}//${user.business_subdomain}.${baseDomain}${port}/`;
|
||||
// Customer is on a different business's subdomain - log them out
|
||||
// They might be trying to book with a different business
|
||||
deleteCookie('access_token');
|
||||
deleteCookie('refresh_token');
|
||||
localStorage.removeItem('masquerade_stack');
|
||||
window.location.reload();
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
@@ -540,6 +586,7 @@ const AppContent: React.FC = () => {
|
||||
)}
|
||||
<Route path="/platform/support" element={<PlatformSupportPage />} />
|
||||
<Route path="/platform/email-addresses" element={<PlatformEmailAddresses />} />
|
||||
<Route path="/platform/email" element={<PlatformStaffEmail />} />
|
||||
<Route path="/help/guide" element={<HelpGuide />} />
|
||||
<Route path="/help/ticketing" element={<HelpTicketing />} />
|
||||
<Route path="/help/api" element={<HelpApiDocs />} />
|
||||
@@ -713,7 +760,8 @@ const AppContent: React.FC = () => {
|
||||
<Route path="/" element={<PublicPage />} />
|
||||
<Route path="/book" element={<BookingFlow />} />
|
||||
<Route path="/embed" element={<EmbedBooking />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
{/* Logged-in business users on their own subdomain get redirected to dashboard */}
|
||||
<Route path="/login" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/sign/:token" element={<ContractSigning />} />
|
||||
|
||||
{/* Dashboard routes inside BusinessLayout */}
|
||||
|
||||
36
frontend/src/api/activepieces.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import api from './client';
|
||||
|
||||
export interface DefaultFlow {
|
||||
flow_type: string;
|
||||
display_name: string;
|
||||
activepieces_flow_id: string | null;
|
||||
is_modified: boolean;
|
||||
is_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface RestoreFlowResponse {
|
||||
success: boolean;
|
||||
flow_type: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RestoreAllResponse {
|
||||
success: boolean;
|
||||
restored: string[];
|
||||
failed: string[];
|
||||
}
|
||||
|
||||
export const getDefaultFlows = async (): Promise<DefaultFlow[]> => {
|
||||
const response = await api.get('/activepieces/default-flows/');
|
||||
return response.data.flows;
|
||||
};
|
||||
|
||||
export const restoreFlow = async (flowType: string): Promise<RestoreFlowResponse> => {
|
||||
const response = await api.post(`/activepieces/default-flows/${flowType}/restore/`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const restoreAllFlows = async (): Promise<RestoreAllResponse> => {
|
||||
const response = await api.post('/activepieces/default-flows/restore-all/');
|
||||
return response.data;
|
||||
};
|
||||
@@ -88,7 +88,7 @@ apiClient.interceptors.response.use(
|
||||
return apiClient(originalRequest);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
// Refresh failed - clear tokens and redirect to login on root domain
|
||||
// Refresh failed - clear tokens and redirect to appropriate login page
|
||||
const { deleteCookie } = await import('../utils/cookies');
|
||||
const { getBaseDomain } = await import('../utils/domain');
|
||||
deleteCookie('access_token');
|
||||
@@ -96,7 +96,16 @@ apiClient.interceptors.response.use(
|
||||
const protocol = window.location.protocol;
|
||||
const baseDomain = getBaseDomain();
|
||||
const port = window.location.port ? `:${window.location.port}` : '';
|
||||
window.location.href = `${protocol}//${baseDomain}${port}/login`;
|
||||
const hostname = window.location.hostname;
|
||||
|
||||
// Check if on platform subdomain
|
||||
if (hostname.startsWith('platform.')) {
|
||||
// Platform users go to platform login page
|
||||
window.location.href = `${protocol}//platform.${baseDomain}${port}/platform/login`;
|
||||
} else {
|
||||
// Business users go to their subdomain's login page
|
||||
window.location.href = `${protocol}//${hostname}${port}/login`;
|
||||
}
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface PlatformEmailAddressListItem {
|
||||
email_address: string;
|
||||
color: string;
|
||||
assigned_user?: AssignedUser | null;
|
||||
routing_mode: 'PLATFORM' | 'STAFF';
|
||||
is_active: boolean;
|
||||
is_default: boolean;
|
||||
mail_server_synced: boolean;
|
||||
@@ -78,6 +79,7 @@ export interface PlatformEmailAddressCreate {
|
||||
domain: string;
|
||||
color: string;
|
||||
password: string;
|
||||
routing_mode?: 'PLATFORM' | 'STAFF';
|
||||
is_active: boolean;
|
||||
is_default: boolean;
|
||||
}
|
||||
@@ -88,6 +90,7 @@ export interface PlatformEmailAddressUpdate {
|
||||
assigned_user_id?: number | null;
|
||||
color?: string;
|
||||
password?: string;
|
||||
routing_mode?: 'PLATFORM' | 'STAFF';
|
||||
is_active?: boolean;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
442
frontend/src/api/staffEmail.ts
Normal file
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* Staff Email API Client
|
||||
*
|
||||
* Provides API functions for the platform staff email client.
|
||||
* This is for platform users (superuser, platform_manager, platform_support)
|
||||
* who have been assigned email addresses in staff routing mode.
|
||||
*/
|
||||
|
||||
import apiClient from './client';
|
||||
import {
|
||||
StaffEmailFolder,
|
||||
StaffEmail,
|
||||
StaffEmailListItem,
|
||||
StaffEmailLabel,
|
||||
StaffEmailAttachment,
|
||||
StaffEmailFilters,
|
||||
StaffEmailCreateDraft,
|
||||
StaffEmailMove,
|
||||
StaffEmailBulkAction,
|
||||
StaffEmailReply,
|
||||
StaffEmailForward,
|
||||
EmailContactSuggestion,
|
||||
StaffEmailStats,
|
||||
} from '../types';
|
||||
|
||||
const BASE_URL = '/staff-email';
|
||||
|
||||
// ============================================================================
|
||||
// Folders
|
||||
// ============================================================================
|
||||
|
||||
export const getFolders = async (): Promise<StaffEmailFolder[]> => {
|
||||
const response = await apiClient.get(`${BASE_URL}/folders/`);
|
||||
return response.data.map(transformFolder);
|
||||
};
|
||||
|
||||
export const createFolder = async (name: string): Promise<StaffEmailFolder> => {
|
||||
const response = await apiClient.post(`${BASE_URL}/folders/`, { name });
|
||||
return transformFolder(response.data);
|
||||
};
|
||||
|
||||
export const updateFolder = async (id: number, name: string): Promise<StaffEmailFolder> => {
|
||||
const response = await apiClient.patch(`${BASE_URL}/folders/${id}/`, { name });
|
||||
return transformFolder(response.data);
|
||||
};
|
||||
|
||||
export const deleteFolder = async (id: number): Promise<void> => {
|
||||
await apiClient.delete(`${BASE_URL}/folders/${id}/`);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Emails (Messages)
|
||||
// ============================================================================
|
||||
|
||||
export interface PaginatedEmailResponse {
|
||||
count: number;
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
results: StaffEmailListItem[];
|
||||
}
|
||||
|
||||
export const getEmails = async (
|
||||
filters?: StaffEmailFilters,
|
||||
page: number = 1,
|
||||
pageSize: number = 50
|
||||
): Promise<PaginatedEmailResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', String(page));
|
||||
params.append('page_size', String(pageSize));
|
||||
|
||||
if (filters?.folderId) params.append('folder', String(filters.folderId));
|
||||
if (filters?.emailAddressId) params.append('email_address', String(filters.emailAddressId));
|
||||
if (filters?.isRead !== undefined) params.append('is_read', String(filters.isRead));
|
||||
if (filters?.isStarred !== undefined) params.append('is_starred', String(filters.isStarred));
|
||||
if (filters?.isImportant !== undefined) params.append('is_important', String(filters.isImportant));
|
||||
if (filters?.labelId) params.append('label', String(filters.labelId));
|
||||
if (filters?.search) params.append('search', filters.search);
|
||||
if (filters?.fromDate) params.append('from_date', filters.fromDate);
|
||||
if (filters?.toDate) params.append('to_date', filters.toDate);
|
||||
|
||||
// Debug logging - remove after fixing folder filter issue
|
||||
console.log('[StaffEmail API] getEmails called with:', { filters, params: params.toString() });
|
||||
|
||||
const response = await apiClient.get(`${BASE_URL}/messages/?${params.toString()}`);
|
||||
|
||||
// Handle both paginated response {count, results, ...} and legacy array response
|
||||
const data = response.data;
|
||||
console.log('[StaffEmail API] Raw response data:', data);
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
// Legacy format (array of emails)
|
||||
console.log('[StaffEmail API] Response (legacy array):', { count: data.length });
|
||||
return {
|
||||
count: data.length,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: data.map(transformEmailListItem),
|
||||
};
|
||||
}
|
||||
|
||||
// New paginated format
|
||||
const result = {
|
||||
count: data.count ?? 0,
|
||||
next: data.next ?? null,
|
||||
previous: data.previous ?? null,
|
||||
results: (data.results ?? []).map(transformEmailListItem),
|
||||
};
|
||||
|
||||
// Debug logging - remove after fixing folder filter issue
|
||||
console.log('[StaffEmail API] Response:', { count: result.count, resultCount: result.results.length });
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getEmail = async (id: number): Promise<StaffEmail> => {
|
||||
const response = await apiClient.get(`${BASE_URL}/messages/${id}/`);
|
||||
return transformEmail(response.data);
|
||||
};
|
||||
|
||||
export const getEmailThread = async (threadId: string): Promise<StaffEmail[]> => {
|
||||
const response = await apiClient.get(`${BASE_URL}/messages/`, {
|
||||
params: { thread_id: threadId },
|
||||
});
|
||||
return response.data.results.map(transformEmail);
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert string email addresses to the format expected by the backend.
|
||||
* Backend expects: [{ email: "test@example.com", name: "" }]
|
||||
* Frontend sends: ["test@example.com"]
|
||||
*/
|
||||
function formatEmailAddresses(addresses: string[]): Array<{ email: string; name: string }> {
|
||||
return addresses.map((addr) => {
|
||||
// Check if it's already in "Name <email>" format
|
||||
const match = addr.match(/^(.+?)\s*<(.+?)>$/);
|
||||
if (match) {
|
||||
return { name: match[1].trim(), email: match[2].trim() };
|
||||
}
|
||||
return { email: addr.trim(), name: '' };
|
||||
});
|
||||
}
|
||||
|
||||
export const createDraft = async (data: StaffEmailCreateDraft): Promise<StaffEmail> => {
|
||||
const payload = {
|
||||
email_address: data.emailAddressId,
|
||||
to_addresses: formatEmailAddresses(data.toAddresses),
|
||||
cc_addresses: formatEmailAddresses(data.ccAddresses || []),
|
||||
bcc_addresses: formatEmailAddresses(data.bccAddresses || []),
|
||||
subject: data.subject,
|
||||
body_text: data.bodyText || '',
|
||||
body_html: data.bodyHtml || '',
|
||||
in_reply_to: data.inReplyTo,
|
||||
thread_id: data.threadId,
|
||||
};
|
||||
const response = await apiClient.post(`${BASE_URL}/messages/`, payload);
|
||||
return transformEmail(response.data);
|
||||
};
|
||||
|
||||
export const updateDraft = async (id: number, data: Partial<StaffEmailCreateDraft>): Promise<StaffEmail> => {
|
||||
const payload: Record<string, any> = {};
|
||||
if (data.toAddresses !== undefined) payload.to_addresses = formatEmailAddresses(data.toAddresses);
|
||||
if (data.ccAddresses !== undefined) payload.cc_addresses = formatEmailAddresses(data.ccAddresses);
|
||||
if (data.bccAddresses !== undefined) payload.bcc_addresses = formatEmailAddresses(data.bccAddresses);
|
||||
if (data.subject !== undefined) payload.subject = data.subject;
|
||||
if (data.bodyText !== undefined) payload.body_text = data.bodyText;
|
||||
if (data.bodyHtml !== undefined) payload.body_html = data.bodyHtml;
|
||||
|
||||
const response = await apiClient.patch(`${BASE_URL}/messages/${id}/`, payload);
|
||||
return transformEmail(response.data);
|
||||
};
|
||||
|
||||
export const deleteDraft = async (id: number): Promise<void> => {
|
||||
await apiClient.delete(`${BASE_URL}/messages/${id}/`);
|
||||
};
|
||||
|
||||
export const sendEmail = async (id: number): Promise<StaffEmail> => {
|
||||
const response = await apiClient.post(`${BASE_URL}/messages/${id}/send/`);
|
||||
return transformEmail(response.data);
|
||||
};
|
||||
|
||||
export const replyToEmail = async (id: number, data: StaffEmailReply): Promise<StaffEmail> => {
|
||||
const payload = {
|
||||
body_text: data.bodyText || '',
|
||||
body_html: data.bodyHtml || '',
|
||||
reply_all: data.replyAll || false,
|
||||
};
|
||||
const response = await apiClient.post(`${BASE_URL}/messages/${id}/reply/`);
|
||||
return transformEmail(response.data);
|
||||
};
|
||||
|
||||
export const forwardEmail = async (id: number, data: StaffEmailForward): Promise<StaffEmail> => {
|
||||
const payload = {
|
||||
to_addresses: formatEmailAddresses(data.toAddresses),
|
||||
cc_addresses: formatEmailAddresses(data.ccAddresses || []),
|
||||
body_text: data.bodyText || '',
|
||||
body_html: data.bodyHtml || '',
|
||||
};
|
||||
const response = await apiClient.post(`${BASE_URL}/messages/${id}/forward/`, payload);
|
||||
return transformEmail(response.data);
|
||||
};
|
||||
|
||||
export const moveEmails = async (data: StaffEmailMove): Promise<void> => {
|
||||
await apiClient.post(`${BASE_URL}/messages/move/`, {
|
||||
email_ids: data.emailIds,
|
||||
folder_id: data.folderId,
|
||||
});
|
||||
};
|
||||
|
||||
export const markAsRead = async (id: number): Promise<void> => {
|
||||
await apiClient.post(`${BASE_URL}/messages/${id}/mark_read/`);
|
||||
};
|
||||
|
||||
export const markAsUnread = async (id: number): Promise<void> => {
|
||||
await apiClient.post(`${BASE_URL}/messages/${id}/mark_unread/`);
|
||||
};
|
||||
|
||||
export const starEmail = async (id: number): Promise<void> => {
|
||||
await apiClient.post(`${BASE_URL}/messages/${id}/star/`);
|
||||
};
|
||||
|
||||
export const unstarEmail = async (id: number): Promise<void> => {
|
||||
await apiClient.post(`${BASE_URL}/messages/${id}/unstar/`);
|
||||
};
|
||||
|
||||
export const archiveEmail = async (id: number): Promise<void> => {
|
||||
await apiClient.post(`${BASE_URL}/messages/${id}/archive/`);
|
||||
};
|
||||
|
||||
export const trashEmail = async (id: number): Promise<void> => {
|
||||
await apiClient.post(`${BASE_URL}/messages/${id}/trash/`);
|
||||
};
|
||||
|
||||
export const restoreEmail = async (id: number): Promise<void> => {
|
||||
await apiClient.post(`${BASE_URL}/messages/${id}/restore/`);
|
||||
};
|
||||
|
||||
export const permanentlyDeleteEmail = async (id: number): Promise<void> => {
|
||||
await apiClient.delete(`${BASE_URL}/messages/${id}/`);
|
||||
};
|
||||
|
||||
export const bulkAction = async (data: StaffEmailBulkAction): Promise<void> => {
|
||||
await apiClient.post(`${BASE_URL}/messages/bulk_action/`, {
|
||||
email_ids: data.emailIds,
|
||||
action: data.action,
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Labels
|
||||
// ============================================================================
|
||||
|
||||
export const getLabels = async (): Promise<StaffEmailLabel[]> => {
|
||||
const response = await apiClient.get(`${BASE_URL}/labels/`);
|
||||
return response.data.map(transformLabel);
|
||||
};
|
||||
|
||||
export const createLabel = async (name: string, color: string): Promise<StaffEmailLabel> => {
|
||||
const response = await apiClient.post(`${BASE_URL}/labels/`, { name, color });
|
||||
return transformLabel(response.data);
|
||||
};
|
||||
|
||||
export const updateLabel = async (id: number, data: { name?: string; color?: string }): Promise<StaffEmailLabel> => {
|
||||
const response = await apiClient.patch(`${BASE_URL}/labels/${id}/`, data);
|
||||
return transformLabel(response.data);
|
||||
};
|
||||
|
||||
export const deleteLabel = async (id: number): Promise<void> => {
|
||||
await apiClient.delete(`${BASE_URL}/labels/${id}/`);
|
||||
};
|
||||
|
||||
export const addLabelToEmail = async (emailId: number, labelId: number): Promise<void> => {
|
||||
await apiClient.post(`${BASE_URL}/messages/${emailId}/add_label/`, { label_id: labelId });
|
||||
};
|
||||
|
||||
export const removeLabelFromEmail = async (emailId: number, labelId: number): Promise<void> => {
|
||||
await apiClient.post(`${BASE_URL}/messages/${emailId}/remove_label/`, { label_id: labelId });
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Contacts
|
||||
// ============================================================================
|
||||
|
||||
export const searchContacts = async (query: string): Promise<EmailContactSuggestion[]> => {
|
||||
const response = await apiClient.get(`${BASE_URL}/contacts/`, {
|
||||
params: { search: query },
|
||||
});
|
||||
return response.data.map(transformContact);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Attachments
|
||||
// ============================================================================
|
||||
|
||||
export const uploadAttachment = async (file: File, emailId?: number): Promise<StaffEmailAttachment> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (emailId) {
|
||||
formData.append('email_id', String(emailId));
|
||||
}
|
||||
const response = await apiClient.post(`${BASE_URL}/attachments/`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return transformAttachment(response.data);
|
||||
};
|
||||
|
||||
export const deleteAttachment = async (id: number): Promise<void> => {
|
||||
await apiClient.delete(`${BASE_URL}/attachments/${id}/`);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Sync
|
||||
// ============================================================================
|
||||
|
||||
export const syncEmails = async (): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await apiClient.post(`${BASE_URL}/messages/sync/`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export interface FullSyncTask {
|
||||
email_address: string;
|
||||
task_id: string;
|
||||
}
|
||||
|
||||
export interface FullSyncResponse {
|
||||
status: string;
|
||||
tasks: FullSyncTask[];
|
||||
}
|
||||
|
||||
export const fullSyncEmails = async (): Promise<FullSyncResponse> => {
|
||||
const response = await apiClient.post(`${BASE_URL}/messages/full_sync/`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// User's Email Addresses
|
||||
// ============================================================================
|
||||
|
||||
export interface UserEmailAddress {
|
||||
id: number;
|
||||
email_address: string;
|
||||
display_name: string;
|
||||
color: string;
|
||||
is_default: boolean;
|
||||
last_check_at: string | null;
|
||||
emails_processed_count: number;
|
||||
}
|
||||
|
||||
export const getUserEmailAddresses = async (): Promise<UserEmailAddress[]> => {
|
||||
const response = await apiClient.get(`${BASE_URL}/messages/email_addresses/`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Transform Functions (snake_case -> camelCase)
|
||||
// ============================================================================
|
||||
|
||||
function transformFolder(data: any): StaffEmailFolder {
|
||||
return {
|
||||
id: data.id,
|
||||
owner: data.owner,
|
||||
name: data.name,
|
||||
folderType: data.folder_type,
|
||||
emailCount: data.email_count || 0,
|
||||
unreadCount: data.unread_count || 0,
|
||||
createdAt: data.created_at,
|
||||
updatedAt: data.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function transformEmailListItem(data: any): StaffEmailListItem {
|
||||
return {
|
||||
id: data.id,
|
||||
folder: data.folder,
|
||||
fromAddress: data.from_address,
|
||||
fromName: data.from_name || '',
|
||||
toAddresses: data.to_addresses || [],
|
||||
subject: data.subject || '(No Subject)',
|
||||
snippet: data.snippet || '',
|
||||
status: data.status,
|
||||
isRead: data.is_read,
|
||||
isStarred: data.is_starred,
|
||||
isImportant: data.is_important,
|
||||
hasAttachments: data.has_attachments || false,
|
||||
attachmentCount: data.attachment_count || 0,
|
||||
threadId: data.thread_id,
|
||||
emailDate: data.email_date,
|
||||
createdAt: data.created_at,
|
||||
labels: (data.labels || []).map(transformLabel),
|
||||
};
|
||||
}
|
||||
|
||||
function transformEmail(data: any): StaffEmail {
|
||||
return {
|
||||
...transformEmailListItem(data),
|
||||
owner: data.owner,
|
||||
emailAddress: data.email_address,
|
||||
messageId: data.message_id || '',
|
||||
inReplyTo: data.in_reply_to,
|
||||
references: data.references || '',
|
||||
ccAddresses: data.cc_addresses || [],
|
||||
bccAddresses: data.bcc_addresses || [],
|
||||
bodyText: data.body_text || '',
|
||||
bodyHtml: data.body_html || '',
|
||||
isAnswered: data.is_answered || false,
|
||||
isPermanentlyDeleted: data.is_permanently_deleted || false,
|
||||
deletedAt: data.deleted_at,
|
||||
attachments: (data.attachments || []).map(transformAttachment),
|
||||
updatedAt: data.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function transformLabel(data: any): StaffEmailLabel {
|
||||
return {
|
||||
id: data.id,
|
||||
owner: data.owner,
|
||||
name: data.name,
|
||||
color: data.color || '#3b82f6',
|
||||
createdAt: data.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function transformAttachment(data: any): StaffEmailAttachment {
|
||||
return {
|
||||
id: data.id,
|
||||
filename: data.filename,
|
||||
contentType: data.content_type,
|
||||
size: data.size,
|
||||
url: data.url || data.file_url || '',
|
||||
createdAt: data.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function transformContact(data: any): EmailContactSuggestion {
|
||||
return {
|
||||
id: data.id,
|
||||
owner: data.owner,
|
||||
email: data.email,
|
||||
name: data.name || '',
|
||||
useCount: data.use_count || 0,
|
||||
lastUsedAt: data.last_used_at,
|
||||
};
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* FloatingHelpButton Component
|
||||
*
|
||||
* A floating help button fixed in the top-right corner of the screen.
|
||||
* Automatically determines the help path based on the current route.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// Map route suffixes to their help page suffixes
|
||||
// These get prefixed appropriately based on context (tenant dashboard or public)
|
||||
const routeToHelpSuffix: Record<string, string> = {
|
||||
'/': 'dashboard',
|
||||
'/dashboard': 'dashboard',
|
||||
'/scheduler': 'scheduler',
|
||||
'/my-schedule': 'scheduler',
|
||||
'/tasks': 'tasks',
|
||||
'/customers': 'customers',
|
||||
'/services': 'services',
|
||||
'/resources': 'resources',
|
||||
'/locations': 'locations',
|
||||
'/staff': 'staff',
|
||||
'/time-blocks': 'time-blocks',
|
||||
'/my-availability': 'time-blocks',
|
||||
'/messages': 'messages',
|
||||
'/tickets': 'ticketing',
|
||||
'/payments': 'payments',
|
||||
'/contracts': 'contracts',
|
||||
'/contracts/templates': 'contracts',
|
||||
'/automations': 'automations',
|
||||
'/automations/marketplace': 'automations',
|
||||
'/automations/my-automations': 'automations',
|
||||
'/automations/create': 'automations/docs',
|
||||
'/site-editor': 'site-builder',
|
||||
'/gallery': 'site-builder',
|
||||
'/settings': 'settings/general',
|
||||
'/settings/general': 'settings/general',
|
||||
'/settings/resource-types': 'settings/resource-types',
|
||||
'/settings/booking': 'settings/booking',
|
||||
'/settings/appearance': 'settings/appearance',
|
||||
'/settings/branding': 'settings/appearance',
|
||||
'/settings/business-hours': 'settings/business-hours',
|
||||
'/settings/email': 'settings/email',
|
||||
'/settings/email-templates': 'settings/email-templates',
|
||||
'/settings/embed-widget': 'settings/embed-widget',
|
||||
'/settings/staff-roles': 'settings/staff-roles',
|
||||
'/settings/sms-calling': 'settings/communication',
|
||||
'/settings/domains': 'settings/domains',
|
||||
'/settings/api': 'settings/api',
|
||||
'/settings/auth': 'settings/auth',
|
||||
'/settings/billing': 'settings/billing',
|
||||
'/settings/quota': 'settings/quota',
|
||||
};
|
||||
|
||||
const FloatingHelpButton: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
// Check if we're on a tenant dashboard route
|
||||
const isOnDashboard = location.pathname.startsWith('/dashboard');
|
||||
|
||||
// Get the help path for the current route
|
||||
const getHelpPath = (): string => {
|
||||
// Determine the base help path based on context
|
||||
const helpBase = isOnDashboard ? '/dashboard/help' : '/help';
|
||||
|
||||
// Get the route to look up (strip /dashboard prefix if present)
|
||||
const lookupPath = isOnDashboard
|
||||
? location.pathname.replace(/^\/dashboard/, '') || '/'
|
||||
: location.pathname;
|
||||
|
||||
// Exact match first
|
||||
if (routeToHelpSuffix[lookupPath]) {
|
||||
return `${helpBase}/${routeToHelpSuffix[lookupPath]}`;
|
||||
}
|
||||
|
||||
// Try matching with a prefix (for dynamic routes like /customers/:id)
|
||||
const pathSegments = lookupPath.split('/').filter(Boolean);
|
||||
if (pathSegments.length > 0) {
|
||||
// Try progressively shorter paths
|
||||
for (let i = pathSegments.length; i > 0; i--) {
|
||||
const testPath = '/' + pathSegments.slice(0, i).join('/');
|
||||
if (routeToHelpSuffix[testPath]) {
|
||||
return `${helpBase}/${routeToHelpSuffix[testPath]}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to the main help page
|
||||
return helpBase;
|
||||
};
|
||||
|
||||
const helpPath = getHelpPath();
|
||||
|
||||
// Don't show on help pages themselves
|
||||
if (location.pathname.includes('/help')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={helpPath}
|
||||
className="fixed top-20 right-4 z-50 inline-flex items-center justify-center w-10 h-10 bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full shadow-lg border border-gray-200 dark:border-gray-700 transition-all duration-200 hover:scale-110"
|
||||
title={t('common.help', 'Help')}
|
||||
aria-label={t('common.help', 'Help')}
|
||||
>
|
||||
<HelpCircle size={20} />
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingHelpButton;
|
||||
@@ -1,31 +1,113 @@
|
||||
/**
|
||||
* HelpButton Component
|
||||
*
|
||||
* A contextual help button that appears at the top-right of pages
|
||||
* and links to the relevant help documentation.
|
||||
* A help button for the top bar that navigates to context-aware help pages.
|
||||
* Automatically determines the help path based on the current route.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface HelpButtonProps {
|
||||
helpPath: string;
|
||||
className?: string;
|
||||
}
|
||||
// Map route suffixes to their help page suffixes
|
||||
// These get prefixed appropriately based on context (tenant dashboard or public)
|
||||
const routeToHelpSuffix: Record<string, string> = {
|
||||
'/': 'dashboard',
|
||||
'/dashboard': 'dashboard',
|
||||
'/scheduler': 'scheduler',
|
||||
'/my-schedule': 'scheduler',
|
||||
'/tasks': 'tasks',
|
||||
'/customers': 'customers',
|
||||
'/services': 'services',
|
||||
'/resources': 'resources',
|
||||
'/locations': 'locations',
|
||||
'/staff': 'staff',
|
||||
'/time-blocks': 'time-blocks',
|
||||
'/my-availability': 'time-blocks',
|
||||
'/messages': 'messages',
|
||||
'/tickets': 'ticketing',
|
||||
'/payments': 'payments',
|
||||
'/contracts': 'contracts',
|
||||
'/contracts/templates': 'contracts',
|
||||
'/automations': 'automations',
|
||||
'/automations/marketplace': 'automations',
|
||||
'/automations/my-automations': 'automations',
|
||||
'/automations/create': 'automations/docs',
|
||||
'/site-editor': 'site-builder',
|
||||
'/gallery': 'site-builder',
|
||||
'/settings': 'settings/general',
|
||||
'/settings/general': 'settings/general',
|
||||
'/settings/resource-types': 'settings/resource-types',
|
||||
'/settings/booking': 'settings/booking',
|
||||
'/settings/appearance': 'settings/appearance',
|
||||
'/settings/branding': 'settings/appearance',
|
||||
'/settings/business-hours': 'settings/business-hours',
|
||||
'/settings/email': 'settings/email',
|
||||
'/settings/email-templates': 'settings/email-templates',
|
||||
'/settings/embed-widget': 'settings/embed-widget',
|
||||
'/settings/staff-roles': 'settings/staff-roles',
|
||||
'/settings/sms-calling': 'settings/communication',
|
||||
'/settings/domains': 'settings/domains',
|
||||
'/settings/api': 'settings/api',
|
||||
'/settings/auth': 'settings/auth',
|
||||
'/settings/billing': 'settings/billing',
|
||||
'/settings/quota': 'settings/quota',
|
||||
};
|
||||
|
||||
const HelpButton: React.FC<HelpButtonProps> = ({ helpPath, className = '' }) => {
|
||||
const HelpButton: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
// Check if we're on a tenant dashboard route
|
||||
const isOnDashboard = location.pathname.startsWith('/dashboard');
|
||||
|
||||
// Get the help path for the current route
|
||||
const getHelpPath = (): string => {
|
||||
// Determine the base help path based on context
|
||||
const helpBase = isOnDashboard ? '/dashboard/help' : '/help';
|
||||
|
||||
// Get the route to look up (strip /dashboard prefix if present)
|
||||
const lookupPath = isOnDashboard
|
||||
? location.pathname.replace(/^\/dashboard/, '') || '/'
|
||||
: location.pathname;
|
||||
|
||||
// Exact match first
|
||||
if (routeToHelpSuffix[lookupPath]) {
|
||||
return `${helpBase}/${routeToHelpSuffix[lookupPath]}`;
|
||||
}
|
||||
|
||||
// Try matching with a prefix (for dynamic routes like /customers/:id)
|
||||
const pathSegments = lookupPath.split('/').filter(Boolean);
|
||||
if (pathSegments.length > 0) {
|
||||
// Try progressively shorter paths
|
||||
for (let i = pathSegments.length; i > 0; i--) {
|
||||
const testPath = '/' + pathSegments.slice(0, i).join('/');
|
||||
if (routeToHelpSuffix[testPath]) {
|
||||
return `${helpBase}/${routeToHelpSuffix[testPath]}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to the main help page
|
||||
return helpBase;
|
||||
};
|
||||
|
||||
const helpPath = getHelpPath();
|
||||
|
||||
// Don't show on help pages themselves
|
||||
if (location.pathname.includes('/help')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={helpPath}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors ${className}`}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||
title={t('common.help', 'Help')}
|
||||
aria-label={t('common.help', 'Help')}
|
||||
>
|
||||
<HelpCircle size={18} />
|
||||
<span className="hidden sm:inline">{t('common.help', 'Help')}</span>
|
||||
<HelpCircle size={20} />
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -59,6 +59,7 @@ interface EmailAddressFormData {
|
||||
domain: string;
|
||||
color: string;
|
||||
password: string;
|
||||
routing_mode: 'PLATFORM' | 'STAFF';
|
||||
is_active: boolean;
|
||||
is_default: boolean;
|
||||
}
|
||||
@@ -92,6 +93,7 @@ const PlatformEmailAddressManager: React.FC = () => {
|
||||
domain: 'smoothschedule.com',
|
||||
color: '#3b82f6',
|
||||
password: '',
|
||||
routing_mode: 'PLATFORM',
|
||||
is_active: true,
|
||||
is_default: false,
|
||||
});
|
||||
@@ -120,6 +122,7 @@ const PlatformEmailAddressManager: React.FC = () => {
|
||||
domain: 'smoothschedule.com',
|
||||
color: '#3b82f6',
|
||||
password: '',
|
||||
routing_mode: 'PLATFORM',
|
||||
is_active: true,
|
||||
is_default: false,
|
||||
});
|
||||
@@ -137,6 +140,7 @@ const PlatformEmailAddressManager: React.FC = () => {
|
||||
domain: address.domain,
|
||||
color: address.color,
|
||||
password: '',
|
||||
routing_mode: address.routing_mode || 'PLATFORM',
|
||||
is_active: address.is_active,
|
||||
is_default: address.is_default,
|
||||
});
|
||||
@@ -188,6 +192,7 @@ const PlatformEmailAddressManager: React.FC = () => {
|
||||
sender_name: formData.sender_name,
|
||||
assigned_user_id: formData.assigned_user_id,
|
||||
color: formData.color,
|
||||
routing_mode: formData.routing_mode,
|
||||
is_active: formData.is_active,
|
||||
is_default: formData.is_default,
|
||||
};
|
||||
@@ -210,6 +215,7 @@ const PlatformEmailAddressManager: React.FC = () => {
|
||||
domain: formData.domain,
|
||||
color: formData.color,
|
||||
password: formData.password,
|
||||
routing_mode: formData.routing_mode,
|
||||
is_active: formData.is_active,
|
||||
is_default: formData.is_default,
|
||||
});
|
||||
@@ -607,6 +613,27 @@ const PlatformEmailAddressManager: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Routing Mode */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Routing Mode
|
||||
</label>
|
||||
<select
|
||||
value={formData.routing_mode}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
routing_mode: e.target.value as 'PLATFORM' | 'STAFF'
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="PLATFORM">Platform (Ticketing System)</option>
|
||||
<option value="STAFF">Staff (Personal Inbox)</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Platform: Emails become support tickets. Staff: Emails go to the assigned user's inbox.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Email Address (only show for new addresses) */}
|
||||
{!editingAddress && (
|
||||
<div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code, Mail, CreditCard } from 'lucide-react';
|
||||
import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code, Mail, CreditCard, Inbox } from 'lucide-react';
|
||||
import { User } from '../types';
|
||||
import SmoothScheduleLogo from './SmoothScheduleLogo';
|
||||
|
||||
@@ -16,7 +16,9 @@ const PlatformSidebar: React.FC<PlatformSidebarProps> = ({ user, isCollapsed, to
|
||||
const location = useLocation();
|
||||
|
||||
const getNavClass = (path: string) => {
|
||||
const isActive = location.pathname === path || (path !== '/' && location.pathname.startsWith(path));
|
||||
// Exact match or starts with path followed by /
|
||||
const isActive = location.pathname === path ||
|
||||
(path !== '/' && (location.pathname.startsWith(path + '/') || location.pathname === path));
|
||||
const baseClasses = `flex items-center gap-3 py-2 text-sm font-medium rounded-md transition-colors`;
|
||||
const collapsedClasses = isCollapsed ? 'px-3 justify-center' : 'px-3';
|
||||
const activeClasses = 'bg-gray-700 text-white';
|
||||
@@ -67,6 +69,10 @@ const PlatformSidebar: React.FC<PlatformSidebarProps> = ({ user, isCollapsed, to
|
||||
<Mail size={18} className="shrink-0" />
|
||||
{!isCollapsed && <span>Email Addresses</span>}
|
||||
</Link>
|
||||
<Link to="/platform/email" className={getNavClass('/platform/email')} title="My Inbox">
|
||||
<Inbox size={18} className="shrink-0" />
|
||||
{!isCollapsed && <span>My Inbox</span>}
|
||||
</Link>
|
||||
|
||||
{isSuperuser && (
|
||||
<>
|
||||
|
||||
@@ -291,6 +291,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
label={t('nav.automations', 'Automations')}
|
||||
isCollapsed={isCollapsed}
|
||||
locked={!canUse('automations')}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
</SidebarSection>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import UserProfileDropdown from './UserProfileDropdown';
|
||||
import LanguageSelector from './LanguageSelector';
|
||||
import NotificationDropdown from './NotificationDropdown';
|
||||
import SandboxToggle from './SandboxToggle';
|
||||
import HelpButton from './HelpButton';
|
||||
import { useSandbox } from '../contexts/SandboxContext';
|
||||
|
||||
interface TopBarProps {
|
||||
@@ -62,6 +63,8 @@ const TopBar: React.FC<TopBarProps> = ({ user, isDarkMode, toggleTheme, onMenuCl
|
||||
|
||||
<NotificationDropdown onTicketClick={onTicketClick} />
|
||||
|
||||
<HelpButton />
|
||||
|
||||
<UserProfileDropdown user={user} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import FloatingHelpButton from '../FloatingHelpButton';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, defaultValue?: string) => defaultValue || key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('FloatingHelpButton', () => {
|
||||
const renderWithRouter = (initialPath: string) => {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<FloatingHelpButton />
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('tenant dashboard routes (prefixed with /dashboard)', () => {
|
||||
it('renders help link on tenant dashboard', () => {
|
||||
renderWithRouter('/dashboard');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/dashboard for /dashboard', () => {
|
||||
renderWithRouter('/dashboard');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/dashboard');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/scheduler for /dashboard/scheduler', () => {
|
||||
renderWithRouter('/dashboard/scheduler');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/scheduler');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/services for /dashboard/services', () => {
|
||||
renderWithRouter('/dashboard/services');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/services');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/resources for /dashboard/resources', () => {
|
||||
renderWithRouter('/dashboard/resources');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/resources');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/settings/general for /dashboard/settings/general', () => {
|
||||
renderWithRouter('/dashboard/settings/general');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/settings/general');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/customers for /dashboard/customers/123', () => {
|
||||
renderWithRouter('/dashboard/customers/123');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/customers');
|
||||
});
|
||||
|
||||
it('returns null on /dashboard/help pages', () => {
|
||||
const { container } = renderWithRouter('/dashboard/help/dashboard');
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('links to /dashboard/help for unknown dashboard routes', () => {
|
||||
renderWithRouter('/dashboard/unknown-route');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/site-builder for /dashboard/site-editor', () => {
|
||||
renderWithRouter('/dashboard/site-editor');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/site-builder');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/site-builder for /dashboard/gallery', () => {
|
||||
renderWithRouter('/dashboard/gallery');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/site-builder');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/locations for /dashboard/locations', () => {
|
||||
renderWithRouter('/dashboard/locations');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/locations');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/settings/business-hours for /dashboard/settings/business-hours', () => {
|
||||
renderWithRouter('/dashboard/settings/business-hours');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/settings/business-hours');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/settings/email-templates for /dashboard/settings/email-templates', () => {
|
||||
renderWithRouter('/dashboard/settings/email-templates');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/settings/email-templates');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/settings/embed-widget for /dashboard/settings/embed-widget', () => {
|
||||
renderWithRouter('/dashboard/settings/embed-widget');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/settings/embed-widget');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/settings/staff-roles for /dashboard/settings/staff-roles', () => {
|
||||
renderWithRouter('/dashboard/settings/staff-roles');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/settings/staff-roles');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/settings/communication for /dashboard/settings/sms-calling', () => {
|
||||
renderWithRouter('/dashboard/settings/sms-calling');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/settings/communication');
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-dashboard routes (public/platform)', () => {
|
||||
it('links to /help/scheduler for /scheduler', () => {
|
||||
renderWithRouter('/scheduler');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/scheduler');
|
||||
});
|
||||
|
||||
it('links to /help/services for /services', () => {
|
||||
renderWithRouter('/services');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/services');
|
||||
});
|
||||
|
||||
it('links to /help/resources for /resources', () => {
|
||||
renderWithRouter('/resources');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/resources');
|
||||
});
|
||||
|
||||
it('links to /help/settings/general for /settings/general', () => {
|
||||
renderWithRouter('/settings/general');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/settings/general');
|
||||
});
|
||||
|
||||
it('links to /help/locations for /locations', () => {
|
||||
renderWithRouter('/locations');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/locations');
|
||||
});
|
||||
|
||||
it('links to /help/settings/business-hours for /settings/business-hours', () => {
|
||||
renderWithRouter('/settings/business-hours');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/settings/business-hours');
|
||||
});
|
||||
|
||||
it('links to /help/settings/email-templates for /settings/email-templates', () => {
|
||||
renderWithRouter('/settings/email-templates');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/settings/email-templates');
|
||||
});
|
||||
|
||||
it('links to /help/settings/embed-widget for /settings/embed-widget', () => {
|
||||
renderWithRouter('/settings/embed-widget');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/settings/embed-widget');
|
||||
});
|
||||
|
||||
it('links to /help/settings/staff-roles for /settings/staff-roles', () => {
|
||||
renderWithRouter('/settings/staff-roles');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/settings/staff-roles');
|
||||
});
|
||||
|
||||
it('links to /help/settings/communication for /settings/sms-calling', () => {
|
||||
renderWithRouter('/settings/sms-calling');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/settings/communication');
|
||||
});
|
||||
|
||||
it('returns null on /help pages', () => {
|
||||
const { container } = renderWithRouter('/help/dashboard');
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('links to /help for unknown routes', () => {
|
||||
renderWithRouter('/unknown-route');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help');
|
||||
});
|
||||
|
||||
it('handles dynamic routes by matching prefix', () => {
|
||||
renderWithRouter('/customers/123');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/customers');
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('has aria-label', () => {
|
||||
renderWithRouter('/dashboard');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('aria-label', 'Help');
|
||||
});
|
||||
|
||||
it('has title attribute', () => {
|
||||
renderWithRouter('/dashboard');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('title', 'Help');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import HelpButton from '../HelpButton';
|
||||
|
||||
// Mock react-i18next
|
||||
@@ -11,47 +11,207 @@ vi.mock('react-i18next', () => ({
|
||||
}));
|
||||
|
||||
describe('HelpButton', () => {
|
||||
const renderHelpButton = (props: { helpPath: string; className?: string }) => {
|
||||
const renderWithRouter = (initialPath: string) => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<HelpButton {...props} />
|
||||
</BrowserRouter>
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<HelpButton />
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
it('renders help link', () => {
|
||||
renderHelpButton({ helpPath: '/help/dashboard' });
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toBeInTheDocument();
|
||||
describe('tenant dashboard routes (prefixed with /dashboard)', () => {
|
||||
it('renders help link on tenant dashboard', () => {
|
||||
renderWithRouter('/dashboard');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/dashboard for /dashboard', () => {
|
||||
renderWithRouter('/dashboard');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/dashboard');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/scheduler for /dashboard/scheduler', () => {
|
||||
renderWithRouter('/dashboard/scheduler');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/scheduler');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/services for /dashboard/services', () => {
|
||||
renderWithRouter('/dashboard/services');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/services');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/resources for /dashboard/resources', () => {
|
||||
renderWithRouter('/dashboard/resources');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/resources');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/settings/general for /dashboard/settings/general', () => {
|
||||
renderWithRouter('/dashboard/settings/general');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/settings/general');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/customers for /dashboard/customers/123', () => {
|
||||
renderWithRouter('/dashboard/customers/123');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/customers');
|
||||
});
|
||||
|
||||
it('returns null on /dashboard/help pages', () => {
|
||||
const { container } = renderWithRouter('/dashboard/help/dashboard');
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('links to /dashboard/help for unknown dashboard routes', () => {
|
||||
renderWithRouter('/dashboard/unknown-route');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/site-builder for /dashboard/site-editor', () => {
|
||||
renderWithRouter('/dashboard/site-editor');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/site-builder');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/site-builder for /dashboard/gallery', () => {
|
||||
renderWithRouter('/dashboard/gallery');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/site-builder');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/locations for /dashboard/locations', () => {
|
||||
renderWithRouter('/dashboard/locations');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/locations');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/settings/business-hours for /dashboard/settings/business-hours', () => {
|
||||
renderWithRouter('/dashboard/settings/business-hours');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/settings/business-hours');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/settings/email-templates for /dashboard/settings/email-templates', () => {
|
||||
renderWithRouter('/dashboard/settings/email-templates');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/settings/email-templates');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/settings/embed-widget for /dashboard/settings/embed-widget', () => {
|
||||
renderWithRouter('/dashboard/settings/embed-widget');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/settings/embed-widget');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/settings/staff-roles for /dashboard/settings/staff-roles', () => {
|
||||
renderWithRouter('/dashboard/settings/staff-roles');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/settings/staff-roles');
|
||||
});
|
||||
|
||||
it('links to /dashboard/help/settings/communication for /dashboard/settings/sms-calling', () => {
|
||||
renderWithRouter('/dashboard/settings/sms-calling');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/help/settings/communication');
|
||||
});
|
||||
});
|
||||
|
||||
it('has correct href', () => {
|
||||
renderHelpButton({ helpPath: '/help/dashboard' });
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/dashboard');
|
||||
describe('non-dashboard routes (public/platform)', () => {
|
||||
it('links to /help/scheduler for /scheduler', () => {
|
||||
renderWithRouter('/scheduler');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/scheduler');
|
||||
});
|
||||
|
||||
it('links to /help/services for /services', () => {
|
||||
renderWithRouter('/services');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/services');
|
||||
});
|
||||
|
||||
it('links to /help/resources for /resources', () => {
|
||||
renderWithRouter('/resources');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/resources');
|
||||
});
|
||||
|
||||
it('links to /help/settings/general for /settings/general', () => {
|
||||
renderWithRouter('/settings/general');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/settings/general');
|
||||
});
|
||||
|
||||
it('links to /help/locations for /locations', () => {
|
||||
renderWithRouter('/locations');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/locations');
|
||||
});
|
||||
|
||||
it('links to /help/settings/business-hours for /settings/business-hours', () => {
|
||||
renderWithRouter('/settings/business-hours');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/settings/business-hours');
|
||||
});
|
||||
|
||||
it('links to /help/settings/email-templates for /settings/email-templates', () => {
|
||||
renderWithRouter('/settings/email-templates');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/settings/email-templates');
|
||||
});
|
||||
|
||||
it('links to /help/settings/embed-widget for /settings/embed-widget', () => {
|
||||
renderWithRouter('/settings/embed-widget');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/settings/embed-widget');
|
||||
});
|
||||
|
||||
it('links to /help/settings/staff-roles for /settings/staff-roles', () => {
|
||||
renderWithRouter('/settings/staff-roles');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/settings/staff-roles');
|
||||
});
|
||||
|
||||
it('links to /help/settings/communication for /settings/sms-calling', () => {
|
||||
renderWithRouter('/settings/sms-calling');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/settings/communication');
|
||||
});
|
||||
|
||||
it('returns null on /help pages', () => {
|
||||
const { container } = renderWithRouter('/help/dashboard');
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('links to /help for unknown routes', () => {
|
||||
renderWithRouter('/unknown-route');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help');
|
||||
});
|
||||
|
||||
it('handles dynamic routes by matching prefix', () => {
|
||||
renderWithRouter('/customers/123');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/customers');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders help text', () => {
|
||||
renderHelpButton({ helpPath: '/help/test' });
|
||||
expect(screen.getByText('Help')).toBeInTheDocument();
|
||||
});
|
||||
describe('accessibility', () => {
|
||||
it('has aria-label', () => {
|
||||
renderWithRouter('/dashboard');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('aria-label', 'Help');
|
||||
});
|
||||
|
||||
it('has title attribute', () => {
|
||||
renderHelpButton({ helpPath: '/help/test' });
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('title', 'Help');
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
renderHelpButton({ helpPath: '/help/test', className: 'custom-class' });
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('has default styles', () => {
|
||||
renderHelpButton({ helpPath: '/help/test' });
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveClass('inline-flex');
|
||||
expect(link).toHaveClass('items-center');
|
||||
it('has title attribute', () => {
|
||||
renderWithRouter('/dashboard');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('title', 'Help');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
420
frontend/src/components/email/EmailComposer.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* Email Composer Component
|
||||
*
|
||||
* Compose, reply, and forward emails with rich text editing.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
X,
|
||||
Send,
|
||||
Paperclip,
|
||||
Trash2,
|
||||
Minimize2,
|
||||
Maximize2,
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
List,
|
||||
ListOrdered,
|
||||
Link,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { StaffEmail, StaffEmailCreateDraft } from '../../types';
|
||||
import {
|
||||
useCreateDraft,
|
||||
useUpdateDraft,
|
||||
useSendEmail,
|
||||
useUploadAttachment,
|
||||
useContactSearch,
|
||||
useUserEmailAddresses,
|
||||
} from '../../hooks/useStaffEmail';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface EmailComposerProps {
|
||||
replyTo?: StaffEmail | null;
|
||||
forwardFrom?: StaffEmail | null;
|
||||
onClose: () => void;
|
||||
onSent: () => void;
|
||||
}
|
||||
|
||||
const EmailComposer: React.FC<EmailComposerProps> = ({
|
||||
replyTo,
|
||||
forwardFrom,
|
||||
onClose,
|
||||
onSent,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Get available email addresses for sending (only those assigned to current user)
|
||||
const { data: userEmailAddresses = [] } = useUserEmailAddresses();
|
||||
|
||||
// Form state
|
||||
const [fromAddressId, setFromAddressId] = useState<number | null>(null);
|
||||
const [to, setTo] = useState('');
|
||||
const [cc, setCc] = useState('');
|
||||
const [bcc, setBcc] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [showCc, setShowCc] = useState(false);
|
||||
const [showBcc, setShowBcc] = useState(false);
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const [draftId, setDraftId] = useState<number | null>(null);
|
||||
|
||||
// Contact search
|
||||
const [toQuery, setToQuery] = useState('');
|
||||
const { data: contactSuggestions = [] } = useContactSearch(toQuery);
|
||||
|
||||
// Mutations
|
||||
const createDraft = useCreateDraft();
|
||||
const updateDraft = useUpdateDraft();
|
||||
const sendEmail = useSendEmail();
|
||||
const uploadAttachment = useUploadAttachment();
|
||||
|
||||
// Initialize form for reply/forward
|
||||
useEffect(() => {
|
||||
if (replyTo) {
|
||||
// Reply mode
|
||||
setTo(replyTo.fromAddress);
|
||||
setSubject(replyTo.subject.startsWith('Re:') ? replyTo.subject : `Re: ${replyTo.subject}`);
|
||||
setBody(`\n\n---\nOn ${new Date(replyTo.emailDate).toLocaleString()}, ${replyTo.fromName || replyTo.fromAddress} wrote:\n\n${replyTo.bodyText}`);
|
||||
} else if (forwardFrom) {
|
||||
// Forward mode
|
||||
setSubject(forwardFrom.subject.startsWith('Fwd:') ? forwardFrom.subject : `Fwd: ${forwardFrom.subject}`);
|
||||
setBody(`\n\n---\nForwarded message:\nFrom: ${forwardFrom.fromName || forwardFrom.fromAddress} <${forwardFrom.fromAddress}>\nDate: ${new Date(forwardFrom.emailDate).toLocaleString()}\nSubject: ${forwardFrom.subject}\nTo: ${forwardFrom.toAddresses.join(', ')}\n\n${forwardFrom.bodyText}`);
|
||||
}
|
||||
}, [replyTo, forwardFrom]);
|
||||
|
||||
// Set default from address
|
||||
useEffect(() => {
|
||||
if (!fromAddressId && userEmailAddresses.length > 0) {
|
||||
setFromAddressId(userEmailAddresses[0].id);
|
||||
}
|
||||
}, [userEmailAddresses, fromAddressId]);
|
||||
|
||||
const parseAddresses = (input: string): string[] => {
|
||||
return input
|
||||
.split(/[,;]/)
|
||||
.map((addr) => addr.trim())
|
||||
.filter((addr) => addr.length > 0);
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!fromAddressId) {
|
||||
toast.error('Please select a From address');
|
||||
return;
|
||||
}
|
||||
|
||||
const toAddresses = parseAddresses(to);
|
||||
if (toAddresses.length === 0) {
|
||||
toast.error('Please enter at least one recipient');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create or update draft first
|
||||
let emailId = draftId;
|
||||
|
||||
const draftData: StaffEmailCreateDraft = {
|
||||
emailAddressId: fromAddressId,
|
||||
toAddresses,
|
||||
ccAddresses: parseAddresses(cc),
|
||||
bccAddresses: parseAddresses(bcc),
|
||||
subject: subject || '(No Subject)',
|
||||
bodyText: body,
|
||||
bodyHtml: `<div style="white-space: pre-wrap;">${body.replace(/</g, '<').replace(/>/g, '>')}</div>`,
|
||||
inReplyTo: replyTo?.id,
|
||||
threadId: replyTo?.threadId || undefined,
|
||||
};
|
||||
|
||||
if (emailId) {
|
||||
await updateDraft.mutateAsync({ id: emailId, data: draftData });
|
||||
} else {
|
||||
const draft = await createDraft.mutateAsync(draftData);
|
||||
emailId = draft.id;
|
||||
setDraftId(emailId);
|
||||
}
|
||||
|
||||
// Send the email
|
||||
await sendEmail.mutateAsync(emailId);
|
||||
toast.success('Email sent');
|
||||
onSent();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.error || 'Failed to send email');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
if (!fromAddressId) {
|
||||
toast.error('Please select a From address');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const draftData: StaffEmailCreateDraft = {
|
||||
emailAddressId: fromAddressId,
|
||||
toAddresses: parseAddresses(to),
|
||||
ccAddresses: parseAddresses(cc),
|
||||
bccAddresses: parseAddresses(bcc),
|
||||
subject: subject || '(No Subject)',
|
||||
bodyText: body,
|
||||
bodyHtml: `<div style="white-space: pre-wrap;">${body.replace(/</g, '<').replace(/>/g, '>')}</div>`,
|
||||
inReplyTo: replyTo?.id,
|
||||
threadId: replyTo?.threadId || undefined,
|
||||
};
|
||||
|
||||
if (draftId) {
|
||||
await updateDraft.mutateAsync({ id: draftId, data: draftData });
|
||||
} else {
|
||||
const draft = await createDraft.mutateAsync(draftData);
|
||||
setDraftId(draft.id);
|
||||
}
|
||||
toast.success('Draft saved');
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.error || 'Failed to save draft');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
// TODO: Implement attachment upload when draft is created
|
||||
toast.error('Attachments not yet implemented');
|
||||
};
|
||||
|
||||
const isSending = createDraft.isPending || updateDraft.isPending || sendEmail.isPending;
|
||||
|
||||
if (isMinimized) {
|
||||
return (
|
||||
<div className="fixed bottom-0 right-4 w-80 bg-white dark:bg-gray-800 shadow-lg rounded-t-lg border border-gray-200 dark:border-gray-700 z-50">
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-2 bg-gray-100 dark:bg-gray-700 rounded-t-lg cursor-pointer"
|
||||
onClick={() => setIsMinimized(false)}
|
||||
>
|
||||
<span className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{subject || 'New Message'}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsMinimized(false);
|
||||
}}
|
||||
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-white dark:bg-gray-800">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{replyTo ? 'Reply' : forwardFrom ? 'Forward' : 'New Message'}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setIsMinimized(true)}
|
||||
className="p-1.5 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
<Minimize2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* From */}
|
||||
<div className="flex items-center px-4 py-2 border-b border-gray-100 dark:border-gray-700">
|
||||
<label className="w-16 text-sm text-gray-500 dark:text-gray-400">From:</label>
|
||||
<select
|
||||
value={fromAddressId || ''}
|
||||
onChange={(e) => setFromAddressId(Number(e.target.value))}
|
||||
className="flex-1 bg-transparent border-0 text-sm text-gray-900 dark:text-white focus:ring-0 p-0"
|
||||
>
|
||||
<option value="">Select email address...</option>
|
||||
{userEmailAddresses.map((addr) => (
|
||||
<option key={addr.id} value={addr.id}>
|
||||
{addr.display_name} <{addr.email_address}>
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* To */}
|
||||
<div className="flex items-center px-4 py-2 border-b border-gray-100 dark:border-gray-700">
|
||||
<label className="w-16 text-sm text-gray-500 dark:text-gray-400">To:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
placeholder="recipient@example.com"
|
||||
className="flex-1 bg-transparent border-0 text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:ring-0 p-0"
|
||||
/>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{!showCc && (
|
||||
<button
|
||||
onClick={() => setShowCc(true)}
|
||||
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
Cc
|
||||
</button>
|
||||
)}
|
||||
{!showBcc && (
|
||||
<button
|
||||
onClick={() => setShowBcc(true)}
|
||||
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
Bcc
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cc */}
|
||||
{showCc && (
|
||||
<div className="flex items-center px-4 py-2 border-b border-gray-100 dark:border-gray-700">
|
||||
<label className="w-16 text-sm text-gray-500 dark:text-gray-400">Cc:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cc}
|
||||
onChange={(e) => setCc(e.target.value)}
|
||||
placeholder="cc@example.com"
|
||||
className="flex-1 bg-transparent border-0 text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:ring-0 p-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bcc */}
|
||||
{showBcc && (
|
||||
<div className="flex items-center px-4 py-2 border-b border-gray-100 dark:border-gray-700">
|
||||
<label className="w-16 text-sm text-gray-500 dark:text-gray-400">Bcc:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bcc}
|
||||
onChange={(e) => setBcc(e.target.value)}
|
||||
placeholder="bcc@example.com"
|
||||
className="flex-1 bg-transparent border-0 text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:ring-0 p-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subject */}
|
||||
<div className="flex items-center px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<label className="w-16 text-sm text-gray-500 dark:text-gray-400">Subject:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder="Email subject"
|
||||
className="flex-1 bg-transparent border-0 text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:ring-0 p-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="Write your message..."
|
||||
className="w-full h-full min-h-[200px] bg-transparent border-0 text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:ring-0 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer toolbar */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={isSending}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSending ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Send size={16} />
|
||||
)}
|
||||
{t('staffEmail.send', 'Send')}
|
||||
</button>
|
||||
|
||||
{/* Formatting buttons - placeholder for future rich text */}
|
||||
<div className="flex items-center gap-1 ml-2 border-l border-gray-300 dark:border-gray-600 pl-2">
|
||||
<button
|
||||
className="p-1.5 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
|
||||
title="Bold"
|
||||
>
|
||||
<Bold size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
|
||||
title="Italic"
|
||||
>
|
||||
<Italic size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
|
||||
title="Underline"
|
||||
>
|
||||
<Underline size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
<label className="p-1.5 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700 rounded cursor-pointer ml-2">
|
||||
<Paperclip size={16} />
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSaveDraft}
|
||||
disabled={createDraft.isPending || updateDraft.isPending}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
Save draft
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
|
||||
title="Discard"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailComposer;
|
||||
389
frontend/src/components/email/EmailViewer.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* Email Viewer Component
|
||||
*
|
||||
* Displays a full email with headers, body, and action buttons.
|
||||
* HTML email content is rendered in a sandboxed iframe for security.
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Reply,
|
||||
ReplyAll,
|
||||
Forward,
|
||||
Archive,
|
||||
Trash2,
|
||||
Star,
|
||||
Download,
|
||||
Paperclip,
|
||||
Loader2,
|
||||
FileText,
|
||||
Code,
|
||||
Mail,
|
||||
MailOpen,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { StaffEmail } from '../../types';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface EmailViewerProps {
|
||||
email: StaffEmail;
|
||||
isLoading?: boolean;
|
||||
onReply: () => void;
|
||||
onReplyAll: () => void;
|
||||
onForward: () => void;
|
||||
onArchive: () => void;
|
||||
onTrash: () => void;
|
||||
onStar: () => void;
|
||||
onMarkRead?: () => void;
|
||||
onMarkUnread?: () => void;
|
||||
onRestore?: () => void;
|
||||
isInTrash?: boolean;
|
||||
}
|
||||
|
||||
const EmailViewer: React.FC<EmailViewerProps> = ({
|
||||
email,
|
||||
isLoading,
|
||||
onReply,
|
||||
onReplyAll,
|
||||
onForward,
|
||||
onArchive,
|
||||
onTrash,
|
||||
onStar,
|
||||
onMarkRead,
|
||||
onMarkUnread,
|
||||
onRestore,
|
||||
isInTrash,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [viewMode, setViewMode] = useState<'html' | 'text'>('html');
|
||||
const [iframeHeight, setIframeHeight] = useState(300);
|
||||
|
||||
// Update iframe content when email changes
|
||||
useEffect(() => {
|
||||
if (iframeRef.current && email.bodyHtml && viewMode === 'html') {
|
||||
const iframe = iframeRef.current;
|
||||
const doc = iframe.contentDocument || iframe.contentWindow?.document;
|
||||
if (doc) {
|
||||
// Create a safe HTML document with styles
|
||||
const htmlContent = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #374151;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
img { max-width: 100%; height: auto; }
|
||||
a { color: #2563eb; }
|
||||
pre, code {
|
||||
background: #f3f4f6;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
blockquote {
|
||||
border-left: 3px solid #d1d5db;
|
||||
margin: 8px 0;
|
||||
padding-left: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
table { border-collapse: collapse; max-width: 100%; }
|
||||
td, th { padding: 4px 8px; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #1f2937; color: #e5e7eb; }
|
||||
a { color: #60a5fa; }
|
||||
pre, code { background: #374151; }
|
||||
blockquote { border-left-color: #4b5563; color: #9ca3af; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>${email.bodyHtml}</body>
|
||||
</html>
|
||||
`;
|
||||
doc.open();
|
||||
doc.write(htmlContent);
|
||||
doc.close();
|
||||
|
||||
// Adjust iframe height to content
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (doc.body) {
|
||||
setIframeHeight(Math.max(300, doc.body.scrollHeight + 32));
|
||||
}
|
||||
});
|
||||
|
||||
if (doc.body) {
|
||||
resizeObserver.observe(doc.body);
|
||||
// Initial height
|
||||
setTimeout(() => {
|
||||
if (doc.body) {
|
||||
setIframeHeight(Math.max(300, doc.body.scrollHeight + 32));
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}
|
||||
}
|
||||
}, [email.bodyHtml, email.id, viewMode]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 size={32} className="animate-spin text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const formatEmailAddresses = (addresses: string[]): string => {
|
||||
return addresses.join(', ');
|
||||
};
|
||||
|
||||
const hasHtml = !!email.bodyHtml;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onReply}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title={t('staffEmail.reply', 'Reply')}
|
||||
>
|
||||
<Reply size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onReplyAll}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title={t('staffEmail.replyAll', 'Reply All')}
|
||||
>
|
||||
<ReplyAll size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onForward}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title={t('staffEmail.forward', 'Forward')}
|
||||
>
|
||||
<Forward size={18} />
|
||||
</button>
|
||||
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-2" />
|
||||
<button
|
||||
onClick={onArchive}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title={t('staffEmail.archive', 'Archive')}
|
||||
>
|
||||
<Archive size={18} />
|
||||
</button>
|
||||
{isInTrash ? (
|
||||
<button
|
||||
onClick={onRestore}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title={t('staffEmail.restore', 'Restore')}
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onTrash}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title={t('staffEmail.trash', 'Delete')}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
)}
|
||||
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-2" />
|
||||
{email.isRead ? (
|
||||
<button
|
||||
onClick={onMarkUnread}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title={t('staffEmail.markUnread', 'Mark as unread')}
|
||||
>
|
||||
<Mail size={18} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onMarkRead}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title={t('staffEmail.markRead', 'Mark as read')}
|
||||
>
|
||||
<MailOpen size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View mode toggle */}
|
||||
{hasHtml && (
|
||||
<div className="flex items-center border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setViewMode('html')}
|
||||
className={`p-1.5 ${
|
||||
viewMode === 'html'
|
||||
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title="HTML view"
|
||||
>
|
||||
<Code size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('text')}
|
||||
className={`p-1.5 ${
|
||||
viewMode === 'text'
|
||||
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title="Plain text view"
|
||||
>
|
||||
<FileText size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={onStar}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<Star
|
||||
size={18}
|
||||
className={email.isStarred ? 'fill-yellow-400 text-yellow-400' : ''}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{email.subject || '(No Subject)'}
|
||||
</h2>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Avatar */}
|
||||
<div className="w-10 h-10 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center text-brand-700 dark:text-brand-400 font-semibold flex-shrink-0">
|
||||
{(email.fromName || email.fromAddress).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{email.fromName || email.fromAddress}
|
||||
</span>
|
||||
{email.fromName && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 ml-2">
|
||||
<{email.fromAddress}>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{format(new Date(email.emailDate), 'MMM d, yyyy h:mm a')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="text-gray-500">To: </span>
|
||||
{formatEmailAddresses(email.toAddresses)}
|
||||
</div>
|
||||
|
||||
{email.ccAddresses && email.ccAddresses.length > 0 && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="text-gray-500">Cc: </span>
|
||||
{formatEmailAddresses(email.ccAddresses)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
{email.labels && email.labels.length > 0 && (
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
{email.labels.map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="text-xs px-2 py-1 rounded text-white"
|
||||
style={{ backgroundColor: label.color }}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
{email.attachments && email.attachments.length > 0 && (
|
||||
<div className="px-6 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||
<div className="flex items-center gap-2 mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<Paperclip size={14} />
|
||||
<span>{email.attachments.length} attachment{email.attachments.length > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{email.attachments.map((attachment) => (
|
||||
<a
|
||||
key={attachment.id}
|
||||
href={attachment.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<Download size={14} className="text-gray-500" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate max-w-[200px]">
|
||||
{attachment.filename}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatFileSize(attachment.size)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email body */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{hasHtml && viewMode === 'html' ? (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title="Email content"
|
||||
sandbox="allow-same-origin"
|
||||
className="w-full border-0"
|
||||
style={{ height: iframeHeight }}
|
||||
/>
|
||||
) : (
|
||||
<div className="px-6 py-4 whitespace-pre-wrap text-gray-700 dark:text-gray-300 font-mono text-sm">
|
||||
{email.bodyText || '(No content)'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick reply bar */}
|
||||
<div className="px-6 py-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||
<button
|
||||
onClick={onReply}
|
||||
className="w-full text-left px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-500 dark:text-gray-400 hover:border-brand-500 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
|
||||
>
|
||||
{t('staffEmail.clickToReply', 'Click here to reply...')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailViewer;
|
||||
8
frontend/src/components/email/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Email Components
|
||||
*
|
||||
* Components for the staff email client.
|
||||
*/
|
||||
|
||||
export { default as EmailViewer } from './EmailViewer';
|
||||
export { default as EmailComposer } from './EmailComposer';
|
||||
@@ -1,197 +1,161 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Mail, Calendar, Bell, ArrowRight, Zap, CheckCircle2, Code, LayoutGrid } from 'lucide-react';
|
||||
import { Mail, Calendar, Bell, ArrowRight, Zap, CheckCircle2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CodeBlock from './CodeBlock';
|
||||
import WorkflowVisual from './WorkflowVisual';
|
||||
|
||||
const AutomationShowcase: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [viewMode, setViewMode] = useState<'marketplace' | 'code'>('marketplace');
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
const examples = [
|
||||
{
|
||||
id: 'winback',
|
||||
icon: Mail,
|
||||
title: t('marketing.plugins.examples.winback.title'),
|
||||
description: t('marketing.plugins.examples.winback.description'),
|
||||
stats: [t('marketing.plugins.examples.winback.stats.retention'), t('marketing.plugins.examples.winback.stats.revenue')],
|
||||
marketplaceImage: 'bg-gradient-to-br from-pink-500 to-rose-500',
|
||||
code: t('marketing.plugins.examples.winback.code'),
|
||||
},
|
||||
{
|
||||
id: 'noshow',
|
||||
icon: Bell,
|
||||
title: t('marketing.plugins.examples.noshow.title'),
|
||||
description: t('marketing.plugins.examples.noshow.description'),
|
||||
stats: [t('marketing.plugins.examples.noshow.stats.reduction'), t('marketing.plugins.examples.noshow.stats.utilization')],
|
||||
marketplaceImage: 'bg-gradient-to-br from-blue-500 to-cyan-500',
|
||||
code: t('marketing.plugins.examples.noshow.code'),
|
||||
},
|
||||
{
|
||||
id: 'report',
|
||||
icon: Calendar,
|
||||
title: t('marketing.plugins.examples.report.title'),
|
||||
description: t('marketing.plugins.examples.report.description'),
|
||||
stats: [t('marketing.plugins.examples.report.stats.timeSaved'), t('marketing.plugins.examples.report.stats.visibility')],
|
||||
marketplaceImage: 'bg-gradient-to-br from-purple-500 to-indigo-500',
|
||||
code: t('marketing.plugins.examples.report.code'),
|
||||
},
|
||||
];
|
||||
const examples = [
|
||||
{
|
||||
id: 'winback',
|
||||
icon: Mail,
|
||||
title: t('marketing.plugins.examples.winback.title'),
|
||||
description: t('marketing.plugins.examples.winback.description'),
|
||||
stats: [
|
||||
t('marketing.plugins.examples.winback.stats.retention'),
|
||||
t('marketing.plugins.examples.winback.stats.revenue'),
|
||||
],
|
||||
variant: 'winback' as const,
|
||||
},
|
||||
{
|
||||
id: 'noshow',
|
||||
icon: Bell,
|
||||
title: t('marketing.plugins.examples.noshow.title'),
|
||||
description: t('marketing.plugins.examples.noshow.description'),
|
||||
stats: [
|
||||
t('marketing.plugins.examples.noshow.stats.reduction'),
|
||||
t('marketing.plugins.examples.noshow.stats.utilization'),
|
||||
],
|
||||
variant: 'noshow' as const,
|
||||
},
|
||||
{
|
||||
id: 'report',
|
||||
icon: Calendar,
|
||||
title: t('marketing.plugins.examples.report.title'),
|
||||
description: t('marketing.plugins.examples.report.description'),
|
||||
stats: [
|
||||
t('marketing.plugins.examples.report.stats.timeSaved'),
|
||||
t('marketing.plugins.examples.report.stats.visibility'),
|
||||
],
|
||||
variant: 'report' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const CurrentIcon = examples[activeTab].icon;
|
||||
|
||||
return (
|
||||
<section className="py-24 bg-gray-50 dark:bg-gray-900 overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid lg:grid-cols-2 gap-16 items-center">
|
||||
|
||||
{/* Left Column: Content */}
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 text-sm font-medium mb-6">
|
||||
<Zap className="w-4 h-4" />
|
||||
<span>{t('marketing.plugins.badge')}</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-4xl font-bold text-gray-900 dark:text-white mb-6">
|
||||
{t('marketing.plugins.headline')}
|
||||
</h2>
|
||||
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400 mb-10">
|
||||
{t('marketing.plugins.subheadline')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{examples.map((example, index) => (
|
||||
<button
|
||||
key={example.id}
|
||||
onClick={() => setActiveTab(index)}
|
||||
className={`w-full text-left p-4 rounded-xl transition-all duration-200 border ${activeTab === index
|
||||
? 'bg-white dark:bg-gray-800 border-brand-500 shadow-lg scale-[1.02]'
|
||||
: 'bg-transparent border-transparent hover:bg-white/50 dark:hover:bg-gray-800/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-2 rounded-lg ${activeTab === index
|
||||
? 'bg-brand-100 text-brand-600 dark:bg-brand-900/50 dark:text-brand-400'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'
|
||||
}`}>
|
||||
<example.icon className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`font-semibold mb-1 ${activeTab === index ? 'text-gray-900 dark:text-white' : 'text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{example.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500">
|
||||
{example.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Visuals */}
|
||||
<div className="relative">
|
||||
{/* Background Decor */}
|
||||
<div className="absolute -inset-4 bg-gradient-to-r from-brand-500/20 to-purple-500/20 rounded-3xl blur-2xl opacity-50" />
|
||||
|
||||
{/* View Toggle */}
|
||||
<div className="absolute -top-12 right-0 flex bg-gray-100 dark:bg-gray-800 p-1 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setViewMode('marketplace')}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-all ${viewMode === 'marketplace'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
{t('marketing.plugins.viewToggle.marketplace')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('code')}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-all ${viewMode === 'code'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Code className="w-4 h-4" />
|
||||
{t('marketing.plugins.viewToggle.developer')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`${activeTab}-${viewMode}`}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="relative mt-8" // Added margin top for toggle
|
||||
>
|
||||
{/* Stats Cards */}
|
||||
<div className="flex gap-4 mb-6">
|
||||
{examples[activeTab].stats.map((stat, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">{stat}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{viewMode === 'marketplace' ? (
|
||||
// Marketplace Card View
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-xl overflow-hidden">
|
||||
<div className={`h-32 ${examples[activeTab].marketplaceImage} flex items-center justify-center`}>
|
||||
<CurrentIcon className="w-16 h-16 text-white opacity-90" />
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white">{examples[activeTab].title}</h3>
|
||||
<div className="text-sm text-gray-500">{t('marketing.plugins.marketplaceCard.author')}</div>
|
||||
</div>
|
||||
<button className="px-4 py-2 bg-brand-600 text-white rounded-lg font-medium text-sm hover:bg-brand-700 transition-colors">
|
||||
{t('marketing.plugins.marketplaceCard.installButton')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
{examples[activeTab].description}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<div className="flex -space-x-2">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="w-6 h-6 rounded-full bg-gray-300 border-2 border-white dark:border-gray-800" />
|
||||
))}
|
||||
</div>
|
||||
<span>{t('marketing.plugins.marketplaceCard.usedBy')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Code View
|
||||
<CodeBlock
|
||||
code={examples[activeTab].code}
|
||||
filename={`${examples[activeTab].id}_automation.py`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-6 text-right">
|
||||
<a href="/features" className="inline-flex items-center gap-2 text-brand-600 dark:text-brand-400 font-medium hover:underline">
|
||||
{t('marketing.plugins.cta')} <ArrowRight className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
return (
|
||||
<section className="py-24 bg-gray-50 dark:bg-gray-900 overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid lg:grid-cols-2 gap-16 items-center">
|
||||
{/* Left Column: Content */}
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 text-sm font-medium mb-6">
|
||||
<Zap className="w-4 h-4" />
|
||||
<span>{t('marketing.plugins.badge')}</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
<h2 className="text-4xl font-bold text-gray-900 dark:text-white mb-6">
|
||||
{t('marketing.plugins.headline')}
|
||||
</h2>
|
||||
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400 mb-10">
|
||||
{t('marketing.plugins.subheadline')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{examples.map((example, index) => (
|
||||
<button
|
||||
key={example.id}
|
||||
onClick={() => setActiveTab(index)}
|
||||
className={`w-full text-left p-4 rounded-xl transition-all duration-200 border ${
|
||||
activeTab === index
|
||||
? 'bg-white dark:bg-gray-800 border-brand-500 shadow-lg scale-[1.02]'
|
||||
: 'bg-transparent border-transparent hover:bg-white/50 dark:hover:bg-gray-800/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`p-2 rounded-lg ${
|
||||
activeTab === index
|
||||
? 'bg-brand-100 text-brand-600 dark:bg-brand-900/50 dark:text-brand-400'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
<example.icon className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
className={`font-semibold mb-1 ${
|
||||
activeTab === index
|
||||
? 'text-gray-900 dark:text-white'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{example.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500">
|
||||
{example.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Workflow Visual */}
|
||||
<div className="relative">
|
||||
{/* Background Decor */}
|
||||
<div className="absolute -inset-4 bg-gradient-to-r from-brand-500/20 to-purple-500/20 rounded-3xl blur-2xl opacity-50" />
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="relative"
|
||||
>
|
||||
{/* Stats Cards */}
|
||||
<div className="flex gap-4 mb-6">
|
||||
{examples[activeTab].stats.map((stat, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{stat}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Workflow Visual */}
|
||||
<WorkflowVisual
|
||||
variant={examples[activeTab].variant}
|
||||
trigger={examples[activeTab].title}
|
||||
actions={[]}
|
||||
/>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-6 text-right">
|
||||
<a
|
||||
href="/features"
|
||||
className="inline-flex items-center gap-2 text-brand-600 dark:text-brand-400 font-medium hover:underline"
|
||||
>
|
||||
{t('marketing.plugins.cta')} <ArrowRight className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutomationShowcase;
|
||||
|
||||
171
frontend/src/components/marketing/WorkflowVisual.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Calendar,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
Search,
|
||||
FileText,
|
||||
Sparkles,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface WorkflowBlock {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
type: 'trigger' | 'action';
|
||||
}
|
||||
|
||||
interface WorkflowVisualProps {
|
||||
trigger: string;
|
||||
actions: string[];
|
||||
variant?: 'winback' | 'noshow' | 'report';
|
||||
}
|
||||
|
||||
const getWorkflowConfig = (
|
||||
variant: WorkflowVisualProps['variant']
|
||||
): WorkflowBlock[] => {
|
||||
switch (variant) {
|
||||
case 'winback':
|
||||
return [
|
||||
{ icon: Clock, label: 'Schedule: Weekly', type: 'trigger' },
|
||||
{ icon: Search, label: 'Find Inactive Customers', type: 'action' },
|
||||
{ icon: Mail, label: 'Send Email', type: 'action' },
|
||||
];
|
||||
case 'noshow':
|
||||
return [
|
||||
{ icon: Calendar, label: 'Event Created', type: 'trigger' },
|
||||
{ icon: Clock, label: 'Wait 2 Hours Before', type: 'action' },
|
||||
{ icon: MessageSquare, label: 'Send SMS', type: 'action' },
|
||||
];
|
||||
case 'report':
|
||||
return [
|
||||
{ icon: Clock, label: 'Daily at 6 PM', type: 'trigger' },
|
||||
{ icon: FileText, label: "Get Tomorrow's Schedule", type: 'action' },
|
||||
{ icon: Mail, label: 'Send Summary', type: 'action' },
|
||||
];
|
||||
default:
|
||||
return [
|
||||
{ icon: Calendar, label: 'Event Created', type: 'trigger' },
|
||||
{ icon: Clock, label: 'Wait', type: 'action' },
|
||||
{ icon: Mail, label: 'Send Notification', type: 'action' },
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
const WorkflowVisual: React.FC<WorkflowVisualProps> = ({
|
||||
variant = 'noshow',
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const blocks = getWorkflowConfig(variant);
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-xl overflow-hidden">
|
||||
{/* AI Copilot Input */}
|
||||
<div className="p-4 bg-gradient-to-r from-purple-50 to-brand-50 dark:from-purple-900/20 dark:to-brand-900/20 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3 bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-3 shadow-sm">
|
||||
<Sparkles className="w-5 h-5 text-purple-500" />
|
||||
<span className="text-gray-400 dark:text-gray-500 text-sm flex-1">
|
||||
{t('marketing.plugins.aiCopilot.placeholder')}
|
||||
</span>
|
||||
<motion.div
|
||||
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity }}
|
||||
className="w-2 h-5 bg-purple-500 rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2 ml-1">
|
||||
{t('marketing.plugins.aiCopilot.examples')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Workflow Visualization */}
|
||||
<div className="p-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
{blocks.map((block, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{/* Block */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.15 }}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border ${
|
||||
block.type === 'trigger'
|
||||
? 'bg-gradient-to-r from-brand-50 to-purple-50 dark:from-brand-900/30 dark:to-purple-900/30 border-brand-200 dark:border-brand-800'
|
||||
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`p-2 rounded-lg ${
|
||||
block.type === 'trigger'
|
||||
? 'bg-brand-100 dark:bg-brand-900/50 text-brand-600 dark:text-brand-400'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
<block.icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span
|
||||
className={`text-xs font-medium uppercase tracking-wide ${
|
||||
block.type === 'trigger'
|
||||
? 'text-brand-600 dark:text-brand-400'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{block.type === 'trigger' ? 'When' : 'Then'}
|
||||
</span>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{block.label}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-400" />
|
||||
</motion.div>
|
||||
|
||||
{/* Connector */}
|
||||
{index < blocks.length - 1 && (
|
||||
<div className="flex items-center justify-center h-4">
|
||||
<div className="relative w-0.5 h-full bg-gray-200 dark:bg-gray-700">
|
||||
<motion.div
|
||||
className="absolute w-2 h-2 bg-brand-500 rounded-full left-1/2 -translate-x-1/2"
|
||||
animate={{ y: [0, 12, 0] }}
|
||||
transition={{
|
||||
duration: 1,
|
||||
repeat: Infinity,
|
||||
delay: index * 0.3,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Integration badges */}
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||
{t('marketing.plugins.integrations.description')}
|
||||
</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{['Gmail', 'Slack', 'Sheets', 'Twilio'].map((app) => (
|
||||
<span
|
||||
key={app}
|
||||
className="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded-md"
|
||||
>
|
||||
{app}
|
||||
</span>
|
||||
))}
|
||||
<span className="px-2 py-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
+1000 more
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowVisual;
|
||||
35
frontend/src/hooks/useActivepieces.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import * as activepiecesApi from '../api/activepieces';
|
||||
|
||||
export const activepiecesKeys = {
|
||||
all: ['activepieces'] as const,
|
||||
defaultFlows: () => [...activepiecesKeys.all, 'defaultFlows'] as const,
|
||||
};
|
||||
|
||||
export const useDefaultFlows = () => {
|
||||
return useQuery({
|
||||
queryKey: activepiecesKeys.defaultFlows(),
|
||||
queryFn: activepiecesApi.getDefaultFlows,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
export const useRestoreFlow = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: activepiecesApi.restoreFlow,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: activepiecesKeys.defaultFlows() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useRestoreAllFlows = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: activepiecesApi.restoreAllFlows,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: activepiecesKeys.defaultFlows() });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -102,11 +102,20 @@ export const useLogout = () => {
|
||||
queryClient.removeQueries({ queryKey: ['currentUser'] });
|
||||
queryClient.clear();
|
||||
|
||||
// Redirect to login page on root domain
|
||||
// Redirect to appropriate login page based on current subdomain
|
||||
const protocol = window.location.protocol;
|
||||
const baseDomain = getBaseDomain();
|
||||
const port = window.location.port ? `:${window.location.port}` : '';
|
||||
window.location.href = `${protocol}//${baseDomain}${port}/login`;
|
||||
const hostname = window.location.hostname;
|
||||
|
||||
// Check if on platform subdomain
|
||||
if (hostname.startsWith('platform.')) {
|
||||
// Platform users go to platform login page
|
||||
window.location.href = `${protocol}//platform.${baseDomain}${port}/platform/login`;
|
||||
} else {
|
||||
// Business users go to their subdomain's login page
|
||||
window.location.href = `${protocol}//${hostname}${port}/login`;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
514
frontend/src/hooks/useStaffEmail.ts
Normal file
@@ -0,0 +1,514 @@
|
||||
/**
|
||||
* Staff Email Hooks
|
||||
*
|
||||
* React Query hooks for the platform staff email client.
|
||||
* Provides data fetching, mutations, and caching for the email UI.
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query';
|
||||
import * as staffEmailApi from '../api/staffEmail';
|
||||
import {
|
||||
StaffEmailFolder,
|
||||
StaffEmail,
|
||||
StaffEmailListItem,
|
||||
StaffEmailLabel,
|
||||
StaffEmailFilters,
|
||||
StaffEmailCreateDraft,
|
||||
StaffEmailMove,
|
||||
StaffEmailBulkAction,
|
||||
StaffEmailReply,
|
||||
StaffEmailForward,
|
||||
EmailContactSuggestion,
|
||||
} from '../types';
|
||||
|
||||
// Query keys for cache management
|
||||
export const staffEmailKeys = {
|
||||
all: ['staffEmail'] as const,
|
||||
folders: () => [...staffEmailKeys.all, 'folders'] as const,
|
||||
emails: () => [...staffEmailKeys.all, 'emails'] as const,
|
||||
// Use explicit key parts instead of object to ensure proper cache separation
|
||||
emailList: (filters: StaffEmailFilters) => [
|
||||
...staffEmailKeys.emails(),
|
||||
'list',
|
||||
'folder',
|
||||
filters.folderId ?? 'none',
|
||||
'account',
|
||||
filters.emailAddressId ?? 'none',
|
||||
'search',
|
||||
filters.search ?? '',
|
||||
] as const,
|
||||
emailDetail: (id: number) => [...staffEmailKeys.emails(), 'detail', id] as const,
|
||||
emailThread: (threadId: string) => [...staffEmailKeys.emails(), 'thread', threadId] as const,
|
||||
labels: () => [...staffEmailKeys.all, 'labels'] as const,
|
||||
contacts: (query: string) => [...staffEmailKeys.all, 'contacts', query] as const,
|
||||
userEmailAddresses: () => [...staffEmailKeys.all, 'userEmailAddresses'] as const,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Folder Hooks
|
||||
// ============================================================================
|
||||
|
||||
export const useStaffEmailFolders = () => {
|
||||
return useQuery<StaffEmailFolder[]>({
|
||||
queryKey: staffEmailKeys.folders(),
|
||||
queryFn: staffEmailApi.getFolders,
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateStaffEmailFolder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (name: string) => staffEmailApi.createFolder(name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateStaffEmailFolder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, name }: { id: number; name: string }) => staffEmailApi.updateFolder(id, name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteStaffEmailFolder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.deleteFolder(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Email List Hooks
|
||||
// ============================================================================
|
||||
|
||||
export const useStaffEmails = (filters: StaffEmailFilters = {}, pageSize: number = 50) => {
|
||||
const queryKey = staffEmailKeys.emailList(filters);
|
||||
|
||||
// Debug logging
|
||||
console.log('[useStaffEmails] Hook called with:', { filters, queryKey, enabled: !!filters.folderId });
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey,
|
||||
queryFn: async ({ pageParam = 1 }) => {
|
||||
console.log('[useStaffEmails] queryFn executing with:', { filters, pageParam });
|
||||
return staffEmailApi.getEmails(filters, pageParam, pageSize);
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
if (lastPage.next) {
|
||||
return allPages.length + 1;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
staleTime: 10000, // 10 seconds
|
||||
// Only fetch when a folder is selected to prevent showing all emails
|
||||
enabled: !!filters.folderId,
|
||||
// Ensure fresh data when filters change
|
||||
refetchOnMount: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const useStaffEmailList = (filters: StaffEmailFilters = {}, page: number = 1, pageSize: number = 50) => {
|
||||
return useQuery({
|
||||
queryKey: [...staffEmailKeys.emailList(filters), page],
|
||||
queryFn: () => staffEmailApi.getEmails(filters, page, pageSize),
|
||||
staleTime: 10000,
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Single Email Hooks
|
||||
// ============================================================================
|
||||
|
||||
export const useStaffEmail = (id: number | undefined) => {
|
||||
return useQuery<StaffEmail>({
|
||||
queryKey: staffEmailKeys.emailDetail(id!),
|
||||
queryFn: () => staffEmailApi.getEmail(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useStaffEmailThread = (threadId: string | undefined) => {
|
||||
return useQuery<StaffEmail[]>({
|
||||
queryKey: staffEmailKeys.emailThread(threadId!),
|
||||
queryFn: () => staffEmailApi.getEmailThread(threadId!),
|
||||
enabled: !!threadId,
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Draft Hooks
|
||||
// ============================================================================
|
||||
|
||||
export const useCreateDraft = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: StaffEmailCreateDraft) => staffEmailApi.createDraft(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateDraft = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<StaffEmailCreateDraft> }) =>
|
||||
staffEmailApi.updateDraft(id, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emailDetail(variables.id) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteDraft = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.deleteDraft(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Send/Reply/Forward Hooks
|
||||
// ============================================================================
|
||||
|
||||
export const useSendEmail = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.sendEmail(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useReplyToEmail = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: StaffEmailReply }) =>
|
||||
staffEmailApi.replyToEmail(id, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emailDetail(variables.id) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useForwardEmail = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: StaffEmailForward }) =>
|
||||
staffEmailApi.forwardEmail(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Email Action Hooks
|
||||
// ============================================================================
|
||||
|
||||
export const useMarkAsRead = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.markAsRead(id),
|
||||
onSuccess: (_, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emailDetail(id) });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
// Optimistic update
|
||||
onMutate: async (id) => {
|
||||
await queryClient.cancelQueries({ queryKey: staffEmailKeys.emailDetail(id) });
|
||||
const previousEmail = queryClient.getQueryData<StaffEmail>(staffEmailKeys.emailDetail(id));
|
||||
if (previousEmail) {
|
||||
queryClient.setQueryData<StaffEmail>(staffEmailKeys.emailDetail(id), {
|
||||
...previousEmail,
|
||||
isRead: true,
|
||||
});
|
||||
}
|
||||
return { previousEmail };
|
||||
},
|
||||
onError: (err, id, context) => {
|
||||
if (context?.previousEmail) {
|
||||
queryClient.setQueryData(staffEmailKeys.emailDetail(id), context.previousEmail);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useMarkAsUnread = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.markAsUnread(id),
|
||||
onSuccess: (_, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emailDetail(id) });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useStarEmail = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.starEmail(id),
|
||||
onSuccess: (_, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emailDetail(id) });
|
||||
},
|
||||
// Optimistic update
|
||||
onMutate: async (id) => {
|
||||
await queryClient.cancelQueries({ queryKey: staffEmailKeys.emailDetail(id) });
|
||||
const previousEmail = queryClient.getQueryData<StaffEmail>(staffEmailKeys.emailDetail(id));
|
||||
if (previousEmail) {
|
||||
queryClient.setQueryData<StaffEmail>(staffEmailKeys.emailDetail(id), {
|
||||
...previousEmail,
|
||||
isStarred: true,
|
||||
});
|
||||
}
|
||||
return { previousEmail };
|
||||
},
|
||||
onError: (err, id, context) => {
|
||||
if (context?.previousEmail) {
|
||||
queryClient.setQueryData(staffEmailKeys.emailDetail(id), context.previousEmail);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUnstarEmail = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.unstarEmail(id),
|
||||
onSuccess: (_, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emailDetail(id) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useArchiveEmail = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.archiveEmail(id),
|
||||
onSuccess: () => {
|
||||
// Reset and refetch all email list queries
|
||||
queryClient.resetQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useTrashEmail = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.trashEmail(id),
|
||||
onSuccess: () => {
|
||||
// Reset and refetch all email list queries
|
||||
queryClient.resetQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useRestoreEmail = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.restoreEmail(id),
|
||||
onSuccess: () => {
|
||||
queryClient.resetQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const usePermanentlyDeleteEmail = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.permanentlyDeleteEmail(id),
|
||||
onSuccess: () => {
|
||||
queryClient.resetQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useMoveEmails = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: StaffEmailMove) => staffEmailApi.moveEmails(data),
|
||||
onSuccess: () => {
|
||||
queryClient.resetQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useBulkEmailAction = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: StaffEmailBulkAction) => staffEmailApi.bulkAction(data),
|
||||
onSuccess: () => {
|
||||
queryClient.resetQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Label Hooks
|
||||
// ============================================================================
|
||||
|
||||
export const useStaffEmailLabels = () => {
|
||||
return useQuery<StaffEmailLabel[]>({
|
||||
queryKey: staffEmailKeys.labels(),
|
||||
queryFn: staffEmailApi.getLabels,
|
||||
staleTime: 60000, // 1 minute
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateLabel = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ name, color }: { name: string; color: string }) =>
|
||||
staffEmailApi.createLabel(name, color),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.labels() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateLabel = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: { name?: string; color?: string } }) =>
|
||||
staffEmailApi.updateLabel(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.labels() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteLabel = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.deleteLabel(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.labels() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emails() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useAddLabelToEmail = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ emailId, labelId }: { emailId: number; labelId: number }) =>
|
||||
staffEmailApi.addLabelToEmail(emailId, labelId),
|
||||
onSuccess: (_, { emailId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emailDetail(emailId) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useRemoveLabelFromEmail = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ emailId, labelId }: { emailId: number; labelId: number }) =>
|
||||
staffEmailApi.removeLabelFromEmail(emailId, labelId),
|
||||
onSuccess: (_, { emailId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emailDetail(emailId) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Contact Search Hook
|
||||
// ============================================================================
|
||||
|
||||
export const useContactSearch = (query: string) => {
|
||||
return useQuery<EmailContactSuggestion[]>({
|
||||
queryKey: staffEmailKeys.contacts(query),
|
||||
queryFn: () => staffEmailApi.searchContacts(query),
|
||||
enabled: query.length >= 2,
|
||||
staleTime: 30000,
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Attachment Hook
|
||||
// ============================================================================
|
||||
|
||||
export const useUploadAttachment = () => {
|
||||
return useMutation({
|
||||
mutationFn: ({ file, emailId }: { file: File; emailId?: number }) =>
|
||||
staffEmailApi.uploadAttachment(file, emailId),
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteAttachment = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.deleteAttachment(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emails() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Sync Hook
|
||||
// ============================================================================
|
||||
|
||||
export const useSyncEmails = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => staffEmailApi.syncEmails(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useFullSyncEmails = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => staffEmailApi.fullSyncEmails(),
|
||||
onSuccess: () => {
|
||||
// Invalidate after a delay to allow sync to complete
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.userEmailAddresses() });
|
||||
}, 2000);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// User Email Addresses Hook
|
||||
// ============================================================================
|
||||
|
||||
export const useUserEmailAddresses = () => {
|
||||
return useQuery({
|
||||
queryKey: staffEmailKeys.userEmailAddresses(),
|
||||
queryFn: staffEmailApi.getUserEmailAddresses,
|
||||
staleTime: 60000, // 1 minute
|
||||
});
|
||||
};
|
||||
345
frontend/src/hooks/useStaffEmailWebSocket.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useCurrentUser } from './useAuth';
|
||||
import { getBaseDomain } from '../utils/domain';
|
||||
import { getCookie } from '../utils/cookies';
|
||||
|
||||
/**
|
||||
* Event types sent by the staff email WebSocket consumer
|
||||
*/
|
||||
type StaffEmailEventType =
|
||||
| 'new_email'
|
||||
| 'email_read'
|
||||
| 'email_unread'
|
||||
| 'email_moved'
|
||||
| 'email_deleted'
|
||||
| 'folder_counts'
|
||||
| 'sync_started'
|
||||
| 'sync_completed'
|
||||
| 'sync_error';
|
||||
|
||||
interface NewEmailData {
|
||||
id?: number;
|
||||
subject?: string;
|
||||
from_name?: string;
|
||||
from_address?: string;
|
||||
snippet?: string;
|
||||
folder_id?: number;
|
||||
email_address_id?: number;
|
||||
}
|
||||
|
||||
interface FolderCountData {
|
||||
[folderId: string]: {
|
||||
unread_count?: number;
|
||||
total_count?: number;
|
||||
folder_type?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SyncStatusData {
|
||||
email_address_id?: number;
|
||||
results?: Record<string, number>;
|
||||
new_count?: number;
|
||||
message?: string;
|
||||
details?: {
|
||||
results?: Record<string, number>;
|
||||
new_count?: number;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type StaffEmailData = NewEmailData | FolderCountData | SyncStatusData;
|
||||
|
||||
interface StaffEmailWebSocketMessage {
|
||||
type: StaffEmailEventType;
|
||||
data: StaffEmailData;
|
||||
}
|
||||
|
||||
interface UseStaffEmailWebSocketOptions {
|
||||
/** Show toast notifications for events (default: true) */
|
||||
showToasts?: boolean;
|
||||
/** Callback when a new email arrives */
|
||||
onNewEmail?: (data: NewEmailData) => void;
|
||||
/** Callback when sync completes */
|
||||
onSyncComplete?: (data: SyncStatusData) => void;
|
||||
/** Callback when folder counts update */
|
||||
onFolderCountsUpdate?: (data: FolderCountData) => void;
|
||||
/** Callback when sync starts */
|
||||
onSyncStarted?: (data: SyncStatusData) => void;
|
||||
/** Callback when sync errors */
|
||||
onSyncError?: (data: SyncStatusData) => void;
|
||||
}
|
||||
|
||||
interface StaffEmailWebSocketResult {
|
||||
/** Whether the WebSocket is currently connected */
|
||||
isConnected: boolean;
|
||||
/** Whether a sync is currently in progress */
|
||||
isSyncing: boolean;
|
||||
/** Manually reconnect the WebSocket */
|
||||
reconnect: () => void;
|
||||
/** Send a message to the WebSocket (for future client commands) */
|
||||
send: (data: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to manage WebSocket connection for real-time staff email updates.
|
||||
* Automatically invalidates React Query cache when email changes occur.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { isConnected, isSyncing } = useStaffEmailWebSocket({
|
||||
* showToasts: true,
|
||||
* onNewEmail: (data) => {
|
||||
* console.log('New email:', data.subject);
|
||||
* },
|
||||
* onSyncComplete: () => {
|
||||
* console.log('Email sync finished');
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export const useStaffEmailWebSocket = (
|
||||
options: UseStaffEmailWebSocketOptions = {}
|
||||
): StaffEmailWebSocketResult => {
|
||||
const {
|
||||
showToasts = true,
|
||||
onNewEmail,
|
||||
onSyncComplete,
|
||||
onFolderCountsUpdate,
|
||||
onSyncStarted,
|
||||
onSyncError,
|
||||
} = options;
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttempts = useRef(0);
|
||||
const maxReconnectAttempts = 5;
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const { data: user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(event: MessageEvent) => {
|
||||
try {
|
||||
const message: StaffEmailWebSocketMessage = JSON.parse(event.data);
|
||||
console.log('Staff Email WebSocket message received:', message);
|
||||
|
||||
switch (message.type) {
|
||||
case 'new_email': {
|
||||
const data = message.data as NewEmailData;
|
||||
// Invalidate email list and folders
|
||||
queryClient.invalidateQueries({ queryKey: ['staff-emails'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['staff-email-folders'] });
|
||||
if (showToasts) {
|
||||
toast.success(
|
||||
`New email from ${data.from_name || data.from_address || 'Unknown'}`,
|
||||
{
|
||||
duration: 5000,
|
||||
position: 'top-right',
|
||||
icon: '📧',
|
||||
}
|
||||
);
|
||||
}
|
||||
onNewEmail?.(data);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'email_read':
|
||||
case 'email_unread':
|
||||
case 'email_moved':
|
||||
case 'email_deleted': {
|
||||
const data = message.data as NewEmailData;
|
||||
// Invalidate email list and specific email
|
||||
queryClient.invalidateQueries({ queryKey: ['staff-emails'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['staff-email-folders'] });
|
||||
if (data.id) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['staff-email', data.id],
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'folder_counts': {
|
||||
const data = message.data as FolderCountData;
|
||||
// Update folder counts without full refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['staff-email-folders'] });
|
||||
onFolderCountsUpdate?.(data);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'sync_started': {
|
||||
const data = message.data as SyncStatusData;
|
||||
setIsSyncing(true);
|
||||
if (showToasts) {
|
||||
toast.loading('Syncing emails...', {
|
||||
id: 'email-sync',
|
||||
position: 'bottom-right',
|
||||
});
|
||||
}
|
||||
onSyncStarted?.(data);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'sync_completed': {
|
||||
const data = message.data as SyncStatusData;
|
||||
setIsSyncing(false);
|
||||
// Invalidate all email-related queries
|
||||
queryClient.invalidateQueries({ queryKey: ['staff-emails'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['staff-email-folders'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['staff-email-addresses'] });
|
||||
if (showToasts) {
|
||||
const newCount = data.details?.new_count || data.new_count || 0;
|
||||
toast.success(
|
||||
newCount > 0 ? `Synced ${newCount} new email${newCount === 1 ? '' : 's'}` : 'Emails synced',
|
||||
{
|
||||
id: 'email-sync',
|
||||
duration: 3000,
|
||||
position: 'bottom-right',
|
||||
}
|
||||
);
|
||||
}
|
||||
onSyncComplete?.(data);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'sync_error': {
|
||||
const data = message.data as SyncStatusData;
|
||||
setIsSyncing(false);
|
||||
if (showToasts) {
|
||||
const errorMsg = data.details?.message || data.message || 'Sync failed';
|
||||
toast.error(`Email sync error: ${errorMsg}`, {
|
||||
id: 'email-sync',
|
||||
duration: 5000,
|
||||
position: 'bottom-right',
|
||||
});
|
||||
}
|
||||
onSyncError?.(data);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.log('Unknown staff email WebSocket message type:', message.type);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing staff email WebSocket message:', error);
|
||||
}
|
||||
},
|
||||
[
|
||||
queryClient,
|
||||
showToasts,
|
||||
onNewEmail,
|
||||
onSyncComplete,
|
||||
onFolderCountsUpdate,
|
||||
onSyncStarted,
|
||||
onSyncError,
|
||||
]
|
||||
);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!user || !user.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine WebSocket URL using same logic as API config
|
||||
const baseDomain = getBaseDomain();
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
|
||||
// For localhost or lvh.me, use port 8000. In production, no port (Traefik handles it)
|
||||
const isDev = baseDomain === 'localhost' || baseDomain === 'lvh.me';
|
||||
const port = isDev ? ':8000' : '';
|
||||
|
||||
const token = getCookie('access_token');
|
||||
const wsUrl = `${protocol}//api.${baseDomain}${port}/ws/staff-email/?token=${token}`;
|
||||
|
||||
console.log('Connecting to staff email WebSocket:', wsUrl);
|
||||
|
||||
try {
|
||||
wsRef.current = new WebSocket(wsUrl);
|
||||
|
||||
wsRef.current.onopen = () => {
|
||||
console.log('Staff Email WebSocket connected');
|
||||
setIsConnected(true);
|
||||
reconnectAttempts.current = 0; // Reset reconnect attempts on successful connection
|
||||
};
|
||||
|
||||
wsRef.current.onmessage = handleMessage;
|
||||
|
||||
wsRef.current.onclose = (event) => {
|
||||
console.log('Staff Email WebSocket disconnected:', event.code, event.reason);
|
||||
setIsConnected(false);
|
||||
setIsSyncing(false);
|
||||
|
||||
// Attempt to reconnect with exponential backoff
|
||||
if (user && user.id && reconnectAttempts.current < maxReconnectAttempts) {
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000);
|
||||
reconnectAttempts.current += 1;
|
||||
console.log(
|
||||
`Attempting to reconnect staff email WebSocket in ${delay}ms (attempt ${reconnectAttempts.current})`
|
||||
);
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
connect();
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
|
||||
wsRef.current.onerror = (error) => {
|
||||
console.error('Staff Email WebSocket error:', error);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to create staff email WebSocket:', error);
|
||||
}
|
||||
}, [user, handleMessage]);
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
// Clear any existing reconnect timeout
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
// Close existing connection
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
// Reset reconnect attempts
|
||||
reconnectAttempts.current = 0;
|
||||
// Connect
|
||||
connect();
|
||||
}, [connect]);
|
||||
|
||||
const send = useCallback((data: unknown) => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(data));
|
||||
} else {
|
||||
console.warn('Cannot send message: WebSocket is not connected');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
// Clear reconnect timeout
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
// Close WebSocket
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
isSyncing,
|
||||
reconnect,
|
||||
send,
|
||||
};
|
||||
};
|
||||
|
||||
export default useStaffEmailWebSocket;
|
||||
@@ -2444,14 +2444,14 @@
|
||||
"pageTitle": "Built for Developers, Designed for Business",
|
||||
"pageSubtitle": "SmoothSchedule isn't just cloud software. It's a programmable platform that adapts to your unique business logic.",
|
||||
"automationEngine": {
|
||||
"badge": "Automation Engine",
|
||||
"title": "Automated Task Manager",
|
||||
"description": "Most schedulers only book appointments. SmoothSchedule runs your business. Our \"Automated Task Manager\" executes internal tasks without blocking your calendar.",
|
||||
"badge": "AI-Powered Automation",
|
||||
"title": "Visual Workflow Builder with AI Copilot",
|
||||
"description": "Most schedulers only book appointments. SmoothSchedule runs your business. Create powerful automations with our visual builder or just describe what you want.",
|
||||
"features": {
|
||||
"recurringJobs": "Run recurring jobs (e.g., \"Every Monday at 9am\")",
|
||||
"customLogic": "Execute custom logic securely",
|
||||
"fullContext": "Access full customer and event context",
|
||||
"zeroInfrastructure": "Zero infrastructure management"
|
||||
"visualBuilder": "Visual drag-and-drop workflow builder",
|
||||
"aiCopilot": "AI Copilot creates flows from natural language",
|
||||
"integrations": "Connect to 1000+ apps (Gmail, Slack, Sheets, etc.)",
|
||||
"templates": "Pre-built templates for common automations"
|
||||
}
|
||||
},
|
||||
"multiTenancy": {
|
||||
@@ -2558,7 +2558,7 @@
|
||||
"0": "Unlimited Users",
|
||||
"1": "Unlimited Appointments",
|
||||
"2": "Unlimited Automations",
|
||||
"3": "Custom Python Scripts",
|
||||
"3": "AI-Powered Workflow Builder",
|
||||
"4": "Custom Domain (White-Label)",
|
||||
"5": "Dedicated Support",
|
||||
"6": "API Access"
|
||||
@@ -2638,9 +2638,9 @@
|
||||
},
|
||||
"faq": {
|
||||
"title": "Frequently Asked Questions",
|
||||
"needPython": {
|
||||
"question": "Do I need to know Python to use SmoothSchedule?",
|
||||
"answer": "Not at all! You can use our pre-built plugins from the marketplace for common tasks like email reminders and reports. Python is only needed if you want to write custom scripts."
|
||||
"needCoding": {
|
||||
"question": "Do I need to know how to code to create automations?",
|
||||
"answer": "Not at all! Our visual workflow builder lets you create automations by dragging and dropping blocks. Even better, just describe what you want in plain English and our AI Copilot will build the workflow for you."
|
||||
},
|
||||
"exceedLimits": {
|
||||
"question": "What happens if I exceed my plan's limits?",
|
||||
@@ -2939,19 +2939,14 @@
|
||||
"copyright": "Smooth Schedule Inc. All rights reserved."
|
||||
},
|
||||
"plugins": {
|
||||
"badge": "Limitless Automation",
|
||||
"headline": "Choose from our Marketplace, or build your own.",
|
||||
"subheadline": "Browse hundreds of pre-built automations to streamline your workflows instantly. Need something custom? Developers can write Python scripts to extend the platform endlessly.",
|
||||
"viewToggle": {
|
||||
"marketplace": "Marketplace",
|
||||
"developer": "Developer"
|
||||
"badge": "Visual Automation Builder",
|
||||
"headline": "Build automations visually, or just describe what you want.",
|
||||
"subheadline": "Create powerful workflows with our drag-and-drop builder. No coding required. Just describe what you want and our AI Copilot will build it for you.",
|
||||
"aiCopilot": {
|
||||
"placeholder": "Describe your automation...",
|
||||
"examples": "e.g., \"Send a reminder 2 hours before each appointment\""
|
||||
},
|
||||
"marketplaceCard": {
|
||||
"author": "by SmoothSchedule Team",
|
||||
"installButton": "Install Automation",
|
||||
"usedBy": "Used by 1,200+ businesses"
|
||||
},
|
||||
"cta": "Explore the Marketplace",
|
||||
"cta": "Try the Automation Builder",
|
||||
"examples": {
|
||||
"winback": {
|
||||
"title": "Client Win-Back",
|
||||
@@ -2960,7 +2955,8 @@
|
||||
"retention": "+15% Retention",
|
||||
"revenue": "$4k/mo Revenue"
|
||||
},
|
||||
"code": "# Win back lost customers\ndays_inactive = 60\ndiscount = \"20%\"\n\n# Find inactive customers\ninactive = api.get_customers(\n last_visit_lt=days_ago(days_inactive)\n)\n\n# Send personalized offer\nfor customer in inactive:\n api.send_email(\n to=customer.email,\n subject=\"We miss you!\",\n body=f\"Come back for {discount} off!\"\n )"
|
||||
"trigger": "Schedule: Every Monday",
|
||||
"actions": ["Find inactive customers", "Send personalized email"]
|
||||
},
|
||||
"noshow": {
|
||||
"title": "No-Show Prevention",
|
||||
@@ -2969,7 +2965,8 @@
|
||||
"reduction": "-40% No-Shows",
|
||||
"utilization": "Better Utilization"
|
||||
},
|
||||
"code": "# Prevent no-shows\nhours_before = 2\n\n# Find upcoming appointments\nupcoming = api.get_appointments(\n start_time__within=hours(hours_before)\n)\n\n# Send SMS reminder\nfor appt in upcoming:\n api.send_sms(\n to=appt.customer.phone,\n body=f\"Reminder: Appointment in 2h at {appt.time}\"\n )"
|
||||
"trigger": "Event: Appointment Created",
|
||||
"actions": ["Wait 2 hours before", "Send SMS reminder"]
|
||||
},
|
||||
"report": {
|
||||
"title": "Daily Reports",
|
||||
@@ -2978,8 +2975,13 @@
|
||||
"timeSaved": "Save 30min/day",
|
||||
"visibility": "Full Visibility"
|
||||
},
|
||||
"code": "# Daily Manager Report\ntomorrow = date.today() + timedelta(days=1)\n\n# Get schedule stats\nstats = api.get_schedule_stats(date=tomorrow)\nrevenue = api.forecast_revenue(date=tomorrow)\n\n# Email manager\napi.send_email(\n to=\"manager@business.com\",\n subject=f\"Schedule for {tomorrow}\",\n body=f\"Bookings: {stats.count}, Est. Rev: ${revenue}\"\n)"
|
||||
"trigger": "Schedule: Daily at 6 PM",
|
||||
"actions": ["Get tomorrow's schedule", "Send email summary"]
|
||||
}
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Connect to 1000+ Apps",
|
||||
"description": "Gmail, Slack, Google Sheets, and more"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
@@ -2993,8 +2995,8 @@
|
||||
"description": "Handle complex resources like staff, rooms, and equipment with concurrency limits."
|
||||
},
|
||||
"automationEngine": {
|
||||
"title": "Automation Engine",
|
||||
"description": "Install automations from our marketplace or build your own to automate tasks."
|
||||
"title": "AI-Powered Automations",
|
||||
"description": "Build visual workflows with AI assistance. Connect to 1000+ apps with no code."
|
||||
},
|
||||
"multiTenant": {
|
||||
"title": "Enterprise Security",
|
||||
@@ -3023,7 +3025,7 @@
|
||||
},
|
||||
"testimonials": {
|
||||
"winBack": {
|
||||
"quote": "I installed the 'Client Win-Back' plugin and recovered $2k in bookings the first week. No setup required.",
|
||||
"quote": "I set up the 'Client Win-Back' automation in 2 minutes using the AI Copilot. Recovered $2k in bookings the first week.",
|
||||
"author": "Alex Rivera",
|
||||
"role": "Owner",
|
||||
"company": "TechSalon"
|
||||
@@ -4039,5 +4041,49 @@
|
||||
"description": "Can't find what you're looking for? Our support team is ready to help.",
|
||||
"contactSupport": "Contact Support"
|
||||
}
|
||||
},
|
||||
"staffEmail": {
|
||||
"title": "Staff Email",
|
||||
"compose": "Compose",
|
||||
"inbox": "Inbox",
|
||||
"sent": "Sent",
|
||||
"drafts": "Drafts",
|
||||
"trash": "Trash",
|
||||
"archive": "Archive",
|
||||
"spam": "Spam",
|
||||
"folders": "Folders",
|
||||
"labels": "Labels",
|
||||
"noEmails": "No emails",
|
||||
"selectEmail": "Select an email to read",
|
||||
"searchPlaceholder": "Search emails...",
|
||||
"reply": "Reply",
|
||||
"replyAll": "Reply All",
|
||||
"forward": "Forward",
|
||||
"markAsRead": "Mark as read",
|
||||
"markAsUnread": "Mark as unread",
|
||||
"star": "Star",
|
||||
"unstar": "Unstar",
|
||||
"moveToTrash": "Move to trash",
|
||||
"delete": "Delete",
|
||||
"restore": "Restore",
|
||||
"send": "Send",
|
||||
"saveDraft": "Save draft",
|
||||
"discard": "Discard",
|
||||
"to": "To",
|
||||
"cc": "Cc",
|
||||
"bcc": "Bcc",
|
||||
"subject": "Subject",
|
||||
"from": "From",
|
||||
"date": "Date",
|
||||
"attachments": "Attachments",
|
||||
"clickToReply": "Click here to reply...",
|
||||
"newMessage": "New Message",
|
||||
"emailSent": "Email sent",
|
||||
"draftSaved": "Draft saved",
|
||||
"emailArchived": "Email archived",
|
||||
"emailTrashed": "Email moved to trash",
|
||||
"emailRestored": "Email restored",
|
||||
"syncComplete": "Emails synced",
|
||||
"syncFailed": "Failed to sync emails"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Business, User } from '../types';
|
||||
import MasqueradeBanner from '../components/MasqueradeBanner';
|
||||
import OnboardingWizard from '../components/OnboardingWizard';
|
||||
import TicketModal from '../components/TicketModal';
|
||||
import FloatingHelpButton from '../components/FloatingHelpButton';
|
||||
import { useStopMasquerade } from '../hooks/useAuth';
|
||||
import { useNotificationWebSocket } from '../hooks/useNotificationWebSocket';
|
||||
import { useTicket } from '../hooks/useTickets';
|
||||
@@ -183,9 +182,6 @@ const BusinessLayoutContent: React.FC<BusinessLayoutProps> = ({ business, user,
|
||||
|
||||
return (
|
||||
<div className="flex h-full bg-gray-50 dark:bg-gray-900 transition-colors duration-200">
|
||||
{/* Floating Help Button */}
|
||||
<FloatingHelpButton />
|
||||
|
||||
<div className={`fixed inset-y-0 left-0 z-40 transform ${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full'} transition-transform duration-300 ease-in-out md:hidden`}>
|
||||
<Sidebar business={business} user={user} isCollapsed={false} toggleCollapse={() => { }} />
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Outlet } from 'react-router-dom';
|
||||
import { Moon, Sun, Bell, Globe, Menu } from 'lucide-react';
|
||||
import { User } from '../types';
|
||||
import PlatformSidebar from '../components/PlatformSidebar';
|
||||
import HelpButton from '../components/HelpButton';
|
||||
import { useScrollToTop } from '../hooks/useScrollToTop';
|
||||
|
||||
interface ManagerLayoutProps {
|
||||
@@ -52,7 +53,7 @@ const ManagerLayout: React.FC<ManagerLayoutProps> = ({ user, darkMode, toggleThe
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
@@ -61,6 +62,7 @@ const ManagerLayout: React.FC<ManagerLayoutProps> = ({ user, darkMode, toggleThe
|
||||
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
|
||||
<Bell size={20} />
|
||||
</button>
|
||||
<HelpButton />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import UserProfileDropdown from '../components/UserProfileDropdown';
|
||||
import NotificationDropdown from '../components/NotificationDropdown';
|
||||
import LanguageSelector from '../components/LanguageSelector';
|
||||
import TicketModal from '../components/TicketModal';
|
||||
import FloatingHelpButton from '../components/FloatingHelpButton';
|
||||
import HelpButton from '../components/HelpButton';
|
||||
import { useTicket } from '../hooks/useTickets';
|
||||
import { useScrollToTop } from '../hooks/useScrollToTop';
|
||||
|
||||
@@ -26,7 +26,7 @@ const PlatformLayout: React.FC<PlatformLayoutProps> = ({ user, darkMode, toggleT
|
||||
const location = useLocation();
|
||||
|
||||
// Pages that need edge-to-edge rendering (no padding)
|
||||
const noPaddingRoutes = ['/help/api-docs'];
|
||||
const noPaddingRoutes = ['/help/api-docs', '/platform/email'];
|
||||
|
||||
useScrollToTop(mainContentRef);
|
||||
|
||||
@@ -43,9 +43,6 @@ const PlatformLayout: React.FC<PlatformLayoutProps> = ({ user, darkMode, toggleT
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100 dark:bg-gray-900">
|
||||
{/* Floating Help Button */}
|
||||
<FloatingHelpButton />
|
||||
|
||||
{/* Mobile menu */}
|
||||
<div className={`fixed inset-y-0 left-0 z-40 transform ${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full'} transition-transform duration-300 ease-in-out md:hidden`}>
|
||||
<PlatformSidebar user={user} isCollapsed={false} toggleCollapse={() => { }} />
|
||||
@@ -86,6 +83,7 @@ const PlatformLayout: React.FC<PlatformLayoutProps> = ({ user, darkMode, toggleT
|
||||
{darkMode ? <Sun size={20} /> : <Moon size={20} />}
|
||||
</button>
|
||||
<NotificationDropdown onTicketClick={handleTicketClick} />
|
||||
<HelpButton />
|
||||
<UserProfileDropdown user={user} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -39,6 +39,7 @@ vi.mock('../../components/TopBar', () => ({
|
||||
TopBar - {user.name} - {isDarkMode ? 'Dark' : 'Light'}
|
||||
<button onClick={toggleTheme} data-testid="theme-toggle">Toggle Theme</button>
|
||||
<button onClick={onMenuClick} data-testid="menu-button">Menu</button>
|
||||
<div data-testid="help-button">Help</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
@@ -99,9 +100,7 @@ vi.mock('../../components/TicketModal', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/FloatingHelpButton', () => ({
|
||||
default: () => <div data-testid="floating-help-button">Help</div>,
|
||||
}));
|
||||
// HelpButton is now rendered inside TopBar, not as a separate component
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('../../hooks/useAuth', () => ({
|
||||
@@ -224,7 +223,7 @@ describe('BusinessLayout', () => {
|
||||
expect(screen.getAllByTestId('sidebar').length).toBeGreaterThan(0);
|
||||
expect(screen.getByTestId('topbar')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('outlet')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('floating-help-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('help-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render children content via Outlet', () => {
|
||||
@@ -649,7 +648,7 @@ describe('BusinessLayout', () => {
|
||||
it('should render floating help button', () => {
|
||||
renderLayout();
|
||||
|
||||
expect(screen.getByTestId('floating-help-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('help-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -795,7 +794,7 @@ describe('BusinessLayout', () => {
|
||||
expect(screen.getAllByTestId('sidebar').length).toBeGreaterThan(0);
|
||||
expect(screen.getByTestId('topbar')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('outlet')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('floating-help-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('help-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,8 +62,8 @@ vi.mock('../../components/TicketModal', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/FloatingHelpButton', () => ({
|
||||
default: () => <div data-testid="floating-help-button">Help</div>,
|
||||
vi.mock('../../components/HelpButton', () => ({
|
||||
default: () => <div data-testid="help-button">Help</div>,
|
||||
}));
|
||||
|
||||
// Mock hooks - create a mocked function that can be reassigned
|
||||
@@ -150,7 +150,7 @@ describe('PlatformLayout', () => {
|
||||
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('notification-dropdown')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('language-selector')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('floating-help-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('help-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render children content via Outlet', () => {
|
||||
@@ -412,7 +412,7 @@ describe('PlatformLayout', () => {
|
||||
it('should render floating help button', () => {
|
||||
renderLayout();
|
||||
|
||||
expect(screen.getByTestId('floating-help-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('help-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Bot, RefreshCw, AlertTriangle, Loader2, ExternalLink, Sparkles } from 'lucide-react';
|
||||
import { Bot, RefreshCw, AlertTriangle, Loader2, ExternalLink, Sparkles, RotateCcw, ChevronDown } from 'lucide-react';
|
||||
import api from '../api/client';
|
||||
import { usePlanFeatures } from '../hooks/usePlanFeatures';
|
||||
import { LockedSection } from '../components/UpgradePrompt';
|
||||
import { UpgradePrompt } from '../components/UpgradePrompt';
|
||||
import { useDarkMode } from '../hooks/useDarkMode';
|
||||
import { useDefaultFlows, useRestoreFlow, useRestoreAllFlows } from '../hooks/useActivepieces';
|
||||
import ConfirmationModal from '../components/ConfirmationModal';
|
||||
|
||||
interface ActivepiecesEmbedData {
|
||||
token: string;
|
||||
@@ -40,11 +43,22 @@ const ActivepiecesVendorEventName = {
|
||||
*/
|
||||
export default function Automations() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { features, loading: featuresLoading } = usePlanFeatures();
|
||||
const { permissions, isLoading: featuresLoading, canUse } = usePlanFeatures();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [iframeReady, setIframeReady] = useState(false);
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const initSentRef = useRef(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
// Dark mode support
|
||||
const isDark = useDarkMode();
|
||||
|
||||
// Restore default flows
|
||||
const { data: defaultFlows } = useDefaultFlows();
|
||||
const restoreFlow = useRestoreFlow();
|
||||
const restoreAll = useRestoreAllFlows();
|
||||
const [showFlowsMenu, setShowFlowsMenu] = useState(false);
|
||||
const [confirmRestore, setConfirmRestore] = useState<{ type: 'all' | string; name: string } | null>(null);
|
||||
|
||||
// Fetch embed token for Activepieces
|
||||
const {
|
||||
@@ -98,12 +112,13 @@ export default function Automations() {
|
||||
hidePageHeader: false,
|
||||
locale: i18n.language || 'en',
|
||||
initialRoute: '/flows', // Start on flows page to show sidebar
|
||||
mode: isDark ? 'dark' : 'light',
|
||||
},
|
||||
};
|
||||
|
||||
iframeRef.current.contentWindow.postMessage(initMessage, '*');
|
||||
initSentRef.current = true;
|
||||
}, [embedData?.token, i18n.language]);
|
||||
}, [embedData?.token, i18n.language, isDark]);
|
||||
|
||||
// Listen for messages from Activepieces iframe
|
||||
useEffect(() => {
|
||||
@@ -151,8 +166,27 @@ export default function Automations() {
|
||||
}
|
||||
}, [embedData?.token]);
|
||||
|
||||
// Reset state when theme changes to force iframe reload
|
||||
useEffect(() => {
|
||||
// Reset state so the iframe reinitializes with new theme
|
||||
setIframeReady(false);
|
||||
setAuthenticated(false);
|
||||
initSentRef.current = false;
|
||||
}, [isDark]);
|
||||
|
||||
// Close flows menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (showFlowsMenu && !(event.target as Element).closest('.flows-menu-container')) {
|
||||
setShowFlowsMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showFlowsMenu]);
|
||||
|
||||
// Check feature access
|
||||
const canAccessAutomations = features?.can_access_automations ?? true;
|
||||
const canAccessAutomations = canUse('automations');
|
||||
|
||||
// Loading state
|
||||
if (isLoading || featuresLoading) {
|
||||
@@ -172,14 +206,7 @@ export default function Automations() {
|
||||
if (!canAccessAutomations) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<LockedSection
|
||||
title={t('automations.locked.title', 'Automations')}
|
||||
description={t(
|
||||
'automations.locked.description',
|
||||
'Upgrade your plan to access powerful workflow automation with AI-powered flow creation.'
|
||||
)}
|
||||
featureName="automations"
|
||||
/>
|
||||
<UpgradePrompt feature="automations" variant="banner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -212,8 +239,9 @@ export default function Automations() {
|
||||
}
|
||||
|
||||
// Build iframe URL - use /embed route for SDK communication
|
||||
// Include theme parameter for dark mode support
|
||||
const iframeSrc = embedData?.embedUrl
|
||||
? `${embedData.embedUrl}/embed`
|
||||
? `${embedData.embedUrl}/embed?theme=${isDark ? 'dark' : 'light'}`
|
||||
: '';
|
||||
|
||||
// Show loading until authenticated
|
||||
@@ -221,6 +249,47 @@ export default function Automations() {
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Confirmation Modal */}
|
||||
<ConfirmationModal
|
||||
isOpen={!!confirmRestore}
|
||||
onClose={() => setConfirmRestore(null)}
|
||||
onConfirm={() => {
|
||||
const refreshIframe = () => {
|
||||
// Reset iframe state and increment key to force remount
|
||||
initSentRef.current = false;
|
||||
setAuthenticated(false);
|
||||
setIframeReady(false);
|
||||
setRefreshKey((k) => k + 1);
|
||||
refetch();
|
||||
setConfirmRestore(null);
|
||||
};
|
||||
|
||||
if (confirmRestore?.type === 'all') {
|
||||
restoreAll.mutate(undefined, {
|
||||
onSuccess: refreshIframe,
|
||||
});
|
||||
} else if (confirmRestore) {
|
||||
restoreFlow.mutate(confirmRestore.type, {
|
||||
onSuccess: refreshIframe,
|
||||
});
|
||||
}
|
||||
}}
|
||||
title={t('automations.restore.title', 'Restore Default Flow')}
|
||||
message={
|
||||
confirmRestore?.type === 'all'
|
||||
? t(
|
||||
'automations.restore.allMessage',
|
||||
'This will restore all default automation flows. Any customizations will be overwritten.'
|
||||
)
|
||||
: t('automations.restore.singleMessage', 'Restore "{{name}}" to its default configuration?', {
|
||||
name: confirmRestore?.name,
|
||||
})
|
||||
}
|
||||
confirmText={t('automations.restore.confirm', 'Restore')}
|
||||
variant="warning"
|
||||
isLoading={restoreFlow.isPending || restoreAll.isPending}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -228,17 +297,9 @@ export default function Automations() {
|
||||
<div className="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
|
||||
<Bot className="h-6 w-6 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{t('automations.title', 'Automations')}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
'automations.subtitle',
|
||||
'Build powerful workflows to automate your business'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{t('automations.title', 'Automations')}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
@@ -250,12 +311,65 @@ export default function Automations() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Restore Default Flows dropdown */}
|
||||
<div className="relative flows-menu-container">
|
||||
<button
|
||||
onClick={() => setShowFlowsMenu(!showFlowsMenu)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t('automations.restoreDefaults', 'Restore Defaults')}</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{showFlowsMenu && (
|
||||
<div className="absolute right-0 mt-2 w-72 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50">
|
||||
<div className="p-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => {
|
||||
setConfirmRestore({ type: 'all', name: 'All Flows' });
|
||||
setShowFlowsMenu(false);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-left text-sm font-medium text-primary-600 dark:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded transition-colors"
|
||||
>
|
||||
{t('automations.restoreAll', 'Restore All Default Flows')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto p-2">
|
||||
{defaultFlows?.map((flow) => (
|
||||
<button
|
||||
key={flow.flow_type}
|
||||
onClick={() => {
|
||||
setConfirmRestore({ type: flow.flow_type, name: flow.display_name });
|
||||
setShowFlowsMenu(false);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors flex justify-between items-center"
|
||||
>
|
||||
<span className="text-gray-700 dark:text-gray-300">{flow.display_name}</span>
|
||||
{flow.is_modified && (
|
||||
<span className="text-xs text-orange-500 dark:text-orange-400">
|
||||
{t('automations.modified', 'Modified')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{(!defaultFlows || defaultFlows.length === 0) && (
|
||||
<p className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('automations.noDefaultFlows', 'No default flows available')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Refresh button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
initSentRef.current = false;
|
||||
setAuthenticated(false);
|
||||
setIframeReady(false);
|
||||
setRefreshKey((k) => k + 1);
|
||||
refetch();
|
||||
}}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
@@ -296,6 +410,7 @@ export default function Automations() {
|
||||
|
||||
{iframeSrc && (
|
||||
<iframe
|
||||
key={`activepieces-${isDark ? 'dark' : 'light'}-${refreshKey}`}
|
||||
ref={iframeRef}
|
||||
src={iframeSrc}
|
||||
className="w-full h-full border-0"
|
||||
|
||||
@@ -10,32 +10,15 @@ import {
|
||||
CheckCircle2,
|
||||
FileSignature,
|
||||
FileCheck,
|
||||
Scale
|
||||
Scale,
|
||||
Sparkles
|
||||
} from 'lucide-react';
|
||||
import CodeBlock from '../../components/marketing/CodeBlock';
|
||||
import WorkflowVisual from '../../components/marketing/WorkflowVisual';
|
||||
import CTASection from '../../components/marketing/CTASection';
|
||||
|
||||
const FeaturesPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const pluginExample = `# Custom Webhook Plugin
|
||||
import requests
|
||||
|
||||
def execute(context):
|
||||
event = context['event']
|
||||
|
||||
# Send data to external CRM
|
||||
response = requests.post(
|
||||
'https://api.crm.com/leads',
|
||||
json={
|
||||
'name': event.customer.name,
|
||||
'email': event.customer.email,
|
||||
'source': 'SmoothSchedule'
|
||||
}
|
||||
)
|
||||
|
||||
return response.status_code == 200`;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 min-h-screen pt-24">
|
||||
|
||||
@@ -55,7 +38,7 @@ def execute(context):
|
||||
<div className="grid lg:grid-cols-2 gap-16 items-center">
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 text-sm font-medium mb-6">
|
||||
<Zap className="w-4 h-4" />
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span>{t('marketing.features.automationEngine.badge')}</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-6">
|
||||
@@ -67,10 +50,10 @@ def execute(context):
|
||||
|
||||
<ul className="space-y-4">
|
||||
{[
|
||||
t('marketing.features.automationEngine.features.recurringJobs'),
|
||||
t('marketing.features.automationEngine.features.customLogic'),
|
||||
t('marketing.features.automationEngine.features.fullContext'),
|
||||
t('marketing.features.automationEngine.features.zeroInfrastructure')
|
||||
t('marketing.features.automationEngine.features.visualBuilder'),
|
||||
t('marketing.features.automationEngine.features.aiCopilot'),
|
||||
t('marketing.features.automationEngine.features.integrations'),
|
||||
t('marketing.features.automationEngine.features.templates')
|
||||
].map((item) => (
|
||||
<li key={item} className="flex items-center gap-3">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
@@ -82,7 +65,7 @@ def execute(context):
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-4 bg-purple-500/20 rounded-3xl blur-2xl" />
|
||||
<CodeBlock code={pluginExample} filename="webhook_plugin.py" />
|
||||
<WorkflowVisual variant="noshow" trigger="" actions={[]} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -113,7 +113,7 @@ const PlatformBusinesses: React.FC<PlatformBusinessesProps> = ({ onMasquerade })
|
||||
<button
|
||||
onClick={() => handleLoginAs(business)}
|
||||
className="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors"
|
||||
title={t('platform.masqueradeAs') + ' ' + business.owner.email}
|
||||
title={t('platform.masquerade.masqueradeAs') + ' ' + business.owner.email}
|
||||
>
|
||||
<Eye size={14} />
|
||||
{t('common.masquerade')}
|
||||
|
||||
861
frontend/src/pages/platform/PlatformStaffEmail.tsx
Normal file
@@ -0,0 +1,861 @@
|
||||
/**
|
||||
* Platform Staff Email Page
|
||||
*
|
||||
* Thunderbird-style email client for platform staff members.
|
||||
* Features multiple email accounts, folder tree, and three-pane layout.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Mail,
|
||||
Inbox,
|
||||
Send,
|
||||
FileEdit,
|
||||
Trash2,
|
||||
Archive,
|
||||
AlertCircle,
|
||||
Star,
|
||||
Tag,
|
||||
Folder,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
Search,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
MoreVertical,
|
||||
X,
|
||||
Settings,
|
||||
GripVertical,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useStaffEmailFolders,
|
||||
useStaffEmails,
|
||||
useStaffEmail,
|
||||
useStaffEmailLabels,
|
||||
useMarkAsRead,
|
||||
useMarkAsUnread,
|
||||
useStarEmail,
|
||||
useUnstarEmail,
|
||||
useArchiveEmail,
|
||||
useTrashEmail,
|
||||
useRestoreEmail,
|
||||
useSyncEmails,
|
||||
useFullSyncEmails,
|
||||
useUserEmailAddresses,
|
||||
staffEmailKeys,
|
||||
} from '../../hooks/useStaffEmail';
|
||||
import { StaffEmailFolder, StaffEmailListItem, StaffEmail, StaffEmailFolderType, StaffEmailFilters } from '../../types';
|
||||
import { UserEmailAddress } from '../../api/staffEmail';
|
||||
import EmailComposer from '../../components/email/EmailComposer';
|
||||
import EmailViewer from '../../components/email/EmailViewer';
|
||||
import toast from 'react-hot-toast';
|
||||
import { formatDistanceToNow, format } from 'date-fns';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useStaffEmailWebSocket } from '../../hooks/useStaffEmailWebSocket';
|
||||
|
||||
// Email Account Settings Modal with Drag & Drop
|
||||
interface AccountSettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
accounts: UserEmailAddress[];
|
||||
accountOrder: number[];
|
||||
onReorder: (newOrder: number[]) => void;
|
||||
}
|
||||
|
||||
const AccountSettingsModal: React.FC<AccountSettingsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
accounts,
|
||||
accountOrder,
|
||||
onReorder,
|
||||
}) => {
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [localOrder, setLocalOrder] = useState<number[]>(accountOrder);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLocalOrder(accountOrder);
|
||||
}, [accountOrder]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const orderedAccounts = localOrder
|
||||
.map(id => accounts.find(a => a.id === id))
|
||||
.filter((a): a is UserEmailAddress => a !== undefined);
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, index: number) => {
|
||||
setDraggedIndex(index);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex === null || draggedIndex === index) return;
|
||||
|
||||
const newOrder = [...localOrder];
|
||||
const [draggedId] = newOrder.splice(draggedIndex, 1);
|
||||
newOrder.splice(index, 0, draggedId);
|
||||
setLocalOrder(newOrder);
|
||||
setDraggedIndex(index);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedIndex(null);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onReorder(localOrder);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md mx-4">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Arrange Email Accounts
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
Drag and drop to reorder your email accounts in the sidebar.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{orderedAccounts.map((account, index) => (
|
||||
<div
|
||||
key={account.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg cursor-move transition-all ${
|
||||
draggedIndex === index ? 'opacity-50 scale-95' : ''
|
||||
}`}
|
||||
>
|
||||
<GripVertical size={18} className="text-gray-400" />
|
||||
<div
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: account.color || '#3b82f6' }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{account.display_name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{account.email_address}
|
||||
</div>
|
||||
</div>
|
||||
{account.is_default && (
|
||||
<span className="text-xs px-2 py-0.5 bg-brand-100 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400 rounded">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-4 py-2 text-sm bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
Save Order
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PlatformStaffEmail: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// UI state
|
||||
const [selectedAccountId, setSelectedAccountId] = useState<number | null>(null);
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<number | null>(null);
|
||||
const [selectedEmailId, setSelectedEmailId] = useState<number | null>(null);
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
const [replyToEmail, setReplyToEmail] = useState<StaffEmail | null>(null);
|
||||
const [forwardEmail, setForwardEmail] = useState<StaffEmail | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedEmails, setSelectedEmails] = useState<Set<number>>(new Set());
|
||||
const [expandedAccounts, setExpandedAccounts] = useState<Set<number>>(new Set());
|
||||
const [showAccountSettings, setShowAccountSettings] = useState(false);
|
||||
const [accountOrder, setAccountOrder] = useState<number[]>([]);
|
||||
|
||||
// Data queries
|
||||
const { data: emailAddresses = [], isLoading: addressesLoading } = useUserEmailAddresses();
|
||||
const { data: folders = [], isLoading: foldersLoading } = useStaffEmailFolders();
|
||||
const { data: labels = [] } = useStaffEmailLabels();
|
||||
|
||||
// Initialize account order and expanded state
|
||||
React.useEffect(() => {
|
||||
if (emailAddresses.length > 0 && accountOrder.length === 0) {
|
||||
const order = emailAddresses.map(a => a.id);
|
||||
setAccountOrder(order);
|
||||
// Expand all accounts by default
|
||||
setExpandedAccounts(new Set(order));
|
||||
// Select first account if none selected
|
||||
if (!selectedAccountId) {
|
||||
setSelectedAccountId(order[0]);
|
||||
}
|
||||
}
|
||||
}, [emailAddresses]);
|
||||
|
||||
// Build filters based on current state - memoized to prevent unnecessary refetches
|
||||
const filters: StaffEmailFilters = useMemo(() => {
|
||||
const f = {
|
||||
folderId: selectedFolderId || undefined,
|
||||
emailAddressId: selectedAccountId || undefined,
|
||||
search: searchQuery || undefined,
|
||||
};
|
||||
// Debug logging - remove after fixing folder filter issue
|
||||
console.log('[StaffEmail UI] Filters updated:', f);
|
||||
return f;
|
||||
}, [selectedFolderId, selectedAccountId, searchQuery]);
|
||||
|
||||
const {
|
||||
data: emailsData,
|
||||
isLoading: emailsLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useStaffEmails(filters);
|
||||
|
||||
const emails = emailsData?.pages.flatMap((page) => page.results) || [];
|
||||
const totalCount = emailsData?.pages[0]?.count || 0;
|
||||
|
||||
// Single email detail
|
||||
const { data: selectedEmail, isLoading: emailLoading } = useStaffEmail(selectedEmailId || undefined);
|
||||
|
||||
// Mutations
|
||||
const markAsRead = useMarkAsRead();
|
||||
const markAsUnread = useMarkAsUnread();
|
||||
const starEmail = useStarEmail();
|
||||
const unstarEmail = useUnstarEmail();
|
||||
const archiveEmail = useArchiveEmail();
|
||||
const trashEmail = useTrashEmail();
|
||||
const restoreEmail = useRestoreEmail();
|
||||
const syncEmails = useSyncEmails();
|
||||
const fullSyncEmails = useFullSyncEmails();
|
||||
|
||||
// WebSocket for real-time updates
|
||||
const { isConnected: wsConnected, isSyncing: wsIsSyncing } = useStaffEmailWebSocket({
|
||||
showToasts: true,
|
||||
onNewEmail: (data) => {
|
||||
console.log('WebSocket: New email received', data);
|
||||
},
|
||||
onSyncComplete: () => {
|
||||
console.log('WebSocket: Sync completed');
|
||||
},
|
||||
});
|
||||
|
||||
// Combined syncing state
|
||||
const isSyncing = syncEmails.isPending || fullSyncEmails.isPending || wsIsSyncing;
|
||||
|
||||
// Determine if currently viewing Trash folder
|
||||
const currentFolder = folders.find(f => f.id === selectedFolderId);
|
||||
const isInTrash = currentFolder?.folderType === 'TRASH';
|
||||
|
||||
// Auto-select inbox on first load
|
||||
React.useEffect(() => {
|
||||
if (!selectedFolderId && folders.length > 0 && selectedAccountId) {
|
||||
const inbox = folders.find((f) => f.folderType === 'INBOX');
|
||||
if (inbox) {
|
||||
setSelectedFolderId(inbox.id);
|
||||
}
|
||||
}
|
||||
}, [folders, selectedFolderId, selectedAccountId]);
|
||||
|
||||
// Mark as read when email is selected
|
||||
React.useEffect(() => {
|
||||
if (selectedEmail && !selectedEmail.isRead) {
|
||||
markAsRead.mutate(selectedEmail.id);
|
||||
}
|
||||
}, [selectedEmail?.id]);
|
||||
|
||||
// Handlers
|
||||
const handleGetMessages = async () => {
|
||||
try {
|
||||
await fullSyncEmails.mutateAsync();
|
||||
toast.success('Syncing all emails from server...');
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.error || 'Failed to sync emails');
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickSync = async () => {
|
||||
try {
|
||||
const result = await syncEmails.mutateAsync();
|
||||
toast.success(result.message || 'Emails synced');
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.error || 'Failed to sync emails');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAccountExpanded = (accountId: number) => {
|
||||
setExpandedAccounts(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(accountId)) {
|
||||
newSet.delete(accountId);
|
||||
} else {
|
||||
newSet.add(accountId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const handleFolderSelect = (folderId: number, accountId: number) => {
|
||||
// Debug logging - remove after fixing folder filter issue
|
||||
console.log('[StaffEmail UI] Folder selected:', { folderId, accountId });
|
||||
setSelectedFolderId(folderId);
|
||||
setSelectedAccountId(accountId);
|
||||
setSelectedEmailId(null);
|
||||
setSelectedEmails(new Set());
|
||||
};
|
||||
|
||||
const handleEmailSelect = (emailId: number) => {
|
||||
setSelectedEmailId(emailId);
|
||||
setIsComposing(false);
|
||||
setReplyToEmail(null);
|
||||
setForwardEmail(null);
|
||||
};
|
||||
|
||||
const handleCompose = () => {
|
||||
setIsComposing(true);
|
||||
setSelectedEmailId(null);
|
||||
setReplyToEmail(null);
|
||||
setForwardEmail(null);
|
||||
};
|
||||
|
||||
const handleReply = (email: StaffEmail, replyAll: boolean = false) => {
|
||||
setReplyToEmail(email);
|
||||
setIsComposing(true);
|
||||
setForwardEmail(null);
|
||||
};
|
||||
|
||||
const handleForward = (email: StaffEmail) => {
|
||||
setForwardEmail(email);
|
||||
setIsComposing(true);
|
||||
setReplyToEmail(null);
|
||||
};
|
||||
|
||||
const handleCloseComposer = () => {
|
||||
setIsComposing(false);
|
||||
setReplyToEmail(null);
|
||||
setForwardEmail(null);
|
||||
};
|
||||
|
||||
const handleStar = async (emailId: number, isStarred: boolean) => {
|
||||
try {
|
||||
if (isStarred) {
|
||||
await unstarEmail.mutateAsync(emailId);
|
||||
} else {
|
||||
await starEmail.mutateAsync(emailId);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to update star');
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async (emailId: number) => {
|
||||
try {
|
||||
await archiveEmail.mutateAsync(emailId);
|
||||
toast.success('Email archived');
|
||||
if (selectedEmailId === emailId) {
|
||||
setSelectedEmailId(null);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to archive email');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrash = async (emailId: number) => {
|
||||
try {
|
||||
await trashEmail.mutateAsync(emailId);
|
||||
toast.success('Email moved to trash');
|
||||
if (selectedEmailId === emailId) {
|
||||
setSelectedEmailId(null);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to move to trash');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async (emailId: number) => {
|
||||
try {
|
||||
await restoreEmail.mutateAsync(emailId);
|
||||
toast.success('Email restored');
|
||||
if (selectedEmailId === emailId) {
|
||||
setSelectedEmailId(null);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to restore email');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkRead = async (emailId: number) => {
|
||||
try {
|
||||
await markAsRead.mutateAsync(emailId);
|
||||
} catch (error) {
|
||||
toast.error('Failed to mark as read');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkUnread = async (emailId: number) => {
|
||||
try {
|
||||
await markAsUnread.mutateAsync(emailId);
|
||||
} catch (error) {
|
||||
toast.error('Failed to mark as unread');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailCheckbox = (emailId: number, checked: boolean) => {
|
||||
setSelectedEmails((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (checked) {
|
||||
newSet.add(emailId);
|
||||
} else {
|
||||
newSet.delete(emailId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAccountOrderChange = (newOrder: number[]) => {
|
||||
setAccountOrder(newOrder);
|
||||
// TODO: Persist to localStorage or backend
|
||||
localStorage.setItem('emailAccountOrder', JSON.stringify(newOrder));
|
||||
};
|
||||
|
||||
// Load saved order from localStorage
|
||||
React.useEffect(() => {
|
||||
const savedOrder = localStorage.getItem('emailAccountOrder');
|
||||
if (savedOrder) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedOrder);
|
||||
if (Array.isArray(parsed)) {
|
||||
setAccountOrder(parsed);
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getFolderIcon = (folderType: StaffEmailFolderType) => {
|
||||
switch (folderType) {
|
||||
case 'INBOX':
|
||||
return <Inbox size={16} />;
|
||||
case 'SENT':
|
||||
return <Send size={16} />;
|
||||
case 'DRAFTS':
|
||||
return <FileEdit size={16} />;
|
||||
case 'TRASH':
|
||||
return <Trash2 size={16} />;
|
||||
case 'ARCHIVE':
|
||||
return <Archive size={16} />;
|
||||
case 'SPAM':
|
||||
return <AlertCircle size={16} />;
|
||||
default:
|
||||
return <Folder size={16} />;
|
||||
}
|
||||
};
|
||||
|
||||
// Sort folders with a defined order: Inbox first, then others in logical order
|
||||
const folderSortOrder: Record<string, number> = {
|
||||
'INBOX': 0,
|
||||
'DRAFTS': 1,
|
||||
'SENT': 2,
|
||||
'ARCHIVE': 3,
|
||||
'SPAM': 4,
|
||||
'TRASH': 5,
|
||||
'CUSTOM': 6,
|
||||
};
|
||||
|
||||
const sortedFolders = [...folders].sort((a, b) => {
|
||||
const orderA = folderSortOrder[a.folderType] ?? 99;
|
||||
const orderB = folderSortOrder[b.folderType] ?? 99;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
const formatEmailDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return format(date, 'h:mm a');
|
||||
} else if (diffDays < 7) {
|
||||
return format(date, 'EEE');
|
||||
} else if (date.getFullYear() === now.getFullYear()) {
|
||||
return format(date, 'MMM d');
|
||||
} else {
|
||||
return format(date, 'MM/dd/yy');
|
||||
}
|
||||
};
|
||||
|
||||
// Order accounts based on saved order
|
||||
const orderedAccounts = accountOrder.length > 0
|
||||
? accountOrder
|
||||
.map(id => emailAddresses.find(a => a.id === id))
|
||||
.filter((a): a is UserEmailAddress => a !== undefined)
|
||||
: emailAddresses;
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-64px)] flex flex-col bg-gray-100 dark:bg-gray-900">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={handleGetMessages}
|
||||
disabled={isSyncing}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={16} className={isSyncing ? 'animate-spin' : ''} />
|
||||
Get Messages
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleCompose}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Write
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative w-64">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search emails..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowAccountSettings(true)}
|
||||
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
title="Account Settings"
|
||||
>
|
||||
<Settings size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main content - three panel layout */}
|
||||
<div className="flex-1 flex min-h-0">
|
||||
{/* Left sidebar - Accounts & Folders (Thunderbird-style) */}
|
||||
<div className="w-60 flex-shrink-0 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{addressesLoading || foldersLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 size={24} className="animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : orderedAccounts.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<Mail size={32} className="mx-auto mb-2 opacity-50" />
|
||||
<p>No email accounts assigned</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-2">
|
||||
{orderedAccounts.map((account) => (
|
||||
<div key={account.id}>
|
||||
{/* Account header */}
|
||||
<button
|
||||
onClick={() => toggleAccountExpanded(account.id)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
{expandedAccounts.has(account.id) ? (
|
||||
<ChevronDown size={16} className="text-gray-400 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight size={16} className="text-gray-400 flex-shrink-0" />
|
||||
)}
|
||||
<div
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: account.color || '#3b82f6' }}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white truncate flex-1 text-left">
|
||||
{account.display_name || account.email_address.split('@')[0]}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Account folders */}
|
||||
{expandedAccounts.has(account.id) && (
|
||||
<div className="ml-5 border-l border-gray-200 dark:border-gray-700">
|
||||
{sortedFolders.map((folder) => (
|
||||
<button
|
||||
key={`${account.id}-${folder.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleFolderSelect(folder.id, account.id);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-1.5 text-sm transition-colors ${
|
||||
selectedFolderId === folder.id && selectedAccountId === account.id
|
||||
? 'bg-brand-100 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{getFolderIcon(folder.folderType)}
|
||||
<span className="truncate">{folder.name}</span>
|
||||
</span>
|
||||
{folder.unreadCount > 0 && (
|
||||
<span className="text-xs font-semibold text-brand-600 dark:text-brand-400">
|
||||
{folder.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Labels section */}
|
||||
{labels.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 mx-3">
|
||||
<div className="px-0 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Labels
|
||||
</div>
|
||||
{labels.map((label) => (
|
||||
<button
|
||||
key={label.id}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: label.color }}
|
||||
/>
|
||||
<span className="truncate">{label.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center - Email list */}
|
||||
<div className="w-80 flex-shrink-0 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col">
|
||||
{/* List header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 dark:border-gray-600 text-brand-600 focus:ring-brand-500"
|
||||
checked={selectedEmails.size > 0 && selectedEmails.size === emails.length}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedEmails(new Set(emails.map(e => e.id)));
|
||||
} else {
|
||||
setSelectedEmails(new Set());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{totalCount} messages
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{emailsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={32} className="animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : emails.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<Mail size={48} className="mb-3 opacity-50" />
|
||||
<p>{t('staffEmail.noEmails', 'No emails')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{emails.map((email) => (
|
||||
<div
|
||||
key={email.id}
|
||||
onClick={() => handleEmailSelect(email.id)}
|
||||
className={`flex items-start gap-2 px-3 py-2 border-b border-gray-100 dark:border-gray-700 cursor-pointer transition-colors ${
|
||||
selectedEmailId === email.id
|
||||
? 'bg-brand-50 dark:bg-brand-900/20'
|
||||
: email.isRead
|
||||
? 'bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
: 'bg-blue-50/50 dark:bg-blue-900/10 hover:bg-blue-50 dark:hover:bg-blue-900/20'
|
||||
}`}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedEmails.has(email.id)}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEmailCheckbox(email.id, e.target.checked);
|
||||
}}
|
||||
className="mt-1 rounded border-gray-300 dark:border-gray-600 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
|
||||
{/* Star */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStar(email.id, email.isStarred);
|
||||
}}
|
||||
className="mt-0.5 flex-shrink-0"
|
||||
>
|
||||
<Star
|
||||
size={14}
|
||||
className={`transition-colors ${
|
||||
email.isStarred
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-gray-300 dark:text-gray-600 hover:text-yellow-400'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Email content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span
|
||||
className={`text-sm truncate ${
|
||||
email.isRead
|
||||
? 'text-gray-600 dark:text-gray-400'
|
||||
: 'font-semibold text-gray-900 dark:text-white'
|
||||
}`}
|
||||
>
|
||||
{email.fromName || email.fromAddress}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
|
||||
{formatEmailDate(email.emailDate)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`text-sm truncate ${
|
||||
email.isRead
|
||||
? 'text-gray-600 dark:text-gray-400'
|
||||
: 'font-medium text-gray-900 dark:text-white'
|
||||
}`}
|
||||
>
|
||||
{email.subject || '(No Subject)'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{email.snippet}
|
||||
</div>
|
||||
{/* Indicators */}
|
||||
{(email.hasAttachments || email.labels.length > 0) && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
{email.hasAttachments && (
|
||||
<span className="text-xs text-gray-400">📎</span>
|
||||
)}
|
||||
{email.labels.map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="text-xs px-1 py-0.5 rounded text-white"
|
||||
style={{ backgroundColor: label.color }}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Load more */}
|
||||
{hasNextPage && (
|
||||
<div className="p-3 text-center">
|
||||
<button
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
className="text-sm text-brand-600 hover:text-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Loading...
|
||||
</span>
|
||||
) : (
|
||||
'Load more'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel - Email viewer or Composer */}
|
||||
<div className="flex-1 flex flex-col min-w-0 bg-white dark:bg-gray-800">
|
||||
{isComposing ? (
|
||||
<EmailComposer
|
||||
replyTo={replyToEmail}
|
||||
forwardFrom={forwardEmail}
|
||||
onClose={handleCloseComposer}
|
||||
onSent={() => {
|
||||
handleCloseComposer();
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emails() });
|
||||
}}
|
||||
/>
|
||||
) : selectedEmail ? (
|
||||
<EmailViewer
|
||||
email={selectedEmail}
|
||||
isLoading={emailLoading}
|
||||
onReply={() => handleReply(selectedEmail)}
|
||||
onReplyAll={() => handleReply(selectedEmail, true)}
|
||||
onForward={() => handleForward(selectedEmail)}
|
||||
onArchive={() => handleArchive(selectedEmail.id)}
|
||||
onTrash={() => handleTrash(selectedEmail.id)}
|
||||
onStar={() => handleStar(selectedEmail.id, selectedEmail.isStarred)}
|
||||
onMarkRead={() => handleMarkRead(selectedEmail.id)}
|
||||
onMarkUnread={() => handleMarkUnread(selectedEmail.id)}
|
||||
onRestore={() => handleRestore(selectedEmail.id)}
|
||||
isInTrash={isInTrash}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800">
|
||||
<div className="text-center">
|
||||
<Mail size={64} className="mx-auto mb-4 opacity-30" />
|
||||
<p>{t('staffEmail.selectEmail', 'Select an email to read')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account Settings Modal */}
|
||||
<AccountSettingsModal
|
||||
isOpen={showAccountSettings}
|
||||
onClose={() => setShowAccountSettings(false)}
|
||||
accounts={emailAddresses}
|
||||
accountOrder={accountOrder}
|
||||
onReorder={handleAccountOrderChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlatformStaffEmail;
|
||||
@@ -104,7 +104,7 @@ const PlatformUsers: React.FC<PlatformUsersProps> = ({ onMasquerade }) => {
|
||||
disabled={u.is_superuser}
|
||||
title={u.is_superuser ? 'Cannot masquerade as superuser' : `Masquerade as ${u.name || u.username}`}
|
||||
>
|
||||
<Eye size={14} /> {t('platform.masquerade')}
|
||||
<Eye size={14} /> {t('platform.masquerade.label')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingUser(u)}
|
||||
|
||||
@@ -439,14 +439,14 @@ describe('PlatformUsers', () => {
|
||||
it('should show masquerade button for each user', () => {
|
||||
render(<PlatformUsers onMasquerade={mockOnMasquerade} />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('platform.masquerade')).toBeInTheDocument();
|
||||
expect(screen.getByText('platform.masquerade.label')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onMasquerade with user data when clicking masquerade button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlatformUsers onMasquerade={mockOnMasquerade} />, { wrapper: createWrapper() });
|
||||
|
||||
const masqueradeButton = screen.getByText('platform.masquerade');
|
||||
const masqueradeButton = screen.getByText('platform.masquerade.label');
|
||||
await user.click(masqueradeButton);
|
||||
|
||||
expect(mockOnMasquerade).toHaveBeenCalledWith({
|
||||
@@ -467,7 +467,7 @@ describe('PlatformUsers', () => {
|
||||
|
||||
render(<PlatformUsers onMasquerade={mockOnMasquerade} />, { wrapper: createWrapper() });
|
||||
|
||||
const masqueradeButton = screen.getByText('platform.masquerade');
|
||||
const masqueradeButton = screen.getByText('platform.masquerade.label');
|
||||
expect(masqueradeButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -831,4 +831,162 @@ export interface SystemEmailTemplateUpdate {
|
||||
subject_template: string;
|
||||
puck_data: Record<string, any>;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
// --- Staff Email Types (Platform Staff Inbox) ---
|
||||
|
||||
export type StaffEmailStatus = 'RECEIVED' | 'SENT' | 'DRAFT' | 'SENDING' | 'FAILED';
|
||||
|
||||
export type StaffEmailFolderType =
|
||||
| 'INBOX'
|
||||
| 'SENT'
|
||||
| 'DRAFTS'
|
||||
| 'TRASH'
|
||||
| 'ARCHIVE'
|
||||
| 'SPAM'
|
||||
| 'CUSTOM';
|
||||
|
||||
export interface StaffEmailFolder {
|
||||
id: number;
|
||||
owner: number;
|
||||
name: string;
|
||||
folderType: StaffEmailFolderType;
|
||||
emailCount: number;
|
||||
unreadCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface StaffEmailAttachment {
|
||||
id: number;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
url: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface StaffEmailLabel {
|
||||
id: number;
|
||||
owner: number;
|
||||
name: string;
|
||||
color: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface StaffEmailListItem {
|
||||
id: number;
|
||||
folder: number;
|
||||
fromAddress: string;
|
||||
fromName: string;
|
||||
toAddresses: string[];
|
||||
subject: string;
|
||||
snippet: string;
|
||||
status: StaffEmailStatus;
|
||||
isRead: boolean;
|
||||
isStarred: boolean;
|
||||
isImportant: boolean;
|
||||
hasAttachments: boolean;
|
||||
attachmentCount: number;
|
||||
threadId: string | null;
|
||||
emailDate: string;
|
||||
createdAt: string;
|
||||
labels: StaffEmailLabel[];
|
||||
}
|
||||
|
||||
export interface StaffEmail extends StaffEmailListItem {
|
||||
owner: number;
|
||||
emailAddress: number;
|
||||
messageId: string;
|
||||
inReplyTo: string | null;
|
||||
references: string;
|
||||
ccAddresses: string[];
|
||||
bccAddresses: string[];
|
||||
bodyText: string;
|
||||
bodyHtml: string;
|
||||
isAnswered: boolean;
|
||||
isPermanentlyDeleted: boolean;
|
||||
deletedAt: string | null;
|
||||
attachments: StaffEmailAttachment[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface StaffEmailThread {
|
||||
threadId: string;
|
||||
emails: StaffEmail[];
|
||||
subject: string;
|
||||
participants: string[];
|
||||
lastEmailDate: string;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export interface StaffEmailFilters {
|
||||
folderId?: number;
|
||||
emailAddressId?: number;
|
||||
isRead?: boolean;
|
||||
isStarred?: boolean;
|
||||
isImportant?: boolean;
|
||||
labelId?: number;
|
||||
search?: string;
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
}
|
||||
|
||||
export interface StaffEmailCreateDraft {
|
||||
emailAddressId: number;
|
||||
toAddresses: string[];
|
||||
ccAddresses?: string[];
|
||||
bccAddresses?: string[];
|
||||
subject: string;
|
||||
bodyText?: string;
|
||||
bodyHtml?: string;
|
||||
inReplyTo?: number;
|
||||
threadId?: string;
|
||||
}
|
||||
|
||||
export interface StaffEmailSend {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface StaffEmailMove {
|
||||
emailIds: number[];
|
||||
folderId: number;
|
||||
}
|
||||
|
||||
export interface StaffEmailBulkAction {
|
||||
emailIds: number[];
|
||||
action: 'mark_read' | 'mark_unread' | 'star' | 'unstar' | 'archive' | 'trash' | 'restore' | 'delete';
|
||||
}
|
||||
|
||||
export interface StaffEmailReply {
|
||||
bodyText?: string;
|
||||
bodyHtml?: string;
|
||||
replyAll?: boolean;
|
||||
}
|
||||
|
||||
export interface StaffEmailForward {
|
||||
toAddresses: string[];
|
||||
ccAddresses?: string[];
|
||||
bodyText?: string;
|
||||
bodyHtml?: string;
|
||||
}
|
||||
|
||||
export interface EmailContactSuggestion {
|
||||
id: number;
|
||||
owner: number;
|
||||
email: string;
|
||||
name: string;
|
||||
useCount: number;
|
||||
lastUsedAt: string;
|
||||
}
|
||||
|
||||
export interface StaffEmailStats {
|
||||
totalEmails: number;
|
||||
unreadCount: number;
|
||||
sentToday: number;
|
||||
folders: {
|
||||
name: string;
|
||||
count: number;
|
||||
unread: number;
|
||||
}[];
|
||||
}
|
||||
BIN
frontend/step1-login-page.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
frontend/step2-filled-form.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
frontend/step3-after-login.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
frontend/step4-email-page.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"48355e96022c09342254-afc6f80c7b0d571cf29c",
|
||||
"48355e96022c09342254-34a31faf9801d1748670",
|
||||
"48355e96022c09342254-b1931f7c2caec15d8c31"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e3]:
|
||||
- generic [ref=e7]:
|
||||
- link "Smooth Schedule" [ref=e9] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e10]
|
||||
- generic [ref=e16]: Smooth Schedule
|
||||
- generic [ref=e17]:
|
||||
- heading "Orchestrate your business with precision." [level=1] [ref=e18]
|
||||
- paragraph [ref=e19]: The all-in-one scheduling platform for businesses of all sizes. Manage resources, staff, and bookings effortlessly.
|
||||
- generic [ref=e24]: © 2025 Smooth Schedule Inc.
|
||||
- generic [ref=e26]:
|
||||
- generic [ref=e27]:
|
||||
- heading "Welcome back" [level=2] [ref=e28]
|
||||
- paragraph [ref=e29]: Please enter your email and password to sign in.
|
||||
- generic [ref=e31]:
|
||||
- img [ref=e33]
|
||||
- generic [ref=e35]:
|
||||
- heading "Authentication Error" [level=3] [ref=e36]
|
||||
- generic [ref=e37]: Invalid credentials
|
||||
- generic [ref=e38]:
|
||||
- generic [ref=e39]:
|
||||
- generic [ref=e40]:
|
||||
- generic [ref=e41]: Email
|
||||
- generic [ref=e42]:
|
||||
- generic:
|
||||
- img
|
||||
- textbox "Email" [ref=e43]:
|
||||
- /placeholder: Enter your email
|
||||
- text: owner@demo.com
|
||||
- generic [ref=e44]:
|
||||
- generic [ref=e45]: Password
|
||||
- generic [ref=e46]:
|
||||
- generic:
|
||||
- img
|
||||
- textbox "Password" [ref=e47]:
|
||||
- /placeholder: ••••••••
|
||||
- text: demopass123
|
||||
- button "Sign in" [ref=e48]:
|
||||
- generic [ref=e49]:
|
||||
- text: Sign in
|
||||
- img [ref=e50]
|
||||
- generic [ref=e57]: Or continue with
|
||||
- button "🇺🇸 English" [ref=e60]:
|
||||
- img [ref=e61]
|
||||
- generic [ref=e64]: 🇺🇸
|
||||
- generic [ref=e65]: English
|
||||
- img [ref=e66]
|
||||
- generic [ref=e68]:
|
||||
- heading "🔓 Quick Login (Dev Only)" [level=3] [ref=e70]:
|
||||
- generic [ref=e71]: 🔓
|
||||
- generic [ref=e72]: Quick Login (Dev Only)
|
||||
- generic [ref=e73]:
|
||||
- button "Business Owner TENANT_OWNER" [ref=e74]:
|
||||
- generic [ref=e75]:
|
||||
- generic [ref=e76]: Business Owner
|
||||
- generic [ref=e77]: TENANT_OWNER
|
||||
- button "Staff (Full Access) TENANT_STAFF" [ref=e78]:
|
||||
- generic [ref=e79]:
|
||||
- generic [ref=e80]: Staff (Full Access)
|
||||
- generic [ref=e81]: TENANT_STAFF
|
||||
- button "Staff (Limited) TENANT_STAFF" [ref=e82]:
|
||||
- generic [ref=e83]:
|
||||
- generic [ref=e84]: Staff (Limited)
|
||||
- generic [ref=e85]: TENANT_STAFF
|
||||
- generic [ref=e86]:
|
||||
- text: "Password for all:"
|
||||
- code [ref=e87]: test123
|
||||
```
|
||||
|
After Width: | Height: | Size: 446 KiB |
60
frontend/tests/e2e/email-debug.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('debug email page', async ({ page }) => {
|
||||
// Enable console logging
|
||||
page.on('console', msg => console.log('CONSOLE:', msg.type(), msg.text()));
|
||||
page.on('pageerror', err => console.log('PAGE ERROR:', err.message));
|
||||
|
||||
// Track network requests
|
||||
page.on('request', req => {
|
||||
if (req.url().includes('staff-email') || req.url().includes('email_addresses')) {
|
||||
console.log('REQUEST:', req.method(), req.url());
|
||||
}
|
||||
});
|
||||
page.on('response', res => {
|
||||
if (res.url().includes('staff-email') || res.url().includes('email_addresses')) {
|
||||
console.log('RESPONSE:', res.status(), res.url());
|
||||
}
|
||||
});
|
||||
|
||||
// Step 1: Go to login page
|
||||
console.log('Step 1: Going to login page...');
|
||||
await page.goto('http://platform.lvh.me:5173/platform/login');
|
||||
await page.screenshot({ path: 'step1-login-page.png' });
|
||||
console.log('Login page URL:', page.url());
|
||||
|
||||
// Step 2: Fill login form
|
||||
console.log('Step 2: Filling login form...');
|
||||
await page.waitForSelector('input[type="email"], input[name="email"], input[placeholder*="email" i]', { timeout: 10000 });
|
||||
await page.fill('input[type="email"], input[name="email"], input[placeholder*="email" i]', 'poduck@gmail.com');
|
||||
await page.fill('input[type="password"], input[name="password"]', 'starry12');
|
||||
await page.screenshot({ path: 'step2-filled-form.png' });
|
||||
|
||||
// Step 3: Submit login
|
||||
console.log('Step 3: Submitting login...');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForTimeout(3000);
|
||||
await page.screenshot({ path: 'step3-after-login.png' });
|
||||
console.log('After login URL:', page.url());
|
||||
|
||||
// Step 4: Navigate to email page
|
||||
console.log('Step 4: Navigating to email page...');
|
||||
await page.goto('http://platform.lvh.me:5173/platform/email');
|
||||
await page.waitForTimeout(5000);
|
||||
await page.screenshot({ path: 'step4-email-page.png' });
|
||||
console.log('Email page URL:', page.url());
|
||||
|
||||
// Step 5: Check page content
|
||||
console.log('Step 5: Checking page content...');
|
||||
const html = await page.content();
|
||||
console.log('Page HTML length:', html.length);
|
||||
console.log('Contains "Get Messages":', html.includes('Get Messages'));
|
||||
console.log('Contains "No email accounts":', html.includes('No email accounts'));
|
||||
console.log('Contains "timm":', html.includes('timm'));
|
||||
|
||||
// Check for any visible text
|
||||
const bodyText = await page.locator('body').textContent();
|
||||
console.log('Body text (first 1000 chars):', bodyText?.substring(0, 1000));
|
||||
});
|
||||
115
scripts/build-activepieces.sh
Executable file
@@ -0,0 +1,115 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================
|
||||
# Build and Deploy Activepieces Docker Image
|
||||
#
|
||||
# This script builds the Activepieces image locally and optionally
|
||||
# transfers it to the production server.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/build-activepieces.sh # Build only
|
||||
# ./scripts/build-activepieces.sh deploy # Build and deploy to server
|
||||
# ./scripts/build-activepieces.sh deploy user@server # Custom server
|
||||
# ==============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
AP_DIR="$PROJECT_ROOT/activepieces-fork"
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
print_status() { echo -e "${GREEN}>>> $1${NC}"; }
|
||||
print_warning() { echo -e "${YELLOW}>>> $1${NC}"; }
|
||||
print_error() { echo -e "${RED}>>> $1${NC}"; }
|
||||
|
||||
# Parse arguments
|
||||
ACTION="${1:-build}"
|
||||
SERVER="${2:-poduck@smoothschedule.com}"
|
||||
|
||||
IMAGE_NAME="smoothschedule_production_activepieces"
|
||||
TEMP_FILE="/tmp/activepieces-image.tar.gz"
|
||||
|
||||
echo ""
|
||||
echo "==========================================="
|
||||
echo " Activepieces Docker Image Builder"
|
||||
echo "==========================================="
|
||||
echo ""
|
||||
|
||||
# Check we have the activepieces-fork directory
|
||||
if [ ! -d "$AP_DIR" ]; then
|
||||
print_error "activepieces-fork directory not found at: $AP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ==============================================================================
|
||||
# Build the image
|
||||
# ==============================================================================
|
||||
print_status "Building Activepieces Docker image..."
|
||||
print_warning "This may take 5-10 minutes and requires 4GB+ RAM"
|
||||
|
||||
cd "$AP_DIR"
|
||||
|
||||
# Build with progress output
|
||||
docker build \
|
||||
--progress=plain \
|
||||
-t "$IMAGE_NAME" \
|
||||
.
|
||||
|
||||
print_status "Build complete!"
|
||||
|
||||
# Show image size
|
||||
docker images "$IMAGE_NAME" --format "Image size: {{.Size}}"
|
||||
|
||||
# ==============================================================================
|
||||
# Deploy to server (if requested)
|
||||
# ==============================================================================
|
||||
if [ "$ACTION" = "deploy" ]; then
|
||||
echo ""
|
||||
print_status "Preparing to deploy to: $SERVER"
|
||||
|
||||
# Save image to compressed archive
|
||||
print_status "Saving image to $TEMP_FILE..."
|
||||
docker save "$IMAGE_NAME" | gzip > "$TEMP_FILE"
|
||||
|
||||
# Show file size
|
||||
ls -lh "$TEMP_FILE" | awk '{print "Archive size: " $5}'
|
||||
|
||||
# Transfer to server
|
||||
print_status "Transferring to server (this may take a few minutes)..."
|
||||
scp "$TEMP_FILE" "$SERVER:/tmp/activepieces-image.tar.gz"
|
||||
|
||||
# Load on server
|
||||
print_status "Loading image on server..."
|
||||
ssh "$SERVER" "gunzip -c /tmp/activepieces-image.tar.gz | docker load && rm /tmp/activepieces-image.tar.gz"
|
||||
|
||||
# Restart Activepieces on server
|
||||
print_status "Restarting Activepieces on server..."
|
||||
ssh "$SERVER" "cd ~/smoothschedule/smoothschedule && docker compose -f docker-compose.production.yml up -d activepieces"
|
||||
|
||||
# Clean up local temp file
|
||||
rm -f "$TEMP_FILE"
|
||||
|
||||
print_status "Deployment complete!"
|
||||
echo ""
|
||||
echo "Activepieces should now be running with the new image."
|
||||
echo "Check status with:"
|
||||
echo " ssh $SERVER 'cd ~/smoothschedule/smoothschedule && docker compose -f docker-compose.production.yml ps activepieces'"
|
||||
echo ""
|
||||
else
|
||||
echo ""
|
||||
print_status "Image built successfully: $IMAGE_NAME"
|
||||
echo ""
|
||||
echo "To deploy to production, run:"
|
||||
echo " $0 deploy"
|
||||
echo ""
|
||||
echo "Or manually:"
|
||||
echo " docker save $IMAGE_NAME | gzip > /tmp/ap.tar.gz"
|
||||
echo " scp /tmp/ap.tar.gz $SERVER:/tmp/"
|
||||
echo " ssh $SERVER 'gunzip -c /tmp/ap.tar.gz | docker load'"
|
||||
echo ""
|
||||
fi
|
||||