Compare commits
41 Commits
feature/ac
...
da508da398
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da508da398 | ||
|
|
2cf156ad36 | ||
|
|
416cd7059b | ||
|
|
8391ecbf88 | ||
|
|
464726ee3e | ||
|
|
d8d3a4e846 | ||
|
|
f71218cc77 | ||
|
|
e4668f81c5 | ||
|
|
3eb1c303e5 | ||
|
|
fa7ecf16b1 | ||
|
|
2bfa01e0d4 | ||
|
|
f1b1f18bc5 | ||
|
|
28d6cee207 | ||
|
|
dd24eede87 | ||
|
|
fb97091cb9 | ||
|
|
f119c6303c | ||
|
|
cfdbc1f42c | ||
|
|
2f22d80b9e | ||
|
|
961dbf0a96 | ||
|
|
33e07fe64f | ||
|
|
18eeda62e8 | ||
|
|
7b380fa903 | ||
|
|
ac3115a5a1 | ||
|
|
99f8271003 | ||
|
|
8564b1deba | ||
|
|
7baf110235 | ||
|
|
c88b77a804 | ||
|
|
6d7d1607b2 | ||
|
|
0f47f118f7 | ||
|
|
f8d8419622 | ||
|
|
2a33e4cf57 | ||
|
|
ab87a4b621 | ||
|
|
07f49cb457 | ||
|
|
e93a7a305d | ||
|
|
a8d176b4ec | ||
|
|
30701cddfb | ||
|
|
fe7b93c7ff | ||
|
|
2d382fd1d4 | ||
|
|
b2c6979338 | ||
|
|
2417bb8313 | ||
|
|
f3e1b8f8bf |
210
CLAUDE.md
210
CLAUDE.md
@@ -539,6 +539,216 @@ docker compose -f docker-compose.local.yml logs django --tail=100
|
|||||||
curl -s "http://lvh.me:8000/api/resources/" | jq
|
curl -s "http://lvh.me:8000/api/resources/" | jq
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Database Initialization & Seeding
|
||||||
|
|
||||||
|
When resetting the database or setting up a fresh environment, follow these steps in order.
|
||||||
|
|
||||||
|
### Step 1: Reset Database (if needed)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/poduck/Desktop/smoothschedule/smoothschedule
|
||||||
|
|
||||||
|
# Stop all containers and remove volumes (DESTRUCTIVE - removes all data)
|
||||||
|
docker compose -f docker-compose.local.yml down -v
|
||||||
|
|
||||||
|
# Start services fresh
|
||||||
|
docker compose -f docker-compose.local.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Create Activepieces Database
|
||||||
|
|
||||||
|
The Activepieces service requires its own database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.local.yml exec postgres psql -U FDuVLuQjfpYzGizTmaTavMcaimqpCMRM -d postgres -c "CREATE DATABASE activepieces;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Initialize Activepieces Platform
|
||||||
|
|
||||||
|
Create the initial Activepieces admin user (this auto-creates the platform):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8090/api/v1/authentication/sign-up \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"admin@smoothschedule.com","password":"Admin123!","firstName":"Admin","lastName":"User","newsLetter":false,"trackEvents":false}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**IMPORTANT:** Update `.envs/.local/.activepieces` with the returned IDs:
|
||||||
|
- `AP_PLATFORM_ID` - from the `platformId` field in response
|
||||||
|
- `AP_DEFAULT_PROJECT_ID` - from the `projectId` field in response
|
||||||
|
|
||||||
|
Then restart containers to pick up new IDs:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.local.yml restart activepieces django
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Run Django Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.local.yml exec django python manage.py migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Seed Billing Catalog
|
||||||
|
|
||||||
|
Creates subscription plans (free, starter, growth, pro, enterprise):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.local.yml exec django python manage.py billing_seed_catalog
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Seed Demo Data
|
||||||
|
|
||||||
|
Run the reseed_demo command to create the demo tenant with sample data:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.local.yml exec django python manage.py reseed_demo
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates:
|
||||||
|
- **Tenant:** "Serenity Salon & Spa" (subdomain: `demo`)
|
||||||
|
- **Pro subscription** with pink theme branding
|
||||||
|
- **Staff:** 6 stylists/therapists with salon/spa themed names
|
||||||
|
- **Services:** 12 salon/spa services (haircuts, coloring, massages, facials, etc.)
|
||||||
|
- **Resources:** 4 treatment rooms
|
||||||
|
- **Customers:** 20 sample customers
|
||||||
|
- **Appointments:** 100 appointments respecting business hours (9am-5pm Mon-Fri)
|
||||||
|
|
||||||
|
### Step 7: Create Platform Test Users
|
||||||
|
|
||||||
|
The DevQuickLogin component expects these users with password `test123`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.local.yml exec django python manage.py shell -c "
|
||||||
|
from smoothschedule.identity.users.models import User
|
||||||
|
|
||||||
|
# Platform users (public schema)
|
||||||
|
users_data = [
|
||||||
|
('superuser@platform.com', 'Super User', 'superuser', True, True),
|
||||||
|
('manager@platform.com', 'Platform Manager', 'platform_manager', True, False),
|
||||||
|
('sales@platform.com', 'Sales Rep', 'platform_sales', True, False),
|
||||||
|
('support@platform.com', 'Support Agent', 'platform_support', True, False),
|
||||||
|
]
|
||||||
|
|
||||||
|
for email, name, role, is_staff, is_superuser in users_data:
|
||||||
|
user, created = User.objects.get_or_create(
|
||||||
|
email=email,
|
||||||
|
defaults={
|
||||||
|
'username': email,
|
||||||
|
'name': name,
|
||||||
|
'role': role,
|
||||||
|
'is_staff': is_staff,
|
||||||
|
'is_superuser': is_superuser,
|
||||||
|
'is_active': True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
user.set_password('test123')
|
||||||
|
user.save()
|
||||||
|
print(f'Created: {email}')
|
||||||
|
else:
|
||||||
|
print(f'Exists: {email}')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 8: Seed Automation Templates
|
||||||
|
|
||||||
|
Seeds 8 automation templates to the Activepieces template gallery:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.local.yml exec django python manage.py seed_automation_templates
|
||||||
|
```
|
||||||
|
|
||||||
|
Templates created:
|
||||||
|
- Appointment Confirmation Email
|
||||||
|
- SMS Appointment Reminder
|
||||||
|
- Staff Notification - New Booking
|
||||||
|
- Cancellation Confirmation Email
|
||||||
|
- Thank You + Google Review Request
|
||||||
|
- Win-back Email Campaign
|
||||||
|
- Google Calendar Sync
|
||||||
|
- Webhook Notification
|
||||||
|
|
||||||
|
### Step 9: Provision Activepieces Connections
|
||||||
|
|
||||||
|
Creates SmoothSchedule connections in Activepieces for all tenants:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.local.yml exec django python manage.py provision_ap_connections --force
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 10: Provision Default Flows
|
||||||
|
|
||||||
|
Creates the default email automation flows for each tenant:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.local.yml exec django python manage.py shell -c "
|
||||||
|
from smoothschedule.identity.core.models import Tenant
|
||||||
|
from smoothschedule.identity.core.signals import _provision_default_flows_for_tenant
|
||||||
|
|
||||||
|
for tenant in Tenant.objects.exclude(schema_name='public'):
|
||||||
|
print(f'Provisioning default flows for: {tenant.name}')
|
||||||
|
_provision_default_flows_for_tenant(tenant.id)
|
||||||
|
print('Done!')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Default flows created per tenant:
|
||||||
|
- `appointment_confirmation` - Confirmation email when appointment is booked
|
||||||
|
- `appointment_reminder` - Reminder based on service settings
|
||||||
|
- `thank_you` - Thank you email after final payment
|
||||||
|
- `payment_deposit` - Deposit payment confirmation
|
||||||
|
- `payment_final` - Final payment confirmation
|
||||||
|
|
||||||
|
### Quick Reference: All Seed Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/poduck/Desktop/smoothschedule/smoothschedule
|
||||||
|
|
||||||
|
# 1. Create Activepieces database
|
||||||
|
docker compose -f docker-compose.local.yml exec postgres psql -U FDuVLuQjfpYzGizTmaTavMcaimqpCMRM -d postgres -c "CREATE DATABASE activepieces;"
|
||||||
|
|
||||||
|
# 2. Run migrations
|
||||||
|
docker compose -f docker-compose.local.yml exec django python manage.py migrate
|
||||||
|
|
||||||
|
# 3. Seed billing plans
|
||||||
|
docker compose -f docker-compose.local.yml exec django python manage.py billing_seed_catalog
|
||||||
|
|
||||||
|
# 4. Seed demo tenant with data
|
||||||
|
docker compose -f docker-compose.local.yml exec django python manage.py reseed_demo
|
||||||
|
|
||||||
|
# 5. Seed automation templates
|
||||||
|
docker compose -f docker-compose.local.yml exec django python manage.py seed_automation_templates
|
||||||
|
|
||||||
|
# 6. Provision Activepieces connections
|
||||||
|
docker compose -f docker-compose.local.yml exec django python manage.py provision_ap_connections --force
|
||||||
|
|
||||||
|
# 7. Provision default flows (run in Django shell - see Step 10 above)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verifying Setup
|
||||||
|
|
||||||
|
After seeding, verify everything is working:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check all services are running
|
||||||
|
docker compose -f docker-compose.local.yml ps
|
||||||
|
|
||||||
|
# Check Activepieces health
|
||||||
|
curl -s http://localhost:8090/api/v1/health
|
||||||
|
|
||||||
|
# Test login
|
||||||
|
curl -s http://api.lvh.me:8000/auth/login/ -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"owner@demo.com","password":"test123"}'
|
||||||
|
|
||||||
|
# Check flows exist in Activepieces
|
||||||
|
docker compose -f docker-compose.local.yml exec postgres psql -U FDuVLuQjfpYzGizTmaTavMcaimqpCMRM -d activepieces -c "SELECT id, status FROM flow;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Access the application at:
|
||||||
|
- **Demo tenant:** http://demo.lvh.me:5173
|
||||||
|
- **Platform:** http://platform.lvh.me:5173
|
||||||
|
|
||||||
## Git Branch
|
## Git Branch
|
||||||
Currently on: `feature/platform-superuser-ui`
|
Currently on: `feature/platform-superuser-ui`
|
||||||
Main branch: `main`
|
Main branch: `main`
|
||||||
|
|||||||
712
DEPLOYMENT.md
712
DEPLOYMENT.md
@@ -1,322 +1,381 @@
|
|||||||
# SmoothSchedule Production Deployment Guide
|
# 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
|
## Prerequisites
|
||||||
|
|
||||||
### Server Requirements
|
### Server Requirements
|
||||||
- Ubuntu/Debian Linux server
|
- Ubuntu 20.04+ or Debian 11+
|
||||||
- Minimum 2GB RAM, 20GB disk space
|
- 4GB RAM minimum (2GB works but cannot build Activepieces image)
|
||||||
- Docker and Docker Compose installed
|
- 40GB disk space
|
||||||
- Domain name pointed to server IP: `smoothschedule.com`
|
- Docker and Docker Compose v2 installed
|
||||||
- DNS configured with wildcard subdomain: `*.smoothschedule.com`
|
- 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
|
### Required Accounts/Services
|
||||||
- [x] DigitalOcean Spaces (already configured)
|
- DigitalOcean Spaces (for static/media files)
|
||||||
- Access Key: DO801P4R8QXYMY4CE8WZ
|
- Stripe (for payments)
|
||||||
- Bucket: smoothschedule
|
- Twilio (for SMS/phone features)
|
||||||
- Region: nyc3
|
- OpenAI API (optional, for Activepieces AI copilot)
|
||||||
- [ ] Email service (optional - Mailgun or SMTP)
|
|
||||||
- [ ] Sentry (optional - error tracking)
|
|
||||||
|
|
||||||
## Pre-Deployment Checklist
|
## Quick Reference
|
||||||
|
|
||||||
### 1. DigitalOcean Spaces Setup
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create the bucket (if not already created)
|
# Regular deployment (after initial setup)
|
||||||
aws --profile do-tor1 s3 mb s3://smoothschedule
|
./deploy.sh
|
||||||
|
|
||||||
# Set bucket to public-read for static/media files
|
# Deploy with Activepieces image rebuild
|
||||||
aws --profile do-tor1 s3api put-bucket-acl \
|
./deploy.sh --deploy-ap
|
||||||
--bucket smoothschedule \
|
|
||||||
--acl public-read
|
|
||||||
|
|
||||||
# Configure CORS (for frontend uploads)
|
# Deploy specific services only
|
||||||
cat > cors.json <<EOF
|
./deploy.sh django nginx
|
||||||
{
|
|
||||||
"CORSRules": [
|
|
||||||
{
|
|
||||||
"AllowedOrigins": ["https://smoothschedule.com", "https://*.smoothschedule.com"],
|
|
||||||
"AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
|
|
||||||
"AllowedHeaders": ["*"],
|
|
||||||
"MaxAgeSeconds": 3000
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
aws --profile do-tor1 s3api put-bucket-cors \
|
# Skip migrations (config changes only)
|
||||||
--bucket smoothschedule \
|
./deploy.sh --no-migrate
|
||||||
--cors-configuration file://cors.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. DNS Configuration
|
## Initial Server Setup (First Time Only)
|
||||||
|
|
||||||
Configure these DNS records at your domain registrar:
|
### 1. Server Preparation
|
||||||
|
|
||||||
```
|
|
||||||
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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# SSH into production server
|
# SSH into production server
|
||||||
ssh poduck@smoothschedule.com
|
ssh your-user@your-server
|
||||||
|
|
||||||
# Install Docker (if not already installed)
|
# Install Docker (if not already installed)
|
||||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||||
sudo sh get-docker.sh
|
sudo sh get-docker.sh
|
||||||
sudo usermod -aG docker $USER
|
sudo usermod -aG docker $USER
|
||||||
|
|
||||||
# Install Docker Compose
|
# Logout and login again for group changes
|
||||||
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
|
|
||||||
exit
|
exit
|
||||||
ssh poduck@smoothschedule.com
|
ssh your-user@your-server
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2: Deploy Backend (Django)
|
### 2. Clone Repository
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create deployment directory
|
git clone https://your-repo-url ~/smoothschedule
|
||||||
mkdir -p ~/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
|
cd ~/smoothschedule
|
||||||
|
./scripts/build-activepieces.sh deploy
|
||||||
# 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 3: Database Initialization
|
Or manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run migrations
|
cd activepieces-fork
|
||||||
docker compose -f docker-compose.production.yml exec django python manage.py migrate
|
docker build -t smoothschedule_production_activepieces .
|
||||||
|
docker save smoothschedule_production_activepieces | gzip > /tmp/ap.tar.gz
|
||||||
# Create public schema (for multi-tenancy)
|
scp /tmp/ap.tar.gz your-user@your-server:/tmp/
|
||||||
docker compose -f docker-compose.production.yml exec django python manage.py migrate_schemas --shared
|
ssh your-user@your-server 'gunzip -c /tmp/ap.tar.gz | docker load'
|
||||||
|
|
||||||
# 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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
|
```bash
|
||||||
# Access Django shell
|
|
||||||
docker compose -f docker-compose.production.yml exec django python manage.py shell
|
docker compose -f docker-compose.production.yml exec django python manage.py shell
|
||||||
|
|
||||||
# In the shell, create your first business tenant:
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from core.models import Business
|
from smoothschedule.identity.core.models import Tenant, Domain
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
|
|
||||||
User = get_user_model()
|
# Create tenant
|
||||||
|
tenant = Tenant.objects.create(
|
||||||
# Create a business
|
|
||||||
business = Business.objects.create(
|
|
||||||
name="Demo Business",
|
name="Demo Business",
|
||||||
subdomain="demo",
|
subdomain="demo",
|
||||||
schema_name="demo",
|
schema_name="demo"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify it was created
|
# Create domain
|
||||||
print(f"Created business: {business.name} at {business.subdomain}.smoothschedule.com")
|
Domain.objects.create(
|
||||||
|
tenant=tenant,
|
||||||
# Create a business owner
|
domain="demo.yourdomain.com",
|
||||||
owner = User.objects.create_user(
|
is_primary=True
|
||||||
username="demo_owner",
|
|
||||||
email="owner@demo.com",
|
|
||||||
password="your_password_here",
|
|
||||||
role="owner",
|
|
||||||
business_subdomain="demo"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"Created owner: {owner.username}")
|
|
||||||
exit()
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 5: Deploy Frontend
|
### 9. Provision Activepieces Connection
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# On your local machine
|
docker compose -f docker-compose.production.yml exec django \
|
||||||
cd /home/poduck/Desktop/smoothschedule2/frontend
|
python manage.py provision_ap_connections --tenant demo
|
||||||
|
|
||||||
# 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Option A: Serve via Django (simpler)**
|
### 10. Verify Deployment
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check all containers are running
|
# Check all containers are running
|
||||||
docker compose -f docker-compose.production.yml ps
|
docker compose -f docker-compose.production.yml ps
|
||||||
|
|
||||||
# Should show:
|
# Test endpoints
|
||||||
# - django (running)
|
curl https://yourdomain.com/api/
|
||||||
# - postgres (running)
|
curl https://platform.yourdomain.com/
|
||||||
# - redis (running)
|
curl https://automations.yourdomain.com/api/v1/health
|
||||||
# - 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)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Post-Deployment
|
## Regular Deployments
|
||||||
|
|
||||||
### 1. Monitoring
|
After initial setup, deployments are simple:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# View logs
|
# From your local machine
|
||||||
docker compose -f docker-compose.production.yml logs -f
|
cd ~/smoothschedule
|
||||||
|
|
||||||
# View specific service logs
|
# Commit and push your changes
|
||||||
docker compose -f docker-compose.production.yml logs -f django
|
git add .
|
||||||
docker compose -f docker-compose.production.yml logs -f postgres
|
git commit -m "Your changes"
|
||||||
|
git push
|
||||||
|
|
||||||
# Monitor Celery tasks via Flower
|
# Deploy
|
||||||
# Access: https://smoothschedule.com:5555
|
./deploy.sh
|
||||||
# Login with credentials from .envs/.production/.django
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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
|
```bash
|
||||||
# Database backup
|
# 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
|
docker compose -f docker-compose.production.yml exec postgres restore backup_filename.sql.gz
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Updates
|
### Monitoring
|
||||||
|
|
||||||
```bash
|
- **Flower Dashboard**: `https://yourdomain.com:5555` - Celery task monitoring
|
||||||
# Pull latest code
|
- **Container Status**: `docker compose ps`
|
||||||
cd ~/smoothschedule/smoothschedule
|
- **Resource Usage**: `docker stats`
|
||||||
git pull origin main
|
|
||||||
|
|
||||||
# Rebuild and restart
|
### Security Checklist
|
||||||
docker compose -f docker-compose.production.yml build
|
|
||||||
docker compose -f docker-compose.production.yml up -d
|
|
||||||
|
|
||||||
# Run migrations
|
- [x] SSL/HTTPS enabled via Let's Encrypt (automatic with Traefik)
|
||||||
docker compose -f docker-compose.production.yml exec django python manage.py migrate
|
- [x] All secret keys are unique random values
|
||||||
|
- [x] Database passwords are strong
|
||||||
# Collect static files
|
- [x] Flower dashboard is password protected
|
||||||
docker compose -f docker-compose.production.yml exec django python manage.py collectstatic --noinput
|
- [ ] Firewall configured (UFW)
|
||||||
```
|
- [ ] SSH key-based authentication only
|
||||||
|
|
||||||
## 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
|
|
||||||
- [ ] Regular backups configured
|
- [ ] 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
|
smoothschedule/
|
||||||
- Update `DJANGO_AWS_S3_CUSTOM_DOMAIN=smoothschedule.nyc3.cdn.digitaloceanspaces.com`
|
├── deploy.sh # Main deployment script
|
||||||
|
├── DEPLOYMENT.md # This file
|
||||||
2. **Scale Gunicorn Workers**
|
├── scripts/
|
||||||
- Adjust `WEB_CONCURRENCY` in `.envs/.production/.django`
|
│ └── build-activepieces.sh # Activepieces image builder
|
||||||
- Formula: (2 x CPU cores) + 1
|
├── smoothschedule/
|
||||||
|
│ ├── docker-compose.production.yml
|
||||||
3. **Add Redis Persistence**
|
│ ├── scripts/
|
||||||
- Update docker-compose.production.yml redis config
|
│ │ └── init-production.sh # One-time initialization
|
||||||
- Enable AOF persistence
|
│ ├── .envs/
|
||||||
|
│ │ └── .production/ # Production secrets (NOT in git)
|
||||||
4. **Database Connection Pooling**
|
│ │ ├── .django
|
||||||
- Already configured via `CONN_MAX_AGE=60`
|
│ │ ├── .postgres
|
||||||
|
│ │ └── .activepieces
|
||||||
## Maintenance
|
│ └── .envs.example/ # Template files (in git)
|
||||||
|
│ ├── .django
|
||||||
### Weekly
|
│ ├── .postgres
|
||||||
- Review error logs
|
│ └── .activepieces
|
||||||
- Check disk space: `df -h`
|
└── activepieces-fork/
|
||||||
- Monitor Flower dashboard for failed tasks
|
├── Dockerfile
|
||||||
|
├── custom-pieces-metadata.sql
|
||||||
### Monthly
|
├── publish-pieces.sh
|
||||||
- Update Docker images: `docker compose pull`
|
└── packages/pieces/community/
|
||||||
- Update dependencies: `uv sync`
|
├── smoothschedule/ # Main custom piece
|
||||||
- Review backups
|
├── python-code/
|
||||||
|
└── ruby-code/
|
||||||
### As Needed
|
```
|
||||||
- Scale resources (CPU/RAM)
|
|
||||||
- Add more Celery workers
|
|
||||||
- Optimize database queries
|
|
||||||
|
|||||||
5
activepieces-fork/.gitignore
vendored
Normal file
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
|
1766388020169
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
FROM node:20.19-bullseye-slim AS base
|
FROM node:20.19-bullseye-slim AS base
|
||||||
|
|
||||||
# Set environment variables early for better layer caching
|
# 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 \
|
ENV LANG=en_US.UTF-8 \
|
||||||
LANGUAGE=en_US:en \
|
LANGUAGE=en_US:en \
|
||||||
LC_ALL=en_US.UTF-8 \
|
LC_ALL=en_US.UTF-8 \
|
||||||
NX_DAEMON=false \
|
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
|
# Install all system dependencies in a single layer with cache mounts
|
||||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
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 \
|
apt-get install -y --no-install-recommends \
|
||||||
openssh-client \
|
openssh-client \
|
||||||
python3 \
|
python3 \
|
||||||
|
python3-pip \
|
||||||
|
ruby \
|
||||||
g++ \
|
g++ \
|
||||||
build-essential \
|
build-essential \
|
||||||
git \
|
git \
|
||||||
@@ -28,17 +34,8 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
|||||||
libcap-dev && \
|
libcap-dev && \
|
||||||
yarn config set python /usr/bin/python3
|
yarn config set python /usr/bin/python3
|
||||||
|
|
||||||
RUN export ARCH=$(uname -m) && \
|
# Install Bun using npm (more reliable than GitHub downloads)
|
||||||
if [ "$ARCH" = "x86_64" ]; then \
|
RUN npm install -g bun@1.3.1
|
||||||
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-*
|
|
||||||
|
|
||||||
RUN bun --version
|
RUN bun --version
|
||||||
|
|
||||||
@@ -62,24 +59,30 @@ WORKDIR /usr/src/app
|
|||||||
# Copy only dependency files first for better layer caching
|
# Copy only dependency files first for better layer caching
|
||||||
COPY .npmrc package.json bun.lock ./
|
COPY .npmrc package.json bun.lock ./
|
||||||
|
|
||||||
# Install all dependencies with frozen lockfile
|
# Install all dependencies
|
||||||
RUN --mount=type=cache,target=/root/.bun/install/cache \
|
RUN --mount=type=cache,target=/root/.bun/install/cache \
|
||||||
bun install --frozen-lockfile
|
bun install
|
||||||
|
|
||||||
# Copy source code after dependency installation
|
# Copy source code after dependency installation
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build all projects including the SmoothSchedule piece
|
# Build all projects including custom pieces
|
||||||
RUN npx nx run-many --target=build --projects=react-ui,server-api,pieces-smoothschedule --configuration production --parallel=2 --skip-nx-cache
|
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
|
# Install production dependencies only for the backend API
|
||||||
RUN --mount=type=cache,target=/root/.bun/install/cache \
|
RUN --mount=type=cache,target=/root/.bun/install/cache \
|
||||||
cd dist/packages/server/api && \
|
cd dist/packages/server/api && \
|
||||||
bun install --production --frozen-lockfile
|
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 \
|
RUN --mount=type=cache,target=/root/.bun/install/cache \
|
||||||
cd dist/packages/pieces/community/smoothschedule && \
|
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
|
bun install --production
|
||||||
|
|
||||||
### STAGE 2: Run ###
|
### STAGE 2: Run ###
|
||||||
@@ -87,24 +90,30 @@ FROM base AS run
|
|||||||
|
|
||||||
WORKDIR /usr/src/app
|
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 \
|
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||||
apt-get update && \
|
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 static configuration files first (better layer caching)
|
||||||
COPY nginx.react.conf /etc/nginx/nginx.conf
|
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 --from=build /usr/src/app/packages/server/api/src/assets/default.cf /usr/local/etc/isolate
|
||||||
COPY docker-entrypoint.sh .
|
COPY docker-entrypoint.sh .
|
||||||
|
COPY custom-pieces-metadata.sql .
|
||||||
|
COPY publish-pieces.sh .
|
||||||
|
|
||||||
# Create all necessary directories in one layer
|
# 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 \
|
RUN mkdir -p \
|
||||||
/usr/src/app/dist/packages/server \
|
/usr/src/app/dist/packages/server \
|
||||||
/usr/src/app/dist/packages/engine \
|
/usr/src/app/dist/packages/engine \
|
||||||
/usr/src/app/dist/packages/shared \
|
/usr/src/app/dist/packages/shared \
|
||||||
/usr/src/app/dist/packages/pieces && \
|
/usr/src/app/dist/packages/pieces \
|
||||||
chmod +x docker-entrypoint.sh
|
/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 built artifacts from build stage
|
||||||
COPY --from=build /usr/src/app/LICENSE .
|
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/server/ ./dist/packages/server/
|
||||||
COPY --from=build /usr/src/app/dist/packages/shared/ ./dist/packages/shared/
|
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/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 frontend files to Nginx document root
|
||||||
COPY --from=build /usr/src/app/dist/packages/react-ui /usr/share/nginx/html/
|
COPY --from=build /usr/src/app/dist/packages/react-ui /usr/share/nginx/html/
|
||||||
|
|||||||
57
activepieces-fork/custom-pieces-metadata.sql
Normal file
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 && \
|
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
|
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
|
# Start Nginx server
|
||||||
nginx -g "daemon off;" &
|
nginx -g "daemon off;" &
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ http {
|
|||||||
proxy_send_timeout 900s;
|
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;
|
root /usr/share/nginx/html;
|
||||||
add_header Expires "0";
|
add_header Expires "0";
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
|||||||
@@ -354,6 +354,7 @@
|
|||||||
"@vitest/ui": "1.6.1",
|
"@vitest/ui": "1.6.1",
|
||||||
"autoprefixer": "10.4.15",
|
"autoprefixer": "10.4.15",
|
||||||
"babel-jest": "30.0.5",
|
"babel-jest": "30.0.5",
|
||||||
|
"bun": "1.3.5",
|
||||||
"chalk": "4.1.2",
|
"chalk": "4.1.2",
|
||||||
"concurrently": "8.2.1",
|
"concurrently": "8.2.1",
|
||||||
"esbuild": "0.25.0",
|
"esbuild": "0.25.0",
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export const STANDARD_CLOUD_PLAN: PlatformPlanWithOnlyLimits = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const OPEN_SOURCE_PLAN: PlatformPlanWithOnlyLimits = {
|
export const OPEN_SOURCE_PLAN: PlatformPlanWithOnlyLimits = {
|
||||||
embeddingEnabled: false,
|
embeddingEnabled: true,
|
||||||
globalConnectionsEnabled: false,
|
globalConnectionsEnabled: false,
|
||||||
customRolesEnabled: false,
|
customRolesEnabled: false,
|
||||||
mcpsEnabled: true,
|
mcpsEnabled: true,
|
||||||
@@ -107,9 +107,9 @@ export const OPEN_SOURCE_PLAN: PlatformPlanWithOnlyLimits = {
|
|||||||
analyticsEnabled: true,
|
analyticsEnabled: true,
|
||||||
showPoweredBy: false,
|
showPoweredBy: false,
|
||||||
auditLogEnabled: false,
|
auditLogEnabled: false,
|
||||||
managePiecesEnabled: false,
|
managePiecesEnabled: true,
|
||||||
manageTemplatesEnabled: false,
|
manageTemplatesEnabled: true,
|
||||||
customAppearanceEnabled: false,
|
customAppearanceEnabled: true,
|
||||||
teamProjectsLimit: TeamProjectsLimit.NONE,
|
teamProjectsLimit: TeamProjectsLimit.NONE,
|
||||||
projectRolesEnabled: false,
|
projectRolesEnabled: false,
|
||||||
customDomainsEnabled: 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"]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -5,3 +5,5 @@ export * from './find-events';
|
|||||||
export * from './list-resources';
|
export * from './list-resources';
|
||||||
export * from './list-services';
|
export * from './list-services';
|
||||||
export * from './list-inactive-customers';
|
export * from './list-inactive-customers';
|
||||||
|
export * from './list-customers';
|
||||||
|
export * from './track-run';
|
||||||
|
|||||||
@@ -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,86 @@
|
|||||||
|
import { createAction } from '@activepieces/pieces-framework';
|
||||||
|
import { HttpMethod, httpClient } from '@activepieces/pieces-common';
|
||||||
|
import { smoothScheduleAuth, SmoothScheduleAuth } from '../../index';
|
||||||
|
|
||||||
|
interface TrackRunResponse {
|
||||||
|
success: boolean;
|
||||||
|
runs_this_month: number;
|
||||||
|
limit: number;
|
||||||
|
remaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track Automation Run Action
|
||||||
|
*
|
||||||
|
* This action should be placed at the beginning of each automation flow
|
||||||
|
* to track executions for quota management. It increments the run counter
|
||||||
|
* for the current flow and returns quota information.
|
||||||
|
*
|
||||||
|
* The action:
|
||||||
|
* 1. Gets the current flow ID from the context
|
||||||
|
* 2. Calls the SmoothSchedule track-run API endpoint
|
||||||
|
* 3. Returns quota usage information
|
||||||
|
*/
|
||||||
|
export const trackRunAction = createAction({
|
||||||
|
auth: smoothScheduleAuth,
|
||||||
|
name: 'track_run',
|
||||||
|
displayName: 'Track Run',
|
||||||
|
description:
|
||||||
|
'Track this automation execution for quota management. Place at the start of each flow.',
|
||||||
|
props: {},
|
||||||
|
async run(context) {
|
||||||
|
const auth = context.auth as SmoothScheduleAuth;
|
||||||
|
|
||||||
|
// Get the current flow ID from the Activepieces context
|
||||||
|
const flowId = context.flows.current.id;
|
||||||
|
|
||||||
|
// Build the URL for the track-run endpoint
|
||||||
|
// The track-run endpoint is at /api/activepieces/track-run/
|
||||||
|
const url = new URL(auth.props.baseUrl);
|
||||||
|
let hostHeader = `${url.hostname}${url.port ? ':' + url.port : ''}`;
|
||||||
|
|
||||||
|
// Map docker hostname to lvh.me (which Django recognizes)
|
||||||
|
if (url.hostname === 'django') {
|
||||||
|
hostHeader = `lvh.me${url.port ? ':' + url.port : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackRunUrl = `${auth.props.baseUrl}/api/activepieces/track-run/`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await httpClient.sendRequest<TrackRunResponse>({
|
||||||
|
method: HttpMethod.POST,
|
||||||
|
url: trackRunUrl,
|
||||||
|
body: {
|
||||||
|
flow_id: flowId,
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'X-Tenant': auth.props.subdomain,
|
||||||
|
Host: hostHeader,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: response.body.success,
|
||||||
|
runs_this_month: response.body.runs_this_month,
|
||||||
|
limit: response.body.limit,
|
||||||
|
remaining: response.body.remaining,
|
||||||
|
message:
|
||||||
|
response.body.limit < 0
|
||||||
|
? 'Unlimited automation runs'
|
||||||
|
: `${response.body.remaining} automation runs remaining this month`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// Log the error but don't fail the flow - tracking is non-critical
|
||||||
|
console.error('Failed to track automation run:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
runs_this_month: -1,
|
||||||
|
limit: -1,
|
||||||
|
remaining: -1,
|
||||||
|
message: 'Failed to track run (flow will continue)',
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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-created';
|
||||||
export * from './event-updated';
|
export * from './event-updated';
|
||||||
export * from './event-cancelled';
|
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 { t } from 'i18next';
|
||||||
import { Plus, Globe } from 'lucide-react';
|
import { Plus, Globe } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useFormContext } from 'react-hook-form';
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
import { AutoFormFieldWrapper } from '@/app/builder/piece-properties/auto-form-field-wrapper';
|
import { AutoFormFieldWrapper } from '@/app/builder/piece-properties/auto-form-field-wrapper';
|
||||||
@@ -80,6 +80,27 @@ function ConnectionSelect(params: ConnectionSelectProps) {
|
|||||||
PropertyExecutionType.DYNAMIC;
|
PropertyExecutionType.DYNAMIC;
|
||||||
const isPLatformAdmin = useIsPlatformAdmin();
|
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 (
|
return (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { t } from 'i18next';
|
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 { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
@@ -24,14 +24,19 @@ import {
|
|||||||
import { LoadingSpinner } from '@/components/ui/spinner';
|
import { LoadingSpinner } from '@/components/ui/spinner';
|
||||||
import { TemplateCard } from '@/features/templates/components/template-card';
|
import { TemplateCard } from '@/features/templates/components/template-card';
|
||||||
import { TemplateDetailsView } from '@/features/templates/components/template-details-view';
|
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 { userHooks } from '@/hooks/user-hooks';
|
||||||
import { PlatformRole, Template, TemplateType } from '@activepieces/shared';
|
import { PlatformRole, Template } from '@activepieces/shared';
|
||||||
|
|
||||||
export const ExplorePage = () => {
|
export const ExplorePage = () => {
|
||||||
const { filteredTemplates, isLoading, search, setSearch } = useTemplates({
|
const {
|
||||||
type: TemplateType.OFFICIAL,
|
filteredCustomTemplates,
|
||||||
});
|
filteredOfficialTemplates,
|
||||||
|
filteredTemplates,
|
||||||
|
isLoading,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
} = useAllTemplates();
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
|
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -47,6 +52,20 @@ export const ExplorePage = () => {
|
|||||||
setSelectedTemplate(null);
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<ProjectDashboardPageHeader title={t('Explore Templates')} />
|
<ProjectDashboardPageHeader title={t('Explore Templates')} />
|
||||||
@@ -67,7 +86,7 @@ export const ExplorePage = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{filteredTemplates?.length === 0 && (
|
{filteredTemplates.length === 0 && (
|
||||||
<Empty className="min-h-[300px]">
|
<Empty className="min-h-[300px]">
|
||||||
<EmptyHeader className="max-w-xl">
|
<EmptyHeader className="max-w-xl">
|
||||||
<EmptyMedia variant="icon">
|
<EmptyMedia variant="icon">
|
||||||
@@ -93,17 +112,38 @@ export const ExplorePage = () => {
|
|||||||
)}
|
)}
|
||||||
</Empty>
|
</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) => (
|
{/* Custom Templates Section (SmoothSchedule-specific) */}
|
||||||
<TemplateCard
|
{filteredCustomTemplates.length > 0 && (
|
||||||
key={template.id}
|
<div className="mb-8">
|
||||||
template={template}
|
<div className="flex items-center gap-2 mb-4">
|
||||||
onSelectTemplate={(template) => {
|
<Building2 className="w-5 h-5 text-primary" />
|
||||||
setSelectedTemplate(template);
|
<h2 className="text-lg font-semibold">
|
||||||
}}
|
{t('SmoothSchedule Templates')}
|
||||||
/>
|
</h2>
|
||||||
))}
|
<span className="text-sm text-muted-foreground">
|
||||||
</div>
|
({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>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,64 @@
|
|||||||
import { t } from 'i18next';
|
import { t } from 'i18next';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { flagsHooks } from '@/hooks/flags-hooks';
|
import { flagsHooks } from '@/hooks/flags-hooks';
|
||||||
|
import { useTheme } from '@/components/theme-provider';
|
||||||
|
|
||||||
const FullLogo = () => {
|
const FullLogo = () => {
|
||||||
const branding = flagsHooks.useWebsiteBranding();
|
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 (
|
return (
|
||||||
<div className="h-[60px]">
|
<div className="h-[60px]">
|
||||||
<img
|
<img
|
||||||
className="h-full"
|
className="h-full"
|
||||||
src={branding.logos.fullLogoUrl}
|
src={logoUrl}
|
||||||
alt={t('logo')}
|
alt={t('logo')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -123,7 +123,15 @@ export const billingQueries = {
|
|||||||
usePlatformSubscription: (platformId: string) => {
|
usePlatformSubscription: (platformId: string) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: billingKeys.platformSubscription(platformId),
|
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 { LoadingSpinner } from '@/components/ui/spinner';
|
||||||
import { TemplateCard } from '@/features/templates/components/template-card';
|
import { TemplateCard } from '@/features/templates/components/template-card';
|
||||||
import { TemplateDetailsView } from '@/features/templates/components/template-details-view';
|
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 { Template, TemplateType } from '@activepieces/shared';
|
import { Template } from '@activepieces/shared';
|
||||||
|
|
||||||
const SelectFlowTemplateDialog = ({
|
const SelectFlowTemplateDialog = ({
|
||||||
children,
|
children,
|
||||||
@@ -32,9 +32,7 @@ const SelectFlowTemplateDialog = ({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
folderId: string;
|
folderId: string;
|
||||||
}) => {
|
}) => {
|
||||||
const { filteredTemplates, isLoading, search, setSearch } = useTemplates({
|
const { filteredTemplates, isLoading, search, setSearch } = useAllTemplates();
|
||||||
type: TemplateType.CUSTOM,
|
|
||||||
});
|
|
||||||
const carousel = useRef<CarouselApi>();
|
const carousel = useRef<CarouselApi>();
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
|
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
|
||||||
null,
|
null,
|
||||||
|
|||||||
@@ -12,20 +12,26 @@ export const projectMembersHooks = {
|
|||||||
const query = useQuery<ProjectMemberWithUser[]>({
|
const query = useQuery<ProjectMemberWithUser[]>({
|
||||||
queryKey: ['project-members', authenticationSession.getProjectId()],
|
queryKey: ['project-members', authenticationSession.getProjectId()],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const projectId = authenticationSession.getProjectId();
|
try {
|
||||||
assertNotNullOrUndefined(projectId, 'Project ID is null');
|
const projectId = authenticationSession.getProjectId();
|
||||||
const res = await projectMembersApi.list({
|
assertNotNullOrUndefined(projectId, 'Project ID is null');
|
||||||
projectId: projectId,
|
const res = await projectMembersApi.list({
|
||||||
projectRoleId: undefined,
|
projectId: projectId,
|
||||||
cursor: undefined,
|
projectRoleId: undefined,
|
||||||
limit: 100,
|
cursor: undefined,
|
||||||
});
|
limit: 100,
|
||||||
return res.data;
|
});
|
||||||
|
return res.data;
|
||||||
|
} catch {
|
||||||
|
// Return empty array if endpoint doesn't exist (community edition)
|
||||||
|
return [];
|
||||||
|
}
|
||||||
},
|
},
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
|
retry: false, // Don't retry on failure
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
projectMembers: query.data,
|
projectMembers: query.data ?? [],
|
||||||
isLoading: query.isLoading,
|
isLoading: query.isLoading,
|
||||||
refetch: query.refetch,
|
refetch: query.refetch,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -79,10 +79,14 @@ export const TemplateCard = ({
|
|||||||
className="rounded-lg border border-solid border-dividers overflow-hidden"
|
className="rounded-lg border border-solid border-dividers overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 p-4">
|
<div className="flex items-center gap-2 p-4">
|
||||||
<PieceIconList
|
{template.flows && template.flows.length > 0 && template.flows[0].trigger ? (
|
||||||
trigger={template.flows![0].trigger}
|
<PieceIconList
|
||||||
maxNumberOfIconsToShow={2}
|
trigger={template.flows[0].trigger}
|
||||||
/>
|
maxNumberOfIconsToShow={2}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-8 w-8 rounded bg-muted" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium px-4 min-h-16">{template.name}</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">
|
<div className="py-2 px-4 gap-1 flex items-center">
|
||||||
|
|||||||
@@ -13,11 +13,15 @@ export const TemplateDetailsView = ({ template }: TemplateDetailsViewProps) => {
|
|||||||
return (
|
return (
|
||||||
<div className="px-2">
|
<div className="px-2">
|
||||||
<div className="mb-4 p-8 flex items-center justify-center gap-2 width-full bg-green-300 rounded-lg">
|
<div className="mb-4 p-8 flex items-center justify-center gap-2 width-full bg-green-300 rounded-lg">
|
||||||
<PieceIconList
|
{template.flows && template.flows.length > 0 && template.flows[0].trigger ? (
|
||||||
size="xxl"
|
<PieceIconList
|
||||||
trigger={template.flows![0].trigger}
|
size="xxl"
|
||||||
maxNumberOfIconsToShow={3}
|
trigger={template.flows[0].trigger}
|
||||||
/>
|
maxNumberOfIconsToShow={3}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-16 w-16 rounded bg-muted" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="px-2 min-h-[156px] h-[calc(70vh-144px)] max-h-[536px]">
|
<ScrollArea className="px-2 min-h-[156px] h-[calc(70vh-144px)] max-h-[536px]">
|
||||||
<div className="mb-4 text-lg font-medium font-black">
|
<div className="mb-4 text-lg font-medium font-black">
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { ListTemplatesRequestQuery, Template } from '@activepieces/shared';
|
import {
|
||||||
|
ListTemplatesRequestQuery,
|
||||||
|
Template,
|
||||||
|
TemplateType,
|
||||||
|
} from '@activepieces/shared';
|
||||||
|
|
||||||
import { templatesApi } from '../lib/templates-api';
|
import { templatesApi } from '../lib/templates-api';
|
||||||
|
|
||||||
@@ -9,7 +13,7 @@ export const useTemplates = (request: ListTemplatesRequestQuery) => {
|
|||||||
const [search, setSearch] = useState<string>('');
|
const [search, setSearch] = useState<string>('');
|
||||||
|
|
||||||
const { data: templates, isLoading } = useQuery<Template[], Error>({
|
const { data: templates, isLoading } = useQuery<Template[], Error>({
|
||||||
queryKey: ['templates'],
|
queryKey: ['templates', request.type],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const templates = await templatesApi.list(request);
|
const templates = await templatesApi.list(request);
|
||||||
return templates.data;
|
return templates.data;
|
||||||
@@ -34,3 +38,86 @@ export const useTemplates = (request: ListTemplatesRequestQuery) => {
|
|||||||
setSearch,
|
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>({
|
return useQuery<ProjectWithLimits[], Error>({
|
||||||
queryKey: ['projects', params],
|
queryKey: ['projects', params],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const results = await projectApi.list({
|
try {
|
||||||
cursor,
|
const results = await projectApi.list({
|
||||||
limit,
|
cursor,
|
||||||
displayName,
|
limit,
|
||||||
...restParams,
|
displayName,
|
||||||
});
|
...restParams,
|
||||||
return results.data;
|
});
|
||||||
|
return results.data;
|
||||||
|
} catch {
|
||||||
|
// Return empty array if endpoint doesn't exist (embedded mode)
|
||||||
|
return [];
|
||||||
|
}
|
||||||
},
|
},
|
||||||
enabled: !displayName || displayName.length > 0,
|
enabled: !displayName || displayName.length > 0,
|
||||||
|
retry: false,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
useProjectsInfinite: (limit = 20) => {
|
useProjectsInfinite: (limit = 20) => {
|
||||||
@@ -77,11 +83,18 @@ export const projectHooks = {
|
|||||||
queryKey: ['projects-infinite', limit],
|
queryKey: ['projects-infinite', limit],
|
||||||
getNextPageParam: (lastPage) => lastPage.next,
|
getNextPageParam: (lastPage) => lastPage.next,
|
||||||
initialPageParam: undefined,
|
initialPageParam: undefined,
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: async ({ pageParam }) => {
|
||||||
projectApi.list({
|
try {
|
||||||
cursor: pageParam as string | undefined,
|
return await projectApi.list({
|
||||||
limit,
|
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: () => {
|
useProjectsForPlatforms: () => {
|
||||||
|
|||||||
@@ -247,6 +247,23 @@ export const appConnectionService = (log: FastifyBaseLogger) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
async delete(params: DeleteParams): Promise<void> {
|
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({
|
await appConnectionsRepo().delete({
|
||||||
id: params.id,
|
id: params.id,
|
||||||
platformId: params.platformId,
|
platformId: params.platformId,
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ export function generateTheme({
|
|||||||
|
|
||||||
export const defaultTheme = generateTheme({
|
export const defaultTheme = generateTheme({
|
||||||
primaryColor: '#6e41e2',
|
primaryColor: '#6e41e2',
|
||||||
websiteName: 'Activepieces',
|
websiteName: 'Automation Builder',
|
||||||
fullLogoUrl: 'https://cdn.activepieces.com/brand/full-logo.png',
|
fullLogoUrl: 'https://smoothschedule.nyc3.digitaloceanspaces.com/static/images/automation-builder-logo-light.svg',
|
||||||
favIconUrl: 'https://cdn.activepieces.com/brand/favicon.ico',
|
favIconUrl: 'https://cdn.activepieces.com/brand/favicon.ico',
|
||||||
logoIconUrl: 'https://cdn.activepieces.com/brand/logo.svg',
|
logoIconUrl: 'https://cdn.activepieces.com/brand/logo.svg',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,6 +37,19 @@ import { pieceListUtils } from './utils'
|
|||||||
|
|
||||||
export const pieceRepos = repoFactory(PieceMetadataEntity)
|
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) => {
|
export const pieceMetadataService = (log: FastifyBaseLogger) => {
|
||||||
return {
|
return {
|
||||||
async setup(): Promise<void> {
|
async setup(): Promise<void> {
|
||||||
@@ -89,13 +102,35 @@ export const pieceMetadataService = (log: FastifyBaseLogger) => {
|
|||||||
release: undefined,
|
release: undefined,
|
||||||
log,
|
log,
|
||||||
})
|
})
|
||||||
const piece = originalPieces.find((piece) => {
|
let piece = originalPieces.find((piece) => {
|
||||||
const strictlyLessThan = (isNil(versionToSearch) || (
|
const strictlyLessThan = (isNil(versionToSearch) || (
|
||||||
semVer.compare(piece.version, versionToSearch.nextExcludedVersion) < 0
|
semVer.compare(piece.version, versionToSearch.nextExcludedVersion) < 0
|
||||||
&& semVer.compare(piece.version, versionToSearch.baseVersion) >= 0
|
&& semVer.compare(piece.version, versionToSearch.baseVersion) >= 0
|
||||||
))
|
))
|
||||||
return piece.name === name && strictlyLessThan
|
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({
|
const isFiltered = !isNil(piece) && await enterpriseFilteringUtils.isFiltered({
|
||||||
piece,
|
piece,
|
||||||
projectId,
|
projectId,
|
||||||
@@ -287,10 +322,20 @@ const loadDevPiecesIfEnabled = async (log: FastifyBaseLogger): Promise<PieceMeta
|
|||||||
if (isNil(devPiecesConfig) || isEmpty(devPiecesConfig)) {
|
if (isNil(devPiecesConfig) || isEmpty(devPiecesConfig)) {
|
||||||
return []
|
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 piecesNames = devPiecesConfig.split(',')
|
||||||
const pieces = await filePiecesUtils(log).loadDistPiecesMetadata(piecesNames)
|
const pieces = await filePiecesUtils(log).loadDistPiecesMetadata(piecesNames)
|
||||||
|
|
||||||
return pieces.map((p): PieceMetadataSchema => ({
|
const result = pieces.map((p): PieceMetadataSchema => ({
|
||||||
id: apId(),
|
id: apId(),
|
||||||
...p,
|
...p,
|
||||||
projectUsage: 0,
|
projectUsage: 0,
|
||||||
@@ -299,6 +344,13 @@ const loadDevPiecesIfEnabled = async (log: FastifyBaseLogger): Promise<PieceMeta
|
|||||||
created: new Date().toISOString(),
|
created: new Date().toISOString(),
|
||||||
updated: 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> => {
|
const findOldestCreatedDate = async ({ name, platformId }: { name: string, platformId?: string }): Promise<string> => {
|
||||||
|
|||||||
@@ -25,6 +25,29 @@ export const communityTemplates = {
|
|||||||
const templates = await response.json()
|
const templates = await response.json()
|
||||||
return templates
|
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) => {
|
export const templateController: FastifyPluginAsyncTypebox = async (app) => {
|
||||||
app.get('/:id', GetParams, async (request) => {
|
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 })
|
return templateService().getOneOrThrow({ id: request.params.id })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
168
activepieces-fork/publish-pieces.sh
Normal file
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
153
deploy.sh
@@ -1,15 +1,33 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
# ==============================================================================
|
||||||
# SmoothSchedule Production Deployment Script
|
# 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
|
# Usage: ./deploy.sh [server] [options] [services...]
|
||||||
# Use --no-migrate to skip migrations (useful for config-only changes like traefik)
|
|
||||||
#
|
#
|
||||||
# This script deploys from git repository, not local files.
|
# Examples:
|
||||||
# Changes must be committed and pushed before deploying.
|
# ./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
|
set -e
|
||||||
|
|
||||||
@@ -23,12 +41,23 @@ NC='\033[0m' # No Color
|
|||||||
SERVER=""
|
SERVER=""
|
||||||
SERVICES=""
|
SERVICES=""
|
||||||
SKIP_MIGRATE=false
|
SKIP_MIGRATE=false
|
||||||
|
DEPLOY_AP=false
|
||||||
|
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
if [[ "$arg" == "--no-migrate" ]]; then
|
if [[ "$arg" == "--no-migrate" ]]; then
|
||||||
SKIP_MIGRATE=true
|
SKIP_MIGRATE=true
|
||||||
elif [[ -z "$SERVER" ]]; then
|
elif [[ "$arg" == "--deploy-ap" ]]; then
|
||||||
|
DEPLOY_AP=true
|
||||||
|
elif [[ "$arg" == *"@"* ]]; then
|
||||||
|
# Looks like user@host
|
||||||
SERVER="$arg"
|
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
|
else
|
||||||
SERVICES="$SERVICES $arg"
|
SERVICES="$SERVICES $arg"
|
||||||
fi
|
fi
|
||||||
@@ -38,6 +67,7 @@ SERVER=${SERVER:-"poduck@smoothschedule.com"}
|
|||||||
SERVICES=$(echo "$SERVICES" | xargs) # Trim whitespace
|
SERVICES=$(echo "$SERVICES" | xargs) # Trim whitespace
|
||||||
REPO_URL="https://git.talova.net/poduck/smoothschedule.git"
|
REPO_URL="https://git.talova.net/poduck/smoothschedule.git"
|
||||||
REMOTE_DIR="/home/poduck/smoothschedule"
|
REMOTE_DIR="/home/poduck/smoothschedule"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
echo -e "${GREEN}==================================="
|
echo -e "${GREEN}==================================="
|
||||||
echo "SmoothSchedule Deployment"
|
echo "SmoothSchedule Deployment"
|
||||||
@@ -51,6 +81,9 @@ fi
|
|||||||
if [[ "$SKIP_MIGRATE" == "true" ]]; then
|
if [[ "$SKIP_MIGRATE" == "true" ]]; then
|
||||||
echo "Migrations: SKIPPED"
|
echo "Migrations: SKIPPED"
|
||||||
fi
|
fi
|
||||||
|
if [[ "$DEPLOY_AP" == "true" ]]; then
|
||||||
|
echo "Activepieces: BUILDING AND DEPLOYING"
|
||||||
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Function to print status
|
# Function to print status
|
||||||
@@ -94,10 +127,45 @@ fi
|
|||||||
|
|
||||||
print_status "All changes committed and pushed!"
|
print_status "All changes committed and pushed!"
|
||||||
|
|
||||||
# Step 2: Deploy on server
|
# Step 2: Build and deploy Activepieces image (if requested)
|
||||||
print_status "Step 2: Deploying on server..."
|
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
|
ssh "$SERVER" "bash -s" << ENDSSH
|
||||||
|
SKIP_AP_BUILD="$SKIP_AP_BUILD_VAL"
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo ">>> Setting up project directory..."
|
echo ">>> Setting up project directory..."
|
||||||
@@ -160,9 +228,16 @@ git log -1 --oneline
|
|||||||
cd smoothschedule
|
cd smoothschedule
|
||||||
|
|
||||||
# Build images (all or specific services)
|
# 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
|
if [[ -n "$SERVICES" ]]; then
|
||||||
echo ">>> Building Docker images: $SERVICES..."
|
echo ">>> Building Docker images: $SERVICES..."
|
||||||
docker compose -f docker-compose.production.yml build $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
|
else
|
||||||
echo ">>> Building all Docker images..."
|
echo ">>> Building all Docker images..."
|
||||||
docker compose -f docker-compose.production.yml build
|
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..."
|
echo ">>> Waiting for containers to start..."
|
||||||
sleep 5
|
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
|
# Run migrations unless skipped
|
||||||
if [[ "$SKIP_MIGRATE" != "true" ]]; then
|
if [[ "$SKIP_MIGRATE" != "true" ]]; then
|
||||||
echo ">>> Running database migrations..."
|
echo ">>> Running database migrations..."
|
||||||
@@ -210,6 +340,7 @@ echo "Your application should now be running at:"
|
|||||||
echo " - https://smoothschedule.com"
|
echo " - https://smoothschedule.com"
|
||||||
echo " - https://platform.smoothschedule.com"
|
echo " - https://platform.smoothschedule.com"
|
||||||
echo " - https://*.smoothschedule.com (tenant subdomains)"
|
echo " - https://*.smoothschedule.com (tenant subdomains)"
|
||||||
|
echo " - https://automations.smoothschedule.com (Activepieces)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "To view logs:"
|
echo "To view logs:"
|
||||||
echo " ssh $SERVER 'cd ~/smoothschedule/smoothschedule && docker compose -f docker-compose.production.yml logs -f'"
|
echo " ssh $SERVER 'cd ~/smoothschedule/smoothschedule && docker compose -f docker-compose.production.yml logs -f'"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
VITE_DEV_MODE=true
|
VITE_DEV_MODE=true
|
||||||
VITE_API_URL=http://api.lvh.me:8000
|
VITE_API_URL=http://api.lvh.me:8000
|
||||||
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51Sa2i4G4IkZ6cJFI77f9dXf1ljmDPAInxbjLCJRRJk4ng1qmJKtWEqkFcDuoVcAdQsxcMH1L1UiQFfPwy8OmLSaz008GsGQ63y
|
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51SdeoF5LKpRprAbuX9NpM0MJ1Sblr5qY5bNjozrirDWZXZub8XhJ6wf4VA3jfNhf5dXuWP8SPW1Cn5ZrZaMo2wg500QonC8D56
|
||||||
VITE_GOOGLE_MAPS_API_KEY=
|
VITE_GOOGLE_MAPS_API_KEY=
|
||||||
|
VITE_OPENAI_API_KEY=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
|
||||||
|
|||||||
BIN
frontend/email-page-debug.png
Normal file
BIN
frontend/email-page-debug.png
Normal file
Binary file not shown.
|
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
|
||||||
|
```
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 446 KiB |
File diff suppressed because one or more lines are too long
@@ -10,7 +10,7 @@ import { useCurrentUser, useMasquerade, useLogout } from './hooks/useAuth';
|
|||||||
import { useCurrentBusiness } from './hooks/useBusiness';
|
import { useCurrentBusiness } from './hooks/useBusiness';
|
||||||
import { useUpdateBusiness } from './hooks/useBusiness';
|
import { useUpdateBusiness } from './hooks/useBusiness';
|
||||||
import { usePlanFeatures } from './hooks/usePlanFeatures';
|
import { usePlanFeatures } from './hooks/usePlanFeatures';
|
||||||
import { setCookie } from './utils/cookies';
|
import { setCookie, deleteCookie } from './utils/cookies';
|
||||||
|
|
||||||
// Import Login Page
|
// Import Login Page
|
||||||
const LoginPage = React.lazy(() => import('./pages/LoginPage'));
|
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 PlatformStaff = React.lazy(() => import('./pages/platform/PlatformStaff'));
|
||||||
const PlatformSettings = React.lazy(() => import('./pages/platform/PlatformSettings'));
|
const PlatformSettings = React.lazy(() => import('./pages/platform/PlatformSettings'));
|
||||||
const BillingManagement = React.lazy(() => import('./pages/platform/BillingManagement'));
|
const BillingManagement = React.lazy(() => import('./pages/platform/BillingManagement'));
|
||||||
|
const PlatformStaffEmail = React.lazy(() => import('./pages/platform/PlatformStaffEmail'));
|
||||||
const ProfileSettings = React.lazy(() => import('./pages/ProfileSettings'));
|
const ProfileSettings = React.lazy(() => import('./pages/ProfileSettings'));
|
||||||
const VerifyEmail = React.lazy(() => import('./pages/VerifyEmail'));
|
const VerifyEmail = React.lazy(() => import('./pages/VerifyEmail'));
|
||||||
const EmailVerificationRequired = React.lazy(() => import('./pages/EmailVerificationRequired'));
|
const EmailVerificationRequired = React.lazy(() => import('./pages/EmailVerificationRequired'));
|
||||||
@@ -114,6 +115,7 @@ const HelpSettingsEmailTemplates = React.lazy(() => import('./pages/help/HelpSet
|
|||||||
const HelpSettingsEmbedWidget = React.lazy(() => import('./pages/help/HelpSettingsEmbedWidget'));
|
const HelpSettingsEmbedWidget = React.lazy(() => import('./pages/help/HelpSettingsEmbedWidget'));
|
||||||
const HelpSettingsStaffRoles = React.lazy(() => import('./pages/help/HelpSettingsStaffRoles'));
|
const HelpSettingsStaffRoles = React.lazy(() => import('./pages/help/HelpSettingsStaffRoles'));
|
||||||
const HelpSettingsCommunication = React.lazy(() => import('./pages/help/HelpSettingsCommunication'));
|
const HelpSettingsCommunication = React.lazy(() => import('./pages/help/HelpSettingsCommunication'));
|
||||||
|
|
||||||
const HelpComprehensive = React.lazy(() => import('./pages/help/HelpComprehensive'));
|
const HelpComprehensive = React.lazy(() => import('./pages/help/HelpComprehensive'));
|
||||||
const StaffHelp = React.lazy(() => import('./pages/help/StaffHelp'));
|
const StaffHelp = React.lazy(() => import('./pages/help/StaffHelp'));
|
||||||
const PlatformSupport = React.lazy(() => import('./pages/PlatformSupport')); // Import Platform Support page (for businesses to contact SmoothSchedule)
|
const PlatformSupport = React.lazy(() => import('./pages/PlatformSupport')); // Import Platform Support page (for businesses to contact SmoothSchedule)
|
||||||
@@ -321,9 +323,37 @@ const AppContent: React.FC = () => {
|
|||||||
return hostname === 'localhost' || hostname === '127.0.0.1' || parts.length === 2;
|
return hostname === 'localhost' || hostname === '127.0.0.1' || parts.length === 2;
|
||||||
};
|
};
|
||||||
|
|
||||||
// On root domain, ALWAYS show marketing site (even if logged in)
|
// On root domain, handle logged-in users appropriately
|
||||||
// Logged-in users will see a "Go to Dashboard" link in the navbar
|
|
||||||
if (isRootDomain()) {
|
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 (
|
return (
|
||||||
<Suspense fallback={<LoadingScreen />}>
|
<Suspense fallback={<LoadingScreen />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -463,6 +493,16 @@ const AppContent: React.FC = () => {
|
|||||||
return <LoadingScreen />;
|
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
|
// RULE: Business users must be on their own business subdomain
|
||||||
if (isBusinessUser && isBusinessSubdomain && user.business_subdomain && user.business_subdomain !== currentSubdomain) {
|
if (isBusinessUser && isBusinessSubdomain && user.business_subdomain && user.business_subdomain !== currentSubdomain) {
|
||||||
const port = window.location.port ? `:${window.location.port}` : '';
|
const port = window.location.port ? `:${window.location.port}` : '';
|
||||||
@@ -470,16 +510,23 @@ const AppContent: React.FC = () => {
|
|||||||
return <LoadingScreen />;
|
return <LoadingScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// RULE: Customers must be on their business subdomain
|
// RULE: Customers must only access their own business subdomain
|
||||||
if (isCustomer && isPlatformDomain && user.business_subdomain) {
|
// If on platform domain or wrong business subdomain, log them out and let them use the form
|
||||||
const port = window.location.port ? `:${window.location.port}` : '';
|
if (isCustomer && isPlatformDomain) {
|
||||||
window.location.href = `${protocol}//${user.business_subdomain}.${baseDomain}${port}/`;
|
deleteCookie('access_token');
|
||||||
|
deleteCookie('refresh_token');
|
||||||
|
localStorage.removeItem('masquerade_stack');
|
||||||
|
window.location.reload();
|
||||||
return <LoadingScreen />;
|
return <LoadingScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCustomer && isBusinessSubdomain && user.business_subdomain && user.business_subdomain !== currentSubdomain) {
|
if (isCustomer && isBusinessSubdomain && user.business_subdomain && user.business_subdomain !== currentSubdomain) {
|
||||||
const port = window.location.port ? `:${window.location.port}` : '';
|
// Customer is on a different business's subdomain - log them out
|
||||||
window.location.href = `${protocol}//${user.business_subdomain}.${baseDomain}${port}/`;
|
// 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 />;
|
return <LoadingScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,6 +587,7 @@ const AppContent: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
<Route path="/platform/support" element={<PlatformSupportPage />} />
|
<Route path="/platform/support" element={<PlatformSupportPage />} />
|
||||||
<Route path="/platform/email-addresses" element={<PlatformEmailAddresses />} />
|
<Route path="/platform/email-addresses" element={<PlatformEmailAddresses />} />
|
||||||
|
<Route path="/platform/email" element={<PlatformStaffEmail />} />
|
||||||
<Route path="/help/guide" element={<HelpGuide />} />
|
<Route path="/help/guide" element={<HelpGuide />} />
|
||||||
<Route path="/help/ticketing" element={<HelpTicketing />} />
|
<Route path="/help/ticketing" element={<HelpTicketing />} />
|
||||||
<Route path="/help/api" element={<HelpApiDocs />} />
|
<Route path="/help/api" element={<HelpApiDocs />} />
|
||||||
@@ -713,7 +761,8 @@ const AppContent: React.FC = () => {
|
|||||||
<Route path="/" element={<PublicPage />} />
|
<Route path="/" element={<PublicPage />} />
|
||||||
<Route path="/book" element={<BookingFlow />} />
|
<Route path="/book" element={<BookingFlow />} />
|
||||||
<Route path="/embed" element={<EmbedBooking />} />
|
<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 />} />
|
<Route path="/sign/:token" element={<ContractSigning />} />
|
||||||
|
|
||||||
{/* Dashboard routes inside BusinessLayout */}
|
{/* Dashboard routes inside BusinessLayout */}
|
||||||
@@ -780,7 +829,6 @@ const AppContent: React.FC = () => {
|
|||||||
<Route path="/dashboard/help/contracts" element={<HelpContracts />} />
|
<Route path="/dashboard/help/contracts" element={<HelpContracts />} />
|
||||||
<Route path="/dashboard/help/automations" element={<HelpAutomations />} />
|
<Route path="/dashboard/help/automations" element={<HelpAutomations />} />
|
||||||
<Route path="/dashboard/help/site-builder" element={<HelpSiteBuilder />} />
|
<Route path="/dashboard/help/site-builder" element={<HelpSiteBuilder />} />
|
||||||
<Route path="/dashboard/help/api" element={<HelpApiOverview />} />
|
|
||||||
<Route path="/dashboard/help/api/appointments" element={<HelpApiAppointments />} />
|
<Route path="/dashboard/help/api/appointments" element={<HelpApiAppointments />} />
|
||||||
<Route path="/dashboard/help/api/services" element={<HelpApiServices />} />
|
<Route path="/dashboard/help/api/services" element={<HelpApiServices />} />
|
||||||
<Route path="/dashboard/help/api/resources" element={<HelpApiResources />} />
|
<Route path="/dashboard/help/api/resources" element={<HelpApiResources />} />
|
||||||
@@ -830,15 +878,10 @@ const AppContent: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* Redirect old services path to new settings location */}
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard/services"
|
path="/dashboard/services"
|
||||||
element={
|
element={<Navigate to="/dashboard/settings/services" replace />}
|
||||||
canAccess('can_access_services') ? (
|
|
||||||
<Services />
|
|
||||||
) : (
|
|
||||||
<Navigate to="/dashboard" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard/resources"
|
path="/dashboard/resources"
|
||||||
@@ -870,15 +913,10 @@ const AppContent: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* Redirect old locations path to new settings location */}
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard/locations"
|
path="/dashboard/locations"
|
||||||
element={
|
element={<Navigate to="/dashboard/settings/locations" replace />}
|
||||||
canAccess('can_access_locations') ? (
|
|
||||||
<Locations />
|
|
||||||
) : (
|
|
||||||
<Navigate to="/dashboard" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard/my-availability"
|
path="/dashboard/my-availability"
|
||||||
@@ -926,15 +964,10 @@ const AppContent: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* Redirect old site-editor path to new settings location */}
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard/site-editor"
|
path="/dashboard/site-editor"
|
||||||
element={
|
element={<Navigate to="/dashboard/settings/site-builder" replace />}
|
||||||
canAccess('can_access_site_editor') ? (
|
|
||||||
<PageEditor />
|
|
||||||
) : (
|
|
||||||
<Navigate to="/dashboard" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard/email-template-editor/:emailType"
|
path="/dashboard/email-template-editor/:emailType"
|
||||||
@@ -976,6 +1009,10 @@ const AppContent: React.FC = () => {
|
|||||||
<Route path="sms-calling" element={<CommunicationSettings />} />
|
<Route path="sms-calling" element={<CommunicationSettings />} />
|
||||||
<Route path="billing" element={<BillingSettings />} />
|
<Route path="billing" element={<BillingSettings />} />
|
||||||
<Route path="quota" element={<QuotaSettings />} />
|
<Route path="quota" element={<QuotaSettings />} />
|
||||||
|
{/* Moved from main sidebar */}
|
||||||
|
<Route path="services" element={<Services />} />
|
||||||
|
<Route path="locations" element={<Locations />} />
|
||||||
|
<Route path="site-builder" element={<PageEditor />} />
|
||||||
</Route>
|
</Route>
|
||||||
) : (
|
) : (
|
||||||
<Route path="/dashboard/settings/*" element={<Navigate to="/dashboard" />} />
|
<Route path="/dashboard/settings/*" element={<Navigate to="/dashboard" />} />
|
||||||
|
|||||||
107
frontend/src/api/__tests__/activepieces.test.ts
Normal file
107
frontend/src/api/__tests__/activepieces.test.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import apiClient from '../client';
|
||||||
|
import {
|
||||||
|
getDefaultFlows,
|
||||||
|
restoreFlow,
|
||||||
|
restoreAllFlows,
|
||||||
|
DefaultFlow,
|
||||||
|
} from '../activepieces';
|
||||||
|
|
||||||
|
vi.mock('../client');
|
||||||
|
|
||||||
|
describe('activepieces API', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockFlow: DefaultFlow = {
|
||||||
|
flow_type: 'appointment_reminder',
|
||||||
|
display_name: 'Appointment Reminder',
|
||||||
|
activepieces_flow_id: 'flow_123',
|
||||||
|
is_modified: false,
|
||||||
|
is_enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('getDefaultFlows', () => {
|
||||||
|
it('fetches default flows', async () => {
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: { flows: [mockFlow] } });
|
||||||
|
|
||||||
|
const result = await getDefaultFlows();
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith('/activepieces/default-flows/');
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].flow_type).toBe('appointment_reminder');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when no flows', async () => {
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: { flows: [] } });
|
||||||
|
|
||||||
|
const result = await getDefaultFlows();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('restoreFlow', () => {
|
||||||
|
it('restores a single flow', async () => {
|
||||||
|
const response = {
|
||||||
|
success: true,
|
||||||
|
flow_type: 'appointment_reminder',
|
||||||
|
message: 'Flow restored successfully',
|
||||||
|
};
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: response });
|
||||||
|
|
||||||
|
const result = await restoreFlow('appointment_reminder');
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/activepieces/default-flows/appointment_reminder/restore/');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.flow_type).toBe('appointment_reminder');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles failed restore', async () => {
|
||||||
|
const response = {
|
||||||
|
success: false,
|
||||||
|
flow_type: 'appointment_reminder',
|
||||||
|
message: 'Flow not found',
|
||||||
|
};
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: response });
|
||||||
|
|
||||||
|
const result = await restoreFlow('appointment_reminder');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('restoreAllFlows', () => {
|
||||||
|
it('restores all flows', async () => {
|
||||||
|
const response = {
|
||||||
|
success: true,
|
||||||
|
restored: ['appointment_reminder', 'booking_confirmation'],
|
||||||
|
failed: [],
|
||||||
|
};
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: response });
|
||||||
|
|
||||||
|
const result = await restoreAllFlows();
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/activepieces/default-flows/restore-all/');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.restored).toHaveLength(2);
|
||||||
|
expect(result.failed).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles partial restore failure', async () => {
|
||||||
|
const response = {
|
||||||
|
success: true,
|
||||||
|
restored: ['appointment_reminder'],
|
||||||
|
failed: ['booking_confirmation'],
|
||||||
|
};
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: response });
|
||||||
|
|
||||||
|
const result = await restoreAllFlows();
|
||||||
|
|
||||||
|
expect(result.restored).toHaveLength(1);
|
||||||
|
expect(result.failed).toHaveLength(1);
|
||||||
|
expect(result.failed[0]).toBe('booking_confirmation');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
363
frontend/src/api/__tests__/media.test.ts
Normal file
363
frontend/src/api/__tests__/media.test.ts
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import apiClient from '../client';
|
||||||
|
import * as mediaApi from '../media';
|
||||||
|
|
||||||
|
vi.mock('../client');
|
||||||
|
|
||||||
|
describe('media API', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Album API', () => {
|
||||||
|
const mockAlbum = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Test Album',
|
||||||
|
description: 'Test Description',
|
||||||
|
cover_image: null,
|
||||||
|
file_count: 5,
|
||||||
|
cover_url: null,
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
updated_at: '2024-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('listAlbums', () => {
|
||||||
|
it('lists all albums', async () => {
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [mockAlbum] });
|
||||||
|
|
||||||
|
const result = await mediaApi.listAlbums();
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith('/albums/');
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].name).toBe('Test Album');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAlbum', () => {
|
||||||
|
it('gets a single album', async () => {
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockAlbum });
|
||||||
|
|
||||||
|
const result = await mediaApi.getAlbum(1);
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith('/albums/1/');
|
||||||
|
expect(result.name).toBe('Test Album');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createAlbum', () => {
|
||||||
|
it('creates a new album', async () => {
|
||||||
|
const newAlbum = { ...mockAlbum, id: 2, name: 'New Album' };
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: newAlbum });
|
||||||
|
|
||||||
|
const result = await mediaApi.createAlbum({ name: 'New Album' });
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/albums/', { name: 'New Album' });
|
||||||
|
expect(result.name).toBe('New Album');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates album with description', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockAlbum });
|
||||||
|
|
||||||
|
await mediaApi.createAlbum({ name: 'Test', description: 'Description' });
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/albums/', {
|
||||||
|
name: 'Test',
|
||||||
|
description: 'Description',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateAlbum', () => {
|
||||||
|
it('updates an album', async () => {
|
||||||
|
vi.mocked(apiClient.patch).mockResolvedValueOnce({
|
||||||
|
data: { ...mockAlbum, name: 'Updated' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await mediaApi.updateAlbum(1, { name: 'Updated' });
|
||||||
|
|
||||||
|
expect(apiClient.patch).toHaveBeenCalledWith('/albums/1/', { name: 'Updated' });
|
||||||
|
expect(result.name).toBe('Updated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteAlbum', () => {
|
||||||
|
it('deletes an album', async () => {
|
||||||
|
vi.mocked(apiClient.delete).mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await mediaApi.deleteAlbum(1);
|
||||||
|
|
||||||
|
expect(apiClient.delete).toHaveBeenCalledWith('/albums/1/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Media File API', () => {
|
||||||
|
const mockMediaFile = {
|
||||||
|
id: 1,
|
||||||
|
url: 'https://example.com/image.jpg',
|
||||||
|
filename: 'image.jpg',
|
||||||
|
alt_text: 'Test image',
|
||||||
|
file_size: 1024,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
mime_type: 'image/jpeg',
|
||||||
|
album: 1,
|
||||||
|
album_name: 'Test Album',
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('listMediaFiles', () => {
|
||||||
|
it('lists all media files', async () => {
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [mockMediaFile] });
|
||||||
|
|
||||||
|
const result = await mediaApi.listMediaFiles();
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith('/media-files/', { params: {} });
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by album ID', async () => {
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [mockMediaFile] });
|
||||||
|
|
||||||
|
await mediaApi.listMediaFiles(1);
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith('/media-files/', { params: { album: 1 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters for uncategorized files', async () => {
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
|
||||||
|
|
||||||
|
await mediaApi.listMediaFiles('null');
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith('/media-files/', { params: { album: 'null' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getMediaFile', () => {
|
||||||
|
it('gets a single media file', async () => {
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockMediaFile });
|
||||||
|
|
||||||
|
const result = await mediaApi.getMediaFile(1);
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith('/media-files/1/');
|
||||||
|
expect(result.filename).toBe('image.jpg');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('uploadMediaFile', () => {
|
||||||
|
it('uploads a file', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockMediaFile });
|
||||||
|
|
||||||
|
const file = new File(['test content'], 'test.jpg', { type: 'image/jpeg' });
|
||||||
|
const result = await mediaApi.uploadMediaFile(file);
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
'/media-files/',
|
||||||
|
expect.any(FormData),
|
||||||
|
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||||
|
);
|
||||||
|
expect(result.filename).toBe('image.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uploads file with album assignment', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockMediaFile });
|
||||||
|
|
||||||
|
const file = new File(['test content'], 'test.jpg', { type: 'image/jpeg' });
|
||||||
|
await mediaApi.uploadMediaFile(file, 1);
|
||||||
|
|
||||||
|
const formData = vi.mocked(apiClient.post).mock.calls[0][1] as FormData;
|
||||||
|
expect(formData.get('album')).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uploads file with alt text', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockMediaFile });
|
||||||
|
|
||||||
|
const file = new File(['test content'], 'test.jpg', { type: 'image/jpeg' });
|
||||||
|
await mediaApi.uploadMediaFile(file, null, 'Alt text');
|
||||||
|
|
||||||
|
const formData = vi.mocked(apiClient.post).mock.calls[0][1] as FormData;
|
||||||
|
expect(formData.get('alt_text')).toBe('Alt text');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateMediaFile', () => {
|
||||||
|
it('updates a media file', async () => {
|
||||||
|
vi.mocked(apiClient.patch).mockResolvedValueOnce({
|
||||||
|
data: { ...mockMediaFile, alt_text: 'Updated alt' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await mediaApi.updateMediaFile(1, { alt_text: 'Updated alt' });
|
||||||
|
|
||||||
|
expect(apiClient.patch).toHaveBeenCalledWith('/media-files/1/', { alt_text: 'Updated alt' });
|
||||||
|
expect(result.alt_text).toBe('Updated alt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates album assignment', async () => {
|
||||||
|
vi.mocked(apiClient.patch).mockResolvedValueOnce({
|
||||||
|
data: { ...mockMediaFile, album: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await mediaApi.updateMediaFile(1, { album: 2 });
|
||||||
|
|
||||||
|
expect(apiClient.patch).toHaveBeenCalledWith('/media-files/1/', { album: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteMediaFile', () => {
|
||||||
|
it('deletes a media file', async () => {
|
||||||
|
vi.mocked(apiClient.delete).mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await mediaApi.deleteMediaFile(1);
|
||||||
|
|
||||||
|
expect(apiClient.delete).toHaveBeenCalledWith('/media-files/1/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bulkMoveFiles', () => {
|
||||||
|
it('moves multiple files to an album', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: { updated: 3 } });
|
||||||
|
|
||||||
|
const result = await mediaApi.bulkMoveFiles([1, 2, 3], 2);
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/media-files/bulk_move/', {
|
||||||
|
file_ids: [1, 2, 3],
|
||||||
|
album_id: 2,
|
||||||
|
});
|
||||||
|
expect(result.updated).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves files to uncategorized', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: { updated: 2 } });
|
||||||
|
|
||||||
|
await mediaApi.bulkMoveFiles([1, 2], null);
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/media-files/bulk_move/', {
|
||||||
|
file_ids: [1, 2],
|
||||||
|
album_id: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bulkDeleteFiles', () => {
|
||||||
|
it('deletes multiple files', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: { deleted: 3 } });
|
||||||
|
|
||||||
|
const result = await mediaApi.bulkDeleteFiles([1, 2, 3]);
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/media-files/bulk_delete/', {
|
||||||
|
file_ids: [1, 2, 3],
|
||||||
|
});
|
||||||
|
expect(result.deleted).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Storage Usage API', () => {
|
||||||
|
describe('getStorageUsage', () => {
|
||||||
|
it('gets storage usage', async () => {
|
||||||
|
const mockUsage = {
|
||||||
|
bytes_used: 1024 * 1024 * 50,
|
||||||
|
bytes_total: 1024 * 1024 * 1024,
|
||||||
|
file_count: 100,
|
||||||
|
percent_used: 5.0,
|
||||||
|
used_display: '50 MB',
|
||||||
|
total_display: '1 GB',
|
||||||
|
};
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockUsage });
|
||||||
|
|
||||||
|
const result = await mediaApi.getStorageUsage();
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith('/storage-usage/');
|
||||||
|
expect(result.bytes_used).toBe(1024 * 1024 * 50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Utility Functions', () => {
|
||||||
|
describe('formatFileSize', () => {
|
||||||
|
it('formats bytes', () => {
|
||||||
|
expect(mediaApi.formatFileSize(500)).toBe('500 B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats kilobytes', () => {
|
||||||
|
expect(mediaApi.formatFileSize(1024)).toBe('1.0 KB');
|
||||||
|
expect(mediaApi.formatFileSize(2048)).toBe('2.0 KB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats megabytes', () => {
|
||||||
|
expect(mediaApi.formatFileSize(1024 * 1024)).toBe('1.0 MB');
|
||||||
|
expect(mediaApi.formatFileSize(5.5 * 1024 * 1024)).toBe('5.5 MB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats gigabytes', () => {
|
||||||
|
expect(mediaApi.formatFileSize(1024 * 1024 * 1024)).toBe('1.0 GB');
|
||||||
|
expect(mediaApi.formatFileSize(2.5 * 1024 * 1024 * 1024)).toBe('2.5 GB');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isAllowedFileType', () => {
|
||||||
|
it('allows jpeg', () => {
|
||||||
|
const file = new File([''], 'test.jpg', { type: 'image/jpeg' });
|
||||||
|
expect(mediaApi.isAllowedFileType(file)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows png', () => {
|
||||||
|
const file = new File([''], 'test.png', { type: 'image/png' });
|
||||||
|
expect(mediaApi.isAllowedFileType(file)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows gif', () => {
|
||||||
|
const file = new File([''], 'test.gif', { type: 'image/gif' });
|
||||||
|
expect(mediaApi.isAllowedFileType(file)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows webp', () => {
|
||||||
|
const file = new File([''], 'test.webp', { type: 'image/webp' });
|
||||||
|
expect(mediaApi.isAllowedFileType(file)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects pdf', () => {
|
||||||
|
const file = new File([''], 'test.pdf', { type: 'application/pdf' });
|
||||||
|
expect(mediaApi.isAllowedFileType(file)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects svg', () => {
|
||||||
|
const file = new File([''], 'test.svg', { type: 'image/svg+xml' });
|
||||||
|
expect(mediaApi.isAllowedFileType(file)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllowedFileTypes', () => {
|
||||||
|
it('returns allowed file types string', () => {
|
||||||
|
const result = mediaApi.getAllowedFileTypes();
|
||||||
|
expect(result).toBe('image/jpeg,image/png,image/gif,image/webp');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MAX_FILE_SIZE', () => {
|
||||||
|
it('is 10 MB', () => {
|
||||||
|
expect(mediaApi.MAX_FILE_SIZE).toBe(10 * 1024 * 1024);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isFileSizeAllowed', () => {
|
||||||
|
it('allows files under 10 MB', () => {
|
||||||
|
const file = new File(['x'.repeat(1024)], 'test.jpg', { type: 'image/jpeg' });
|
||||||
|
Object.defineProperty(file, 'size', { value: 5 * 1024 * 1024 });
|
||||||
|
expect(mediaApi.isFileSizeAllowed(file)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows files exactly 10 MB', () => {
|
||||||
|
const file = new File([''], 'test.jpg', { type: 'image/jpeg' });
|
||||||
|
Object.defineProperty(file, 'size', { value: 10 * 1024 * 1024 });
|
||||||
|
expect(mediaApi.isFileSizeAllowed(file)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects files over 10 MB', () => {
|
||||||
|
const file = new File([''], 'test.jpg', { type: 'image/jpeg' });
|
||||||
|
Object.defineProperty(file, 'size', { value: 11 * 1024 * 1024 });
|
||||||
|
expect(mediaApi.isFileSizeAllowed(file)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
611
frontend/src/api/__tests__/staffEmail.test.ts
Normal file
611
frontend/src/api/__tests__/staffEmail.test.ts
Normal file
@@ -0,0 +1,611 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import apiClient from '../client';
|
||||||
|
import * as staffEmailApi from '../staffEmail';
|
||||||
|
|
||||||
|
vi.mock('../client');
|
||||||
|
|
||||||
|
describe('staffEmail API', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Folder Operations', () => {
|
||||||
|
const mockFolderResponse = {
|
||||||
|
id: 1,
|
||||||
|
owner: 1,
|
||||||
|
name: 'Inbox',
|
||||||
|
folder_type: 'inbox',
|
||||||
|
email_count: 10,
|
||||||
|
unread_count: 3,
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
updated_at: '2024-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('getFolders', () => {
|
||||||
|
it('fetches all folders', async () => {
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [mockFolderResponse] });
|
||||||
|
|
||||||
|
const result = await staffEmailApi.getFolders();
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith('/staff-email/folders/');
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].folderType).toBe('inbox');
|
||||||
|
expect(result[0].emailCount).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transforms snake_case to camelCase', async () => {
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [mockFolderResponse] });
|
||||||
|
|
||||||
|
const result = await staffEmailApi.getFolders();
|
||||||
|
|
||||||
|
expect(result[0].createdAt).toBe('2024-01-01T00:00:00Z');
|
||||||
|
expect(result[0].updatedAt).toBe('2024-01-01T00:00:00Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createFolder', () => {
|
||||||
|
it('creates a new folder', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({
|
||||||
|
data: { ...mockFolderResponse, id: 2, name: 'Custom' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await staffEmailApi.createFolder('Custom');
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/staff-email/folders/', { name: 'Custom' });
|
||||||
|
expect(result.name).toBe('Custom');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateFolder', () => {
|
||||||
|
it('updates a folder name', async () => {
|
||||||
|
vi.mocked(apiClient.patch).mockResolvedValueOnce({
|
||||||
|
data: { ...mockFolderResponse, name: 'Updated' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await staffEmailApi.updateFolder(1, 'Updated');
|
||||||
|
|
||||||
|
expect(apiClient.patch).toHaveBeenCalledWith('/staff-email/folders/1/', { name: 'Updated' });
|
||||||
|
expect(result.name).toBe('Updated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteFolder', () => {
|
||||||
|
it('deletes a folder', async () => {
|
||||||
|
vi.mocked(apiClient.delete).mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await staffEmailApi.deleteFolder(1);
|
||||||
|
|
||||||
|
expect(apiClient.delete).toHaveBeenCalledWith('/staff-email/folders/1/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Email Operations', () => {
|
||||||
|
const mockEmailResponse = {
|
||||||
|
id: 1,
|
||||||
|
folder: 1,
|
||||||
|
from_address: 'sender@example.com',
|
||||||
|
from_name: 'Sender',
|
||||||
|
to_addresses: [{ email: 'recipient@example.com', name: 'Recipient' }],
|
||||||
|
subject: 'Test Email',
|
||||||
|
snippet: 'This is a test...',
|
||||||
|
status: 'received',
|
||||||
|
is_read: false,
|
||||||
|
is_starred: false,
|
||||||
|
is_important: false,
|
||||||
|
has_attachments: false,
|
||||||
|
attachment_count: 0,
|
||||||
|
thread_id: 'thread-1',
|
||||||
|
email_date: '2024-01-01T12:00:00Z',
|
||||||
|
created_at: '2024-01-01T12:00:00Z',
|
||||||
|
labels: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('getEmails', () => {
|
||||||
|
it('fetches emails with filters', async () => {
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
count: 1,
|
||||||
|
next: null,
|
||||||
|
previous: null,
|
||||||
|
results: [mockEmailResponse],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await staffEmailApi.getEmails({ folderId: 1 }, 1, 50);
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/staff-email/messages/')
|
||||||
|
);
|
||||||
|
expect(result.count).toBe(1);
|
||||||
|
expect(result.results).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles legacy array response', async () => {
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||||
|
data: [mockEmailResponse],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await staffEmailApi.getEmails({}, 1, 50);
|
||||||
|
|
||||||
|
expect(result.count).toBe(1);
|
||||||
|
expect(result.results).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies all filter parameters', async () => {
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||||
|
data: { count: 0, next: null, previous: null, results: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await staffEmailApi.getEmails({
|
||||||
|
folderId: 1,
|
||||||
|
emailAddressId: 2,
|
||||||
|
isRead: true,
|
||||||
|
isStarred: false,
|
||||||
|
search: 'test',
|
||||||
|
fromDate: '2024-01-01',
|
||||||
|
toDate: '2024-01-31',
|
||||||
|
});
|
||||||
|
|
||||||
|
const callUrl = vi.mocked(apiClient.get).mock.calls[0][0] as string;
|
||||||
|
expect(callUrl).toContain('folder=1');
|
||||||
|
expect(callUrl).toContain('email_address=2');
|
||||||
|
expect(callUrl).toContain('is_read=true');
|
||||||
|
expect(callUrl).toContain('is_starred=false');
|
||||||
|
expect(callUrl).toContain('search=test');
|
||||||
|
expect(callUrl).toContain('from_date=2024-01-01');
|
||||||
|
expect(callUrl).toContain('to_date=2024-01-31');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getEmail', () => {
|
||||||
|
it('fetches a single email by id', async () => {
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockEmailResponse });
|
||||||
|
|
||||||
|
const result = await staffEmailApi.getEmail(1);
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith('/staff-email/messages/1/');
|
||||||
|
expect(result.fromAddress).toBe('sender@example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getEmailThread', () => {
|
||||||
|
it('fetches emails in a thread', async () => {
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||||
|
data: { results: [mockEmailResponse] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await staffEmailApi.getEmailThread('thread-1');
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith('/staff-email/messages/', {
|
||||||
|
params: { thread_id: 'thread-1' },
|
||||||
|
});
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Draft Operations', () => {
|
||||||
|
describe('createDraft', () => {
|
||||||
|
it('creates a draft with formatted addresses', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
folder: 1,
|
||||||
|
subject: 'New Draft',
|
||||||
|
from_address: 'sender@example.com',
|
||||||
|
to_addresses: [{ email: 'recipient@example.com', name: '' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await staffEmailApi.createDraft({
|
||||||
|
emailAddressId: 1,
|
||||||
|
toAddresses: ['recipient@example.com'],
|
||||||
|
subject: 'New Draft',
|
||||||
|
bodyText: 'Body text',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/staff-email/messages/', expect.objectContaining({
|
||||||
|
email_address: 1,
|
||||||
|
to_addresses: [{ email: 'recipient@example.com', name: '' }],
|
||||||
|
subject: 'New Draft',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles "Name <email>" format', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({
|
||||||
|
data: { id: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await staffEmailApi.createDraft({
|
||||||
|
emailAddressId: 1,
|
||||||
|
toAddresses: ['John Doe <john@example.com>'],
|
||||||
|
subject: 'Test',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/staff-email/messages/', expect.objectContaining({
|
||||||
|
to_addresses: [{ email: 'john@example.com', name: 'John Doe' }],
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateDraft', () => {
|
||||||
|
it('updates draft subject', async () => {
|
||||||
|
vi.mocked(apiClient.patch).mockResolvedValueOnce({
|
||||||
|
data: { id: 1, subject: 'Updated Subject' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await staffEmailApi.updateDraft(1, { subject: 'Updated Subject' });
|
||||||
|
|
||||||
|
expect(apiClient.patch).toHaveBeenCalledWith('/staff-email/messages/1/', {
|
||||||
|
subject: 'Updated Subject',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteDraft', () => {
|
||||||
|
it('deletes a draft', async () => {
|
||||||
|
vi.mocked(apiClient.delete).mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await staffEmailApi.deleteDraft(1);
|
||||||
|
|
||||||
|
expect(apiClient.delete).toHaveBeenCalledWith('/staff-email/messages/1/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Send/Reply/Forward', () => {
|
||||||
|
describe('sendEmail', () => {
|
||||||
|
it('sends a draft', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({
|
||||||
|
data: { id: 1, status: 'sent' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await staffEmailApi.sendEmail(1);
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/staff-email/messages/1/send/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('replyToEmail', () => {
|
||||||
|
it('replies to an email', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({
|
||||||
|
data: { id: 2, in_reply_to: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await staffEmailApi.replyToEmail(1, {
|
||||||
|
bodyText: 'Reply body',
|
||||||
|
replyAll: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/staff-email/messages/1/reply/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('forwardEmail', () => {
|
||||||
|
it('forwards an email', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({
|
||||||
|
data: { id: 3 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await staffEmailApi.forwardEmail(1, {
|
||||||
|
toAddresses: ['forward@example.com'],
|
||||||
|
bodyText: 'FW: Original message',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/staff-email/messages/1/forward/', expect.objectContaining({
|
||||||
|
to_addresses: [{ email: 'forward@example.com', name: '' }],
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Email Actions', () => {
|
||||||
|
describe('markAsRead', () => {
|
||||||
|
it('marks email as read', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await staffEmailApi.markAsRead(1);
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/staff-email/messages/1/mark_read/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markAsUnread', () => {
|
||||||
|
it('marks email as unread', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await staffEmailApi.markAsUnread(1);
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/staff-email/messages/1/mark_unread/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('starEmail', () => {
|
||||||
|
it('stars an email', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await staffEmailApi.starEmail(1);
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/staff-email/messages/1/star/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unstarEmail', () => {
|
||||||
|
it('unstars an email', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await staffEmailApi.unstarEmail(1);
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/staff-email/messages/1/unstar/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('archiveEmail', () => {
|
||||||
|
it('archives an email', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await staffEmailApi.archiveEmail(1);
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/staff-email/messages/1/archive/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('trashEmail', () => {
|
||||||
|
it('moves email to trash', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await staffEmailApi.trashEmail(1);
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/staff-email/messages/1/trash/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('restoreEmail', () => {
|
||||||
|
it('restores email from trash', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await staffEmailApi.restoreEmail(1);
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/staff-email/messages/1/restore/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('permanentlyDeleteEmail', () => {
|
||||||
|
it('permanently deletes an email', async () => {
|
||||||
|
vi.mocked(apiClient.delete).mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await staffEmailApi.permanentlyDeleteEmail(1);
|
||||||
|
|
||||||
|
expect(apiClient.delete).toHaveBeenCalledWith('/staff-email/messages/1/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('moveEmails', () => {
|
||||||
|
it('moves emails to a folder', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await staffEmailApi.moveEmails({ emailIds: [1, 2, 3], folderId: 2 });
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/staff-email/messages/move/', {
|
||||||
|
email_ids: [1, 2, 3],
|
||||||
|
folder_id: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bulkAction', () => {
|
||||||
|
it('performs bulk action on emails', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await staffEmailApi.bulkAction({ emailIds: [1, 2], action: 'mark_read' });
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/staff-email/messages/bulk_action/', {
|
||||||
|
email_ids: [1, 2],
|
||||||
|
action: 'mark_read',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Labels', () => {
|
||||||
|
const mockLabelResponse = {
|
||||||
|
id: 1,
|
||||||
|
owner: 1,
|
||||||
|
name: 'Important',
|
||||||
|
color: '#ef4444',
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('getLabels', () => {
|
||||||
|
it('fetches all labels', async () => {
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [mockLabelResponse] });
|
||||||
|
|
||||||
|
const result = await staffEmailApi.getLabels();
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith('/staff-email/labels/');
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].name).toBe('Important');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createLabel', () => {
|
||||||
|
it('creates a new label', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({
|
||||||
|
data: { ...mockLabelResponse, id: 2, name: 'Work', color: '#10b981' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await staffEmailApi.createLabel('Work', '#10b981');
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/staff-email/labels/', { name: 'Work', color: '#10b981' });
|
||||||
|
expect(result.name).toBe('Work');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateLabel', () => {
|
||||||
|
it('updates a label', async () => {
|
||||||
|
vi.mocked(apiClient.patch).mockResolvedValueOnce({
|
||||||
|
data: { ...mockLabelResponse, name: 'Updated' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await staffEmailApi.updateLabel(1, { name: 'Updated' });
|
||||||
|
|
||||||
|
expect(apiClient.patch).toHaveBeenCalledWith('/staff-email/labels/1/', { name: 'Updated' });
|
||||||
|
expect(result.name).toBe('Updated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteLabel', () => {
|
||||||
|
it('deletes a label', async () => {
|
||||||
|
vi.mocked(apiClient.delete).mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await staffEmailApi.deleteLabel(1);
|
||||||
|
|
||||||
|
expect(apiClient.delete).toHaveBeenCalledWith('/staff-email/labels/1/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addLabelToEmail', () => {
|
||||||
|
it('adds label to email', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await staffEmailApi.addLabelToEmail(1, 2);
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/staff-email/messages/1/add_label/', { label_id: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeLabelFromEmail', () => {
|
||||||
|
it('removes label from email', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await staffEmailApi.removeLabelFromEmail(1, 2);
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/staff-email/messages/1/remove_label/', { label_id: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Contacts', () => {
|
||||||
|
describe('searchContacts', () => {
|
||||||
|
it('searches contacts', async () => {
|
||||||
|
const mockContacts = [
|
||||||
|
{ id: 1, owner: 1, email: 'test@example.com', name: 'Test', use_count: 5, last_used_at: '2024-01-01' },
|
||||||
|
];
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockContacts });
|
||||||
|
|
||||||
|
const result = await staffEmailApi.searchContacts('test');
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith('/staff-email/contacts/', {
|
||||||
|
params: { search: 'test' },
|
||||||
|
});
|
||||||
|
expect(result[0].email).toBe('test@example.com');
|
||||||
|
expect(result[0].useCount).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Attachments', () => {
|
||||||
|
describe('uploadAttachment', () => {
|
||||||
|
it('uploads a file attachment', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
id: 1,
|
||||||
|
filename: 'test.pdf',
|
||||||
|
content_type: 'application/pdf',
|
||||||
|
size: 1024,
|
||||||
|
url: 'https://example.com/test.pdf',
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
|
||||||
|
|
||||||
|
const file = new File(['content'], 'test.pdf', { type: 'application/pdf' });
|
||||||
|
const result = await staffEmailApi.uploadAttachment(file, 1);
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
'/staff-email/attachments/',
|
||||||
|
expect.any(FormData),
|
||||||
|
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||||
|
);
|
||||||
|
expect(result.filename).toBe('test.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uploads attachment without email id', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({
|
||||||
|
data: { id: 1, filename: 'test.pdf' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const file = new File(['content'], 'test.pdf', { type: 'application/pdf' });
|
||||||
|
await staffEmailApi.uploadAttachment(file);
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteAttachment', () => {
|
||||||
|
it('deletes an attachment', async () => {
|
||||||
|
vi.mocked(apiClient.delete).mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await staffEmailApi.deleteAttachment(1);
|
||||||
|
|
||||||
|
expect(apiClient.delete).toHaveBeenCalledWith('/staff-email/attachments/1/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Sync', () => {
|
||||||
|
describe('syncEmails', () => {
|
||||||
|
it('triggers email sync', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({
|
||||||
|
data: { success: true, message: 'Synced' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await staffEmailApi.syncEmails();
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/staff-email/messages/sync/');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fullSyncEmails', () => {
|
||||||
|
it('triggers full email sync', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
status: 'started',
|
||||||
|
tasks: [{ email_address: 'user@example.com', task_id: 'task-1' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await staffEmailApi.fullSyncEmails();
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/staff-email/messages/full_sync/');
|
||||||
|
expect(result.status).toBe('started');
|
||||||
|
expect(result.tasks).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('User Email Addresses', () => {
|
||||||
|
describe('getUserEmailAddresses', () => {
|
||||||
|
it('fetches user email addresses', async () => {
|
||||||
|
const mockAddresses = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
email_address: 'user@example.com',
|
||||||
|
display_name: 'User',
|
||||||
|
color: '#3b82f6',
|
||||||
|
is_default: true,
|
||||||
|
last_check_at: '2024-01-01T00:00:00Z',
|
||||||
|
emails_processed_count: 100,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockAddresses });
|
||||||
|
|
||||||
|
const result = await staffEmailApi.getUserEmailAddresses();
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith('/staff-email/messages/email_addresses/');
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].email_address).toBe('user@example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
36
frontend/src/api/activepieces.ts
Normal file
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);
|
return apiClient(originalRequest);
|
||||||
}
|
}
|
||||||
} catch (refreshError) {
|
} 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 { deleteCookie } = await import('../utils/cookies');
|
||||||
const { getBaseDomain } = await import('../utils/domain');
|
const { getBaseDomain } = await import('../utils/domain');
|
||||||
deleteCookie('access_token');
|
deleteCookie('access_token');
|
||||||
@@ -96,7 +96,16 @@ apiClient.interceptors.response.use(
|
|||||||
const protocol = window.location.protocol;
|
const protocol = window.location.protocol;
|
||||||
const baseDomain = getBaseDomain();
|
const baseDomain = getBaseDomain();
|
||||||
const port = window.location.port ? `:${window.location.port}` : '';
|
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);
|
return Promise.reject(refreshError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export async function deleteAlbum(id: number): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
export async function listMediaFiles(albumId?: number | 'null'): Promise<MediaFile[]> {
|
export async function listMediaFiles(albumId?: number | 'null'): Promise<MediaFile[]> {
|
||||||
const params = albumId !== undefined ? { album: albumId } : {};
|
const params = albumId !== undefined ? { album: albumId } : {};
|
||||||
const response = await apiClient.get('/media/', { params });
|
const response = await apiClient.get('/media-files/', { params });
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ export async function listMediaFiles(albumId?: number | 'null'): Promise<MediaFi
|
|||||||
* Get a single media file
|
* Get a single media file
|
||||||
*/
|
*/
|
||||||
export async function getMediaFile(id: number): Promise<MediaFile> {
|
export async function getMediaFile(id: number): Promise<MediaFile> {
|
||||||
const response = await apiClient.get(`/media/${id}/`);
|
const response = await apiClient.get(`/media-files/${id}/`);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ export async function uploadMediaFile(
|
|||||||
formData.append('alt_text', altText);
|
formData.append('alt_text', altText);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiClient.post('/media/', formData, {
|
const response = await apiClient.post('/media-files/', formData, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
@@ -167,7 +167,7 @@ export async function updateMediaFile(
|
|||||||
id: number,
|
id: number,
|
||||||
data: MediaFileUpdatePayload
|
data: MediaFileUpdatePayload
|
||||||
): Promise<MediaFile> {
|
): Promise<MediaFile> {
|
||||||
const response = await apiClient.patch(`/media/${id}/`, data);
|
const response = await apiClient.patch(`/media-files/${id}/`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ export async function updateMediaFile(
|
|||||||
* Delete a media file
|
* Delete a media file
|
||||||
*/
|
*/
|
||||||
export async function deleteMediaFile(id: number): Promise<void> {
|
export async function deleteMediaFile(id: number): Promise<void> {
|
||||||
await apiClient.delete(`/media/${id}/`);
|
await apiClient.delete(`/media-files/${id}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -185,7 +185,7 @@ export async function bulkMoveFiles(
|
|||||||
fileIds: number[],
|
fileIds: number[],
|
||||||
albumId: number | null
|
albumId: number | null
|
||||||
): Promise<{ updated: number }> {
|
): Promise<{ updated: number }> {
|
||||||
const response = await apiClient.post('/media/bulk_move/', {
|
const response = await apiClient.post('/media-files/bulk_move/', {
|
||||||
file_ids: fileIds,
|
file_ids: fileIds,
|
||||||
album_id: albumId,
|
album_id: albumId,
|
||||||
});
|
});
|
||||||
@@ -196,7 +196,7 @@ export async function bulkMoveFiles(
|
|||||||
* Delete multiple files
|
* Delete multiple files
|
||||||
*/
|
*/
|
||||||
export async function bulkDeleteFiles(fileIds: number[]): Promise<{ deleted: number }> {
|
export async function bulkDeleteFiles(fileIds: number[]): Promise<{ deleted: number }> {
|
||||||
const response = await apiClient.post('/media/bulk_delete/', {
|
const response = await apiClient.post('/media-files/bulk_delete/', {
|
||||||
file_ids: fileIds,
|
file_ids: fileIds,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -543,3 +543,109 @@ export const reactivateSubscription = (subscriptionId: string) =>
|
|||||||
apiClient.post<ReactivateSubscriptionResponse>('/payments/subscriptions/reactivate/', {
|
apiClient.post<ReactivateSubscriptionResponse>('/payments/subscriptions/reactivate/', {
|
||||||
subscription_id: subscriptionId,
|
subscription_id: subscriptionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Stripe Settings (Connect Accounts)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type PayoutInterval = 'daily' | 'weekly' | 'monthly' | 'manual';
|
||||||
|
export type WeeklyAnchor = 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday';
|
||||||
|
|
||||||
|
export interface PayoutSchedule {
|
||||||
|
interval: PayoutInterval;
|
||||||
|
delay_days: number;
|
||||||
|
weekly_anchor: WeeklyAnchor | null;
|
||||||
|
monthly_anchor: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PayoutSettings {
|
||||||
|
schedule: PayoutSchedule;
|
||||||
|
statement_descriptor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusinessProfile {
|
||||||
|
name: string;
|
||||||
|
support_email: string;
|
||||||
|
support_phone: string;
|
||||||
|
support_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrandingSettings {
|
||||||
|
primary_color: string;
|
||||||
|
secondary_color: string;
|
||||||
|
icon: string;
|
||||||
|
logo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankAccount {
|
||||||
|
id: string;
|
||||||
|
bank_name: string;
|
||||||
|
last4: string;
|
||||||
|
currency: string;
|
||||||
|
default_for_currency: boolean;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StripeSettings {
|
||||||
|
payouts: PayoutSettings;
|
||||||
|
business_profile: BusinessProfile;
|
||||||
|
branding: BrandingSettings;
|
||||||
|
bank_accounts: BankAccount[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StripeSettingsUpdatePayouts {
|
||||||
|
schedule?: Partial<PayoutSchedule>;
|
||||||
|
statement_descriptor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StripeSettingsUpdate {
|
||||||
|
payouts?: StripeSettingsUpdatePayouts;
|
||||||
|
business_profile?: Partial<BusinessProfile>;
|
||||||
|
branding?: Pick<BrandingSettings, 'primary_color' | 'secondary_color'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StripeSettingsUpdateResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StripeSettingsErrorResponse {
|
||||||
|
errors: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Stripe account settings for Connect accounts.
|
||||||
|
* Includes payout schedule, business profile, branding, and bank accounts.
|
||||||
|
*/
|
||||||
|
export const getStripeSettings = () =>
|
||||||
|
apiClient.get<StripeSettings>('/payments/settings/');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Stripe account settings.
|
||||||
|
* Can update payout settings, business profile, or branding.
|
||||||
|
*/
|
||||||
|
export const updateStripeSettings = (updates: StripeSettingsUpdate) =>
|
||||||
|
apiClient.patch<StripeSettingsUpdateResponse>('/payments/settings/', updates);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Connect Login Link
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface LoginLinkRequest {
|
||||||
|
return_url?: string;
|
||||||
|
refresh_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginLinkResponse {
|
||||||
|
url: string;
|
||||||
|
type: 'login_link' | 'account_link';
|
||||||
|
expires_at?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a dashboard link for the Connect account.
|
||||||
|
* For Express accounts: Returns a one-time login link.
|
||||||
|
* For Custom accounts: Returns an account link (requires return/refresh URLs).
|
||||||
|
*/
|
||||||
|
export const createConnectLoginLink = (request?: LoginLinkRequest) =>
|
||||||
|
apiClient.post<LoginLinkResponse>('/payments/connect/login-link/', request || {});
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export interface PlatformEmailAddressListItem {
|
|||||||
email_address: string;
|
email_address: string;
|
||||||
color: string;
|
color: string;
|
||||||
assigned_user?: AssignedUser | null;
|
assigned_user?: AssignedUser | null;
|
||||||
|
routing_mode: 'PLATFORM' | 'STAFF';
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
mail_server_synced: boolean;
|
mail_server_synced: boolean;
|
||||||
@@ -78,6 +79,7 @@ export interface PlatformEmailAddressCreate {
|
|||||||
domain: string;
|
domain: string;
|
||||||
color: string;
|
color: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
routing_mode?: 'PLATFORM' | 'STAFF';
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
}
|
}
|
||||||
@@ -88,6 +90,7 @@ export interface PlatformEmailAddressUpdate {
|
|||||||
assigned_user_id?: number | null;
|
assigned_user_id?: number | null;
|
||||||
color?: string;
|
color?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
routing_mode?: 'PLATFORM' | 'STAFF';
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
is_default?: boolean;
|
is_default?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
442
frontend/src/api/staffEmail.ts
Normal file
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ export interface CatalogItem {
|
|||||||
|
|
||||||
export interface CatalogListPanelProps {
|
export interface CatalogListPanelProps {
|
||||||
items: CatalogItem[];
|
items: CatalogItem[];
|
||||||
selectedId: number | null;
|
selectedItem: CatalogItem | null;
|
||||||
onSelect: (item: CatalogItem) => void;
|
onSelect: (item: CatalogItem) => void;
|
||||||
onCreatePlan: () => void;
|
onCreatePlan: () => void;
|
||||||
onCreateAddon: () => void;
|
onCreateAddon: () => void;
|
||||||
@@ -47,7 +47,7 @@ type LegacyFilter = 'all' | 'current' | 'legacy';
|
|||||||
|
|
||||||
export const CatalogListPanel: React.FC<CatalogListPanelProps> = ({
|
export const CatalogListPanel: React.FC<CatalogListPanelProps> = ({
|
||||||
items,
|
items,
|
||||||
selectedId,
|
selectedItem,
|
||||||
onSelect,
|
onSelect,
|
||||||
onCreatePlan,
|
onCreatePlan,
|
||||||
onCreateAddon,
|
onCreateAddon,
|
||||||
@@ -219,7 +219,7 @@ export const CatalogListPanel: React.FC<CatalogListPanelProps> = ({
|
|||||||
<CatalogListItem
|
<CatalogListItem
|
||||||
key={`${item.type}-${item.id}`}
|
key={`${item.type}-${item.id}`}
|
||||||
item={item}
|
item={item}
|
||||||
isSelected={selectedId === item.id}
|
isSelected={selectedItem?.id === item.id && selectedItem?.type === item.type}
|
||||||
onSelect={() => onSelect(item)}
|
onSelect={() => onSelect(item)}
|
||||||
formatPrice={formatPrice}
|
formatPrice={formatPrice}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -171,6 +171,9 @@ export const FeaturePicker: React.FC<FeaturePickerProps> = ({
|
|||||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
{feature.name}
|
{feature.name}
|
||||||
</span>
|
</span>
|
||||||
|
<code className="text-xs text-gray-400 dark:text-gray-500 block mt-0.5 font-mono">
|
||||||
|
{feature.code}
|
||||||
|
</code>
|
||||||
{feature.description && (
|
{feature.description && (
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
|
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
|
||||||
{feature.description}
|
{feature.description}
|
||||||
@@ -207,17 +210,22 @@ export const FeaturePicker: React.FC<FeaturePickerProps> = ({
|
|||||||
: 'border-gray-200 dark:border-gray-700'
|
: 'border-gray-200 dark:border-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<label className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer">
|
<label className="flex items-start gap-3 flex-1 min-w-0 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selected}
|
checked={selected}
|
||||||
onChange={() => toggleIntegerFeature(feature.code)}
|
onChange={() => toggleIntegerFeature(feature.code)}
|
||||||
aria-label={feature.name}
|
aria-label={feature.name}
|
||||||
className="rounded border-gray-300 dark:border-gray-600"
|
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium text-gray-900 dark:text-white truncate flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{feature.name}
|
<span className="text-sm font-medium text-gray-900 dark:text-white block">
|
||||||
</span>
|
{feature.name}
|
||||||
|
</span>
|
||||||
|
<code className="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||||
|
{feature.code}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
{selected && (
|
{selected && (
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ const allItems = [...mockPlans, ...mockAddons];
|
|||||||
describe('CatalogListPanel', () => {
|
describe('CatalogListPanel', () => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
items: allItems,
|
items: allItems,
|
||||||
selectedId: null,
|
selectedItem: null,
|
||||||
onSelect: vi.fn(),
|
onSelect: vi.fn(),
|
||||||
onCreatePlan: vi.fn(),
|
onCreatePlan: vi.fn(),
|
||||||
onCreateAddon: vi.fn(),
|
onCreateAddon: vi.fn(),
|
||||||
@@ -403,7 +403,8 @@ describe('CatalogListPanel', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('highlights the selected item', () => {
|
it('highlights the selected item', () => {
|
||||||
render(<CatalogListPanel {...defaultProps} selectedId={2} />);
|
const selectedItem = mockPlans.find(p => p.id === 2)!;
|
||||||
|
render(<CatalogListPanel {...defaultProps} selectedItem={selectedItem} />);
|
||||||
|
|
||||||
// The selected item should have a different style
|
// The selected item should have a different style
|
||||||
const starterItem = screen.getByText('Starter').closest('button');
|
const starterItem = screen.getByText('Starter').closest('button');
|
||||||
|
|||||||
@@ -164,7 +164,10 @@ describe('FeaturePicker', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Canonical Catalog Validation', () => {
|
describe('Canonical Catalog Validation', () => {
|
||||||
it('shows warning badge for features not in canonical catalog', () => {
|
// Note: The FeaturePicker component currently does not implement
|
||||||
|
// canonical catalog validation. These tests are skipped until
|
||||||
|
// the feature is implemented.
|
||||||
|
it.skip('shows warning badge for features not in canonical catalog', () => {
|
||||||
render(<FeaturePicker {...defaultProps} />);
|
render(<FeaturePicker {...defaultProps} />);
|
||||||
|
|
||||||
// custom_feature is not in the canonical catalog
|
// custom_feature is not in the canonical catalog
|
||||||
@@ -183,6 +186,7 @@ describe('FeaturePicker', () => {
|
|||||||
const smsFeatureRow = screen.getByText('SMS Enabled').closest('label');
|
const smsFeatureRow = screen.getByText('SMS Enabled').closest('label');
|
||||||
expect(smsFeatureRow).toBeInTheDocument();
|
expect(smsFeatureRow).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Component doesn't implement warning badges, so none should exist
|
||||||
const warningIndicator = within(smsFeatureRow!).queryByTitle(/not in canonical catalog/i);
|
const warningIndicator = within(smsFeatureRow!).queryByTitle(/not in canonical catalog/i);
|
||||||
expect(warningIndicator).not.toBeInTheDocument();
|
expect(warningIndicator).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
X,
|
X,
|
||||||
|
FlaskConical,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useApiTokens,
|
useApiTokens,
|
||||||
@@ -26,14 +27,16 @@ import {
|
|||||||
APIToken,
|
APIToken,
|
||||||
APITokenCreateResponse,
|
APITokenCreateResponse,
|
||||||
} from '../hooks/useApiTokens';
|
} from '../hooks/useApiTokens';
|
||||||
|
import { useSandbox } from '../contexts/SandboxContext';
|
||||||
|
|
||||||
interface NewTokenModalProps {
|
interface NewTokenModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onTokenCreated: (token: APITokenCreateResponse) => void;
|
onTokenCreated: (token: APITokenCreateResponse) => void;
|
||||||
|
isSandbox: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NewTokenModal: React.FC<NewTokenModalProps> = ({ isOpen, onClose, onTokenCreated }) => {
|
const NewTokenModal: React.FC<NewTokenModalProps> = ({ isOpen, onClose, onTokenCreated, isSandbox }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [selectedScopes, setSelectedScopes] = useState<string[]>([]);
|
const [selectedScopes, setSelectedScopes] = useState<string[]>([]);
|
||||||
@@ -84,6 +87,7 @@ const NewTokenModal: React.FC<NewTokenModalProps> = ({ isOpen, onClose, onTokenC
|
|||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
scopes: selectedScopes,
|
scopes: selectedScopes,
|
||||||
expires_at: calculateExpiryDate(),
|
expires_at: calculateExpiryDate(),
|
||||||
|
is_sandbox: isSandbox,
|
||||||
});
|
});
|
||||||
onTokenCreated(result);
|
onTokenCreated(result);
|
||||||
setName('');
|
setName('');
|
||||||
@@ -101,9 +105,17 @@ const NewTokenModal: React.FC<NewTokenModalProps> = ({ isOpen, onClose, onTokenC
|
|||||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
<div className="flex items-center gap-3">
|
||||||
Create API Token
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
</h2>
|
Create API Token
|
||||||
|
</h2>
|
||||||
|
{isSandbox && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-full">
|
||||||
|
<FlaskConical size={12} />
|
||||||
|
Test Token
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
@@ -488,12 +500,16 @@ const TokenRow: React.FC<TokenRowProps> = ({ token, onRevoke, isRevoking }) => {
|
|||||||
|
|
||||||
const ApiTokensSection: React.FC = () => {
|
const ApiTokensSection: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { isSandbox } = useSandbox();
|
||||||
const { data: tokens, isLoading, error } = useApiTokens();
|
const { data: tokens, isLoading, error } = useApiTokens();
|
||||||
const revokeMutation = useRevokeApiToken();
|
const revokeMutation = useRevokeApiToken();
|
||||||
const [showNewTokenModal, setShowNewTokenModal] = useState(false);
|
const [showNewTokenModal, setShowNewTokenModal] = useState(false);
|
||||||
const [createdToken, setCreatedToken] = useState<APITokenCreateResponse | null>(null);
|
const [createdToken, setCreatedToken] = useState<APITokenCreateResponse | null>(null);
|
||||||
const [tokenToRevoke, setTokenToRevoke] = useState<{ id: string; name: string } | null>(null);
|
const [tokenToRevoke, setTokenToRevoke] = useState<{ id: string; name: string } | null>(null);
|
||||||
|
|
||||||
|
// Filter tokens based on sandbox mode - only show test tokens in sandbox, live tokens otherwise
|
||||||
|
const filteredTokens = tokens?.filter(t => t.is_sandbox === isSandbox) || [];
|
||||||
|
|
||||||
const handleTokenCreated = (token: APITokenCreateResponse) => {
|
const handleTokenCreated = (token: APITokenCreateResponse) => {
|
||||||
setShowNewTokenModal(false);
|
setShowNewTokenModal(false);
|
||||||
setCreatedToken(token);
|
setCreatedToken(token);
|
||||||
@@ -509,8 +525,8 @@ const ApiTokensSection: React.FC = () => {
|
|||||||
await revokeMutation.mutateAsync(tokenToRevoke.id);
|
await revokeMutation.mutateAsync(tokenToRevoke.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeTokens = tokens?.filter(t => t.is_active) || [];
|
const activeTokens = filteredTokens.filter(t => t.is_active);
|
||||||
const revokedTokens = tokens?.filter(t => !t.is_active) || [];
|
const revokedTokens = filteredTokens.filter(t => !t.is_active);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -559,14 +575,23 @@ const ApiTokensSection: React.FC = () => {
|
|||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
<Key size={20} className="text-brand-500" />
|
<Key size={20} className="text-brand-500" />
|
||||||
API Tokens
|
API Tokens
|
||||||
|
{isSandbox && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-full">
|
||||||
|
<FlaskConical size={12} />
|
||||||
|
Test Mode
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
Create and manage API tokens for third-party integrations
|
{isSandbox
|
||||||
|
? 'Create and manage test tokens for development and testing'
|
||||||
|
: 'Create and manage API tokens for third-party integrations'
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<a
|
<a
|
||||||
href="/help/api"
|
href="/dashboard/help/api"
|
||||||
className="px-3 py-2 text-sm font-medium text-brand-600 dark:text-brand-400 hover:bg-brand-50 dark:hover:bg-brand-900/20 rounded-lg transition-colors flex items-center gap-2"
|
className="px-3 py-2 text-sm font-medium text-brand-600 dark:text-brand-400 hover:bg-brand-50 dark:hover:bg-brand-900/20 rounded-lg transition-colors flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<ExternalLink size={16} />
|
<ExternalLink size={16} />
|
||||||
@@ -577,7 +602,7 @@ const ApiTokensSection: React.FC = () => {
|
|||||||
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 hover:bg-brand-700 rounded-lg transition-colors flex items-center gap-2"
|
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 hover:bg-brand-700 rounded-lg transition-colors flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
New Token
|
{isSandbox ? 'New Test Token' : 'New Token'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -592,23 +617,32 @@ const ApiTokensSection: React.FC = () => {
|
|||||||
Failed to load API tokens. Please try again later.
|
Failed to load API tokens. Please try again later.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : tokens && tokens.length === 0 ? (
|
) : filteredTokens.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-full mb-4">
|
<div className={`inline-flex items-center justify-center w-16 h-16 rounded-full mb-4 ${
|
||||||
<Key size={32} className="text-gray-400" />
|
isSandbox ? 'bg-amber-100 dark:bg-amber-900/30' : 'bg-gray-100 dark:bg-gray-700'
|
||||||
|
}`}>
|
||||||
|
{isSandbox ? (
|
||||||
|
<FlaskConical size={32} className="text-amber-500" />
|
||||||
|
) : (
|
||||||
|
<Key size={32} className="text-gray-400" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||||
No API tokens yet
|
{isSandbox ? 'No test tokens yet' : 'No API tokens yet'}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4 max-w-sm mx-auto">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4 max-w-sm mx-auto">
|
||||||
Create your first API token to start integrating with external services and applications.
|
{isSandbox
|
||||||
|
? 'Create a test token to try out the API without affecting live data.'
|
||||||
|
: 'Create your first API token to start integrating with external services and applications.'
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowNewTokenModal(true)}
|
onClick={() => setShowNewTokenModal(true)}
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 hover:bg-brand-700 rounded-lg transition-colors inline-flex items-center gap-2"
|
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 hover:bg-brand-700 rounded-lg transition-colors inline-flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
Create API Token
|
{isSandbox ? 'Create Test Token' : 'Create API Token'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -659,6 +693,7 @@ const ApiTokensSection: React.FC = () => {
|
|||||||
isOpen={showNewTokenModal}
|
isOpen={showNewTokenModal}
|
||||||
onClose={() => setShowNewTokenModal(false)}
|
onClose={() => setShowNewTokenModal(false)}
|
||||||
onTokenCreated={handleTokenCreated}
|
onTokenCreated={handleTokenCreated}
|
||||||
|
isSandbox={isSandbox}
|
||||||
/>
|
/>
|
||||||
<TokenCreatedModal
|
<TokenCreatedModal
|
||||||
token={createdToken}
|
token={createdToken}
|
||||||
|
|||||||
738
frontend/src/components/AppointmentModal.tsx
Normal file
738
frontend/src/components/AppointmentModal.tsx
Normal file
@@ -0,0 +1,738 @@
|
|||||||
|
/**
|
||||||
|
* AppointmentModal Component
|
||||||
|
*
|
||||||
|
* Unified modal for creating and editing appointments.
|
||||||
|
* Features:
|
||||||
|
* - Multi-select customer autocomplete with "Add new customer" option
|
||||||
|
* - Service selection with addon support
|
||||||
|
* - Participant management (additional staff)
|
||||||
|
* - Status management (edit mode only)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { X, Search, User as UserIcon, Calendar, Clock, Users, Plus, Package, Check, Loader2 } from 'lucide-react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { Resource, Service, ParticipantInput, Customer, Appointment, AppointmentStatus } from '../types';
|
||||||
|
import { useCustomers, useCreateCustomer } from '../hooks/useCustomers';
|
||||||
|
import { useServiceAddons } from '../hooks/useServiceAddons';
|
||||||
|
import { ParticipantSelector } from './ParticipantSelector';
|
||||||
|
|
||||||
|
interface BaseModalProps {
|
||||||
|
resources: Resource[];
|
||||||
|
services: Service[];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateModeProps extends BaseModalProps {
|
||||||
|
mode: 'create';
|
||||||
|
initialDate?: Date;
|
||||||
|
initialResourceId?: string | null;
|
||||||
|
onCreate: (appointmentData: {
|
||||||
|
serviceId: string;
|
||||||
|
customerIds: string[];
|
||||||
|
startTime: Date;
|
||||||
|
resourceId?: string | null;
|
||||||
|
durationMinutes: number;
|
||||||
|
notes?: string;
|
||||||
|
participantsInput?: ParticipantInput[];
|
||||||
|
addonIds?: number[];
|
||||||
|
}) => void;
|
||||||
|
isSubmitting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditModeProps extends BaseModalProps {
|
||||||
|
mode: 'edit';
|
||||||
|
appointment: Appointment & {
|
||||||
|
customerEmail?: string;
|
||||||
|
customerPhone?: string;
|
||||||
|
};
|
||||||
|
onSave: (updates: {
|
||||||
|
serviceId?: string;
|
||||||
|
customerIds?: string[];
|
||||||
|
startTime?: Date;
|
||||||
|
resourceId?: string | null;
|
||||||
|
durationMinutes?: number;
|
||||||
|
status?: AppointmentStatus;
|
||||||
|
notes?: string;
|
||||||
|
participantsInput?: ParticipantInput[];
|
||||||
|
addonIds?: number[];
|
||||||
|
}) => void;
|
||||||
|
isSubmitting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppointmentModalProps = CreateModeProps | EditModeProps;
|
||||||
|
|
||||||
|
// Mini form for creating a new customer inline
|
||||||
|
interface NewCustomerFormData {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppointmentModal: React.FC<AppointmentModalProps> = (props) => {
|
||||||
|
const { resources, services, onClose } = props;
|
||||||
|
const isEditMode = props.mode === 'edit';
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [selectedServiceId, setSelectedServiceId] = useState('');
|
||||||
|
const [selectedCustomers, setSelectedCustomers] = useState<Customer[]>([]);
|
||||||
|
const [customerSearch, setCustomerSearch] = useState('');
|
||||||
|
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
|
||||||
|
const [selectedDateTime, setSelectedDateTime] = useState('');
|
||||||
|
const [selectedResourceId, setSelectedResourceId] = useState('');
|
||||||
|
const [duration, setDuration] = useState(30);
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [participants, setParticipants] = useState<ParticipantInput[]>([]);
|
||||||
|
const [selectedAddonIds, setSelectedAddonIds] = useState<number[]>([]);
|
||||||
|
const [status, setStatus] = useState<AppointmentStatus>('SCHEDULED');
|
||||||
|
|
||||||
|
// New customer form state
|
||||||
|
const [showNewCustomerForm, setShowNewCustomerForm] = useState(false);
|
||||||
|
const [newCustomerData, setNewCustomerData] = useState<NewCustomerFormData>({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize form state based on mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditMode) {
|
||||||
|
const appointment = (props as EditModeProps).appointment;
|
||||||
|
|
||||||
|
// Set service
|
||||||
|
setSelectedServiceId(appointment.serviceId || '');
|
||||||
|
|
||||||
|
// Set customer(s) - convert from appointment data
|
||||||
|
if (appointment.customerName) {
|
||||||
|
const customerData: Customer = {
|
||||||
|
id: appointment.customerId || '',
|
||||||
|
name: appointment.customerName,
|
||||||
|
email: appointment.customerEmail || '',
|
||||||
|
phone: appointment.customerPhone || '',
|
||||||
|
userId: appointment.customerId || '',
|
||||||
|
};
|
||||||
|
setSelectedCustomers([customerData]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set date/time
|
||||||
|
const startTime = appointment.startTime;
|
||||||
|
const localDateTime = new Date(startTime.getTime() - startTime.getTimezoneOffset() * 60000)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 16);
|
||||||
|
setSelectedDateTime(localDateTime);
|
||||||
|
|
||||||
|
// Set other fields
|
||||||
|
setSelectedResourceId(appointment.resourceId || '');
|
||||||
|
setDuration(appointment.durationMinutes || 30);
|
||||||
|
setNotes(appointment.notes || '');
|
||||||
|
setStatus(appointment.status || 'SCHEDULED');
|
||||||
|
|
||||||
|
// Set addons if present
|
||||||
|
if (appointment.addonIds) {
|
||||||
|
setSelectedAddonIds(appointment.addonIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize staff participants from existing appointment participants
|
||||||
|
if (appointment.participants) {
|
||||||
|
const staffParticipants: ParticipantInput[] = appointment.participants
|
||||||
|
.filter(p => p.role === 'STAFF')
|
||||||
|
.map(p => ({
|
||||||
|
role: 'STAFF' as const,
|
||||||
|
userId: p.userId ? parseInt(p.userId) : undefined,
|
||||||
|
resourceId: p.resourceId ? parseInt(p.resourceId) : undefined,
|
||||||
|
externalEmail: p.externalEmail,
|
||||||
|
externalName: p.externalName,
|
||||||
|
}));
|
||||||
|
setParticipants(staffParticipants);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create mode - set defaults
|
||||||
|
const createProps = props as CreateModeProps;
|
||||||
|
const date = createProps.initialDate || new Date();
|
||||||
|
const minutes = Math.ceil(date.getMinutes() / 15) * 15;
|
||||||
|
date.setMinutes(minutes, 0, 0);
|
||||||
|
const localDateTime = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 16);
|
||||||
|
setSelectedDateTime(localDateTime);
|
||||||
|
setSelectedResourceId(createProps.initialResourceId || '');
|
||||||
|
}
|
||||||
|
}, [isEditMode, props]);
|
||||||
|
|
||||||
|
// Fetch customers for search
|
||||||
|
const { data: customers = [] } = useCustomers({ search: customerSearch });
|
||||||
|
|
||||||
|
// Create customer mutation
|
||||||
|
const createCustomer = useCreateCustomer();
|
||||||
|
|
||||||
|
// Fetch addons for selected service
|
||||||
|
const { data: serviceAddons = [], isLoading: addonsLoading } = useServiceAddons(
|
||||||
|
selectedServiceId ? selectedServiceId : null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter to only active addons
|
||||||
|
const activeAddons = useMemo(() => {
|
||||||
|
return serviceAddons.filter(addon => addon.is_active);
|
||||||
|
}, [serviceAddons]);
|
||||||
|
|
||||||
|
// Get selected service details
|
||||||
|
const selectedService = useMemo(() => {
|
||||||
|
return services.find(s => s.id === selectedServiceId);
|
||||||
|
}, [services, selectedServiceId]);
|
||||||
|
|
||||||
|
// When service changes, update duration to service default and reset addons
|
||||||
|
const handleServiceChange = useCallback((serviceId: string) => {
|
||||||
|
setSelectedServiceId(serviceId);
|
||||||
|
setSelectedAddonIds([]); // Reset addon selections when service changes
|
||||||
|
const service = services.find(s => s.id === serviceId);
|
||||||
|
if (service) {
|
||||||
|
setDuration(service.durationMinutes);
|
||||||
|
}
|
||||||
|
}, [services]);
|
||||||
|
|
||||||
|
// Handle customer selection from search
|
||||||
|
const handleSelectCustomer = useCallback((customer: Customer) => {
|
||||||
|
// Don't add duplicates
|
||||||
|
if (!selectedCustomers.find(c => c.id === customer.id)) {
|
||||||
|
setSelectedCustomers(prev => [...prev, customer]);
|
||||||
|
}
|
||||||
|
setCustomerSearch('');
|
||||||
|
setShowCustomerDropdown(false);
|
||||||
|
}, [selectedCustomers]);
|
||||||
|
|
||||||
|
// Remove a selected customer
|
||||||
|
const handleRemoveCustomer = useCallback((customerId: string) => {
|
||||||
|
setSelectedCustomers(prev => prev.filter(c => c.id !== customerId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle creating a new customer
|
||||||
|
const handleCreateCustomer = useCallback(async () => {
|
||||||
|
if (!newCustomerData.name.trim() || !newCustomerData.email.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await createCustomer.mutateAsync({
|
||||||
|
name: newCustomerData.name.trim(),
|
||||||
|
email: newCustomerData.email.trim(),
|
||||||
|
phone: newCustomerData.phone.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add the newly created customer to selection
|
||||||
|
const newCustomer: Customer = {
|
||||||
|
id: String(result.id),
|
||||||
|
name: newCustomerData.name.trim(),
|
||||||
|
email: newCustomerData.email.trim(),
|
||||||
|
phone: newCustomerData.phone.trim(),
|
||||||
|
userId: String(result.user_id || result.user),
|
||||||
|
};
|
||||||
|
setSelectedCustomers(prev => [...prev, newCustomer]);
|
||||||
|
|
||||||
|
// Reset and close form
|
||||||
|
setNewCustomerData({ name: '', email: '', phone: '' });
|
||||||
|
setShowNewCustomerForm(false);
|
||||||
|
setCustomerSearch('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create customer:', error);
|
||||||
|
}
|
||||||
|
}, [newCustomerData, createCustomer]);
|
||||||
|
|
||||||
|
// Toggle addon selection
|
||||||
|
const handleToggleAddon = useCallback((addonId: number) => {
|
||||||
|
setSelectedAddonIds(prev =>
|
||||||
|
prev.includes(addonId)
|
||||||
|
? prev.filter(id => id !== addonId)
|
||||||
|
: [...prev, addonId]
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Calculate total duration including addons
|
||||||
|
const totalDuration = useMemo(() => {
|
||||||
|
let total = duration;
|
||||||
|
selectedAddonIds.forEach(addonId => {
|
||||||
|
const addon = activeAddons.find(a => a.id === addonId);
|
||||||
|
if (addon && addon.duration_mode === 'SEQUENTIAL') {
|
||||||
|
total += addon.additional_duration || 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return total;
|
||||||
|
}, [duration, selectedAddonIds, activeAddons]);
|
||||||
|
|
||||||
|
// Filter customers based on search (exclude already selected)
|
||||||
|
const filteredCustomers = useMemo(() => {
|
||||||
|
if (!customerSearch.trim()) return [];
|
||||||
|
const selectedIds = new Set(selectedCustomers.map(c => c.id));
|
||||||
|
return customers.filter(c => !selectedIds.has(c.id)).slice(0, 10);
|
||||||
|
}, [customers, customerSearch, selectedCustomers]);
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
const canSubmit = useMemo(() => {
|
||||||
|
return selectedServiceId && selectedCustomers.length > 0 && selectedDateTime && duration >= 15;
|
||||||
|
}, [selectedServiceId, selectedCustomers, selectedDateTime, duration]);
|
||||||
|
|
||||||
|
// Handle submit
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
if (!canSubmit) return;
|
||||||
|
|
||||||
|
const startTime = new Date(selectedDateTime);
|
||||||
|
|
||||||
|
if (isEditMode) {
|
||||||
|
(props as EditModeProps).onSave({
|
||||||
|
serviceId: selectedServiceId,
|
||||||
|
customerIds: selectedCustomers.map(c => c.id),
|
||||||
|
startTime,
|
||||||
|
resourceId: selectedResourceId || null,
|
||||||
|
durationMinutes: totalDuration,
|
||||||
|
status,
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
participantsInput: participants.length > 0 ? participants : undefined,
|
||||||
|
addonIds: selectedAddonIds.length > 0 ? selectedAddonIds : undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
(props as CreateModeProps).onCreate({
|
||||||
|
serviceId: selectedServiceId,
|
||||||
|
customerIds: selectedCustomers.map(c => c.id),
|
||||||
|
startTime,
|
||||||
|
resourceId: selectedResourceId || null,
|
||||||
|
durationMinutes: totalDuration,
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
participantsInput: participants.length > 0 ? participants : undefined,
|
||||||
|
addonIds: selectedAddonIds.length > 0 ? selectedAddonIds : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [canSubmit, selectedServiceId, selectedCustomers, selectedDateTime, selectedResourceId, totalDuration, status, notes, participants, selectedAddonIds, isEditMode, props]);
|
||||||
|
|
||||||
|
// Format price for display
|
||||||
|
const formatPrice = (cents: number) => {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
}).format(cents / 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSubmitting = props.isSubmitting || false;
|
||||||
|
|
||||||
|
const modalContent = (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-lg bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden max-h-[90vh] flex flex-col"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-brand-50 to-brand-100 dark:from-brand-900/30 dark:to-brand-800/30">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-brand-500 rounded-lg">
|
||||||
|
<Calendar className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{isEditMode
|
||||||
|
? t('scheduler.editAppointment', 'Edit Appointment')
|
||||||
|
: t('scheduler.newAppointment', 'New Appointment')
|
||||||
|
}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 text-gray-400 hover:bg-white/50 dark:hover:bg-gray-700/50 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable content */}
|
||||||
|
<div className="overflow-y-auto flex-1 p-6 space-y-5">
|
||||||
|
{/* Service Selection */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{t('scheduler.service', 'Service')} <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedServiceId}
|
||||||
|
onChange={(e) => handleServiceChange(e.target.value)}
|
||||||
|
className="w-full px-3 py-2.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||||
|
>
|
||||||
|
<option value="">{t('scheduler.selectService', 'Select a service...')}</option>
|
||||||
|
{services.filter(s => s.is_active !== false).map(service => (
|
||||||
|
<option key={service.id} value={service.id}>
|
||||||
|
{service.name} ({service.durationMinutes} min)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Service Addons - Only show when service has addons */}
|
||||||
|
{selectedServiceId && activeAddons.length > 0 && (
|
||||||
|
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Package className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||||
|
<span className="text-sm font-medium text-purple-700 dark:text-purple-300">
|
||||||
|
{t('scheduler.addons', 'Add-ons')}
|
||||||
|
</span>
|
||||||
|
{addonsLoading && <Loader2 className="w-4 h-4 animate-spin text-purple-500" />}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{activeAddons.map(addon => (
|
||||||
|
<button
|
||||||
|
key={addon.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggleAddon(addon.id)}
|
||||||
|
className={`w-full flex items-center justify-between p-3 rounded-lg border transition-all ${
|
||||||
|
selectedAddonIds.includes(addon.id)
|
||||||
|
? 'bg-purple-100 dark:bg-purple-800/40 border-purple-400 dark:border-purple-600'
|
||||||
|
: 'bg-white dark:bg-gray-700 border-gray-200 dark:border-gray-600 hover:border-purple-300 dark:hover:border-purple-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
||||||
|
selectedAddonIds.includes(addon.id)
|
||||||
|
? 'bg-purple-500 border-purple-500'
|
||||||
|
: 'border-gray-300 dark:border-gray-500'
|
||||||
|
}`}>
|
||||||
|
{selectedAddonIds.includes(addon.id) && (
|
||||||
|
<Check className="w-3 h-3 text-white" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{addon.name}
|
||||||
|
</div>
|
||||||
|
{addon.description && (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{addon.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{addon.duration_mode === 'SEQUENTIAL' && addon.additional_duration > 0 && (
|
||||||
|
<div className="text-xs text-purple-600 dark:text-purple-400">
|
||||||
|
+{addon.additional_duration} min
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{formatPrice(addon.price_cents)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Customer Selection - Multi-select with autocomplete */}
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{t('scheduler.customers', 'Customers')} <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Selected customers chips */}
|
||||||
|
{selectedCustomers.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{selectedCustomers.map(customer => (
|
||||||
|
<div
|
||||||
|
key={customer.id}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-brand-100 dark:bg-brand-900/40 text-brand-700 dark:text-brand-300 rounded-full text-sm"
|
||||||
|
>
|
||||||
|
<UserIcon className="w-3.5 h-3.5" />
|
||||||
|
<span>{customer.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveCustomer(customer.id)}
|
||||||
|
className="p-0.5 hover:bg-brand-200 dark:hover:bg-brand-800 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search input */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customerSearch}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCustomerSearch(e.target.value);
|
||||||
|
setShowCustomerDropdown(true);
|
||||||
|
setShowNewCustomerForm(false);
|
||||||
|
}}
|
||||||
|
onFocus={() => setShowCustomerDropdown(true)}
|
||||||
|
placeholder={t('customers.searchPlaceholder', 'Search customers by name or email...')}
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Customer search results dropdown */}
|
||||||
|
{showCustomerDropdown && customerSearch.trim() && !showNewCustomerForm && (
|
||||||
|
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||||
|
{filteredCustomers.length === 0 ? (
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="px-2 py-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t('common.noResults', 'No results found')}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowNewCustomerForm(true);
|
||||||
|
setNewCustomerData(prev => ({ ...prev, name: customerSearch }));
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-2 flex items-center gap-2 hover:bg-green-50 dark:hover:bg-green-900/30 text-green-600 dark:text-green-400 text-left rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{t('customers.addNew', 'Add new customer')} "{customerSearch}"
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{filteredCustomers.map((customer) => (
|
||||||
|
<button
|
||||||
|
key={customer.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectCustomer(customer)}
|
||||||
|
className="w-full px-4 py-2 flex items-center gap-3 hover:bg-gray-100 dark:hover:bg-gray-700 text-left"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 w-8 h-8 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center">
|
||||||
|
<UserIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-grow min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||||
|
{customer.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
{customer.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="border-t dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowNewCustomerForm(true);
|
||||||
|
setNewCustomerData(prev => ({ ...prev, name: customerSearch }));
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-2 flex items-center gap-2 hover:bg-green-50 dark:hover:bg-green-900/30 text-green-600 dark:text-green-400 text-left transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{t('customers.addNew', 'Add new customer')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* New customer inline form */}
|
||||||
|
{showNewCustomerForm && (
|
||||||
|
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-lg shadow-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{t('customers.addNewCustomer', 'Add New Customer')}
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewCustomerForm(false)}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newCustomerData.name}
|
||||||
|
onChange={(e) => setNewCustomerData(prev => ({ ...prev, name: e.target.value }))}
|
||||||
|
placeholder={t('customers.name', 'Name')}
|
||||||
|
className="w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={newCustomerData.email}
|
||||||
|
onChange={(e) => setNewCustomerData(prev => ({ ...prev, email: e.target.value }))}
|
||||||
|
placeholder={t('customers.email', 'Email')}
|
||||||
|
className="w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={newCustomerData.phone}
|
||||||
|
onChange={(e) => setNewCustomerData(prev => ({ ...prev, phone: e.target.value }))}
|
||||||
|
placeholder={t('customers.phone', 'Phone (optional)')}
|
||||||
|
className="w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNewCustomerForm(false)}
|
||||||
|
className="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{t('common.cancel', 'Cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreateCustomer}
|
||||||
|
disabled={!newCustomerData.name.trim() || !newCustomerData.email.trim() || createCustomer.isPending}
|
||||||
|
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{createCustomer.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||||
|
{t('common.add', 'Add')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Click outside to close dropdown */}
|
||||||
|
{(showCustomerDropdown || showNewCustomerForm) && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCustomerDropdown(false);
|
||||||
|
setShowNewCustomerForm(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status (Edit mode only) */}
|
||||||
|
{isEditMode && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{t('scheduler.status', 'Status')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => setStatus(e.target.value as AppointmentStatus)}
|
||||||
|
className="w-full px-3 py-2.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||||
|
>
|
||||||
|
<option value="SCHEDULED">{t('scheduler.confirmed', 'Scheduled')}</option>
|
||||||
|
<option value="EN_ROUTE">En Route</option>
|
||||||
|
<option value="IN_PROGRESS">In Progress</option>
|
||||||
|
<option value="COMPLETED">{t('scheduler.completed', 'Completed')}</option>
|
||||||
|
<option value="AWAITING_PAYMENT">Awaiting Payment</option>
|
||||||
|
<option value="CANCELLED">{t('scheduler.cancelled', 'Cancelled')}</option>
|
||||||
|
<option value="NO_SHOW">{t('scheduler.noShow', 'No Show')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Date, Time & Duration */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{t('scheduler.selectDate', 'Date')} & {t('scheduler.selectTime', 'Time')} <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={selectedDateTime}
|
||||||
|
onChange={(e) => setSelectedDateTime(e.target.value)}
|
||||||
|
className="w-full px-3 py-2.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
<Clock className="w-4 h-4 inline mr-1" />
|
||||||
|
{t('scheduler.duration', 'Duration')} (min) <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="15"
|
||||||
|
step="15"
|
||||||
|
value={duration}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = parseInt(e.target.value);
|
||||||
|
setDuration(value >= 15 ? value : 15);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||||
|
/>
|
||||||
|
{selectedAddonIds.length > 0 && totalDuration !== duration && (
|
||||||
|
<div className="text-xs text-purple-600 dark:text-purple-400 mt-1">
|
||||||
|
{t('scheduler.totalWithAddons', 'Total with add-ons')}: {totalDuration} min
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resource Assignment */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{t('scheduler.selectResource', 'Assign to Resource')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedResourceId}
|
||||||
|
onChange={(e) => setSelectedResourceId(e.target.value)}
|
||||||
|
className="w-full px-3 py-2.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||||
|
>
|
||||||
|
<option value="">{t('scheduler.unassigned', 'Unassigned')}</option>
|
||||||
|
{resources.map(resource => (
|
||||||
|
<option key={resource.id} value={resource.id}>
|
||||||
|
{resource.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Additional Staff Section */}
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Users className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{t('scheduler.additionalStaff', 'Additional Staff')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ParticipantSelector
|
||||||
|
value={participants}
|
||||||
|
onChange={setParticipants}
|
||||||
|
allowedRoles={['STAFF']}
|
||||||
|
allowExternalEmail={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{t('scheduler.notes', 'Notes')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500 resize-none"
|
||||||
|
placeholder={t('scheduler.notesPlaceholder', 'Add notes about this appointment...')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer with action buttons */}
|
||||||
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3 bg-white dark:bg-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('common.cancel', 'Cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit || isSubmitting}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isSubmitting
|
||||||
|
? (isEditMode ? t('common.saving', 'Saving...') : t('common.creating', 'Creating...'))
|
||||||
|
: (isEditMode ? t('scheduler.saveChanges', 'Save Changes') : t('scheduler.createAppointment', 'Create Appointment'))
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(modalContent, document.body);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppointmentModal;
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
* onboarding experience without redirecting users away from the app.
|
* onboarding experience without redirecting users away from the app.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
ConnectComponentsProvider,
|
ConnectComponentsProvider,
|
||||||
ConnectAccountOnboarding,
|
ConnectAccountOnboarding,
|
||||||
@@ -22,6 +22,65 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { createAccountSession, refreshConnectStatus, ConnectAccountInfo } from '../api/payments';
|
import { createAccountSession, refreshConnectStatus, ConnectAccountInfo } from '../api/payments';
|
||||||
|
import { useDarkMode } from '../hooks/useDarkMode';
|
||||||
|
|
||||||
|
// Get appearance config based on dark mode
|
||||||
|
const getAppearance = (isDark: boolean) => ({
|
||||||
|
overlays: 'drawer' as const,
|
||||||
|
variables: {
|
||||||
|
// Brand colors - using your blue theme
|
||||||
|
colorPrimary: '#3b82f6', // brand-500
|
||||||
|
colorBackground: isDark ? '#1f2937' : '#ffffff', // gray-800 / white
|
||||||
|
colorText: isDark ? '#f9fafb' : '#111827', // gray-50 / gray-900
|
||||||
|
colorSecondaryText: isDark ? '#9ca3af' : '#6b7280', // gray-400 / gray-500
|
||||||
|
colorBorder: isDark ? '#374151' : '#e5e7eb', // gray-700 / gray-200
|
||||||
|
colorDanger: '#ef4444', // red-500
|
||||||
|
|
||||||
|
// Typography - matching Inter font
|
||||||
|
fontFamily: 'Inter, system-ui, -apple-system, sans-serif',
|
||||||
|
fontSizeBase: '14px',
|
||||||
|
fontSizeSm: '12px',
|
||||||
|
fontSizeLg: '16px',
|
||||||
|
fontSizeXl: '18px',
|
||||||
|
fontWeightNormal: '400',
|
||||||
|
fontWeightMedium: '500',
|
||||||
|
fontWeightBold: '600',
|
||||||
|
|
||||||
|
// Spacing & Borders - matching your rounded-lg style
|
||||||
|
spacingUnit: '12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
|
||||||
|
// Form elements
|
||||||
|
formBackgroundColor: isDark ? '#111827' : '#f9fafb', // gray-900 / gray-50
|
||||||
|
formBorderColor: isDark ? '#374151' : '#d1d5db', // gray-700 / gray-300
|
||||||
|
formHighlightColorBorder: '#3b82f6', // brand-500
|
||||||
|
formAccentColor: '#3b82f6', // brand-500
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
buttonPrimaryColorBackground: '#3b82f6', // brand-500
|
||||||
|
buttonPrimaryColorText: '#ffffff',
|
||||||
|
buttonSecondaryColorBackground: isDark ? '#374151' : '#f3f4f6', // gray-700 / gray-100
|
||||||
|
buttonSecondaryColorText: isDark ? '#f9fafb' : '#374151', // gray-50 / gray-700
|
||||||
|
buttonSecondaryColorBorder: isDark ? '#4b5563' : '#d1d5db', // gray-600 / gray-300
|
||||||
|
|
||||||
|
// Action colors
|
||||||
|
actionPrimaryColorText: '#3b82f6', // brand-500
|
||||||
|
actionSecondaryColorText: isDark ? '#9ca3af' : '#6b7280', // gray-400 / gray-500
|
||||||
|
|
||||||
|
// Badge colors
|
||||||
|
badgeNeutralColorBackground: isDark ? '#374151' : '#f3f4f6', // gray-700 / gray-100
|
||||||
|
badgeNeutralColorText: isDark ? '#d1d5db' : '#4b5563', // gray-300 / gray-600
|
||||||
|
badgeSuccessColorBackground: isDark ? '#065f46' : '#d1fae5', // green-800 / green-100
|
||||||
|
badgeSuccessColorText: isDark ? '#6ee7b7' : '#065f46', // green-300 / green-800
|
||||||
|
badgeWarningColorBackground: isDark ? '#92400e' : '#fef3c7', // amber-800 / amber-100
|
||||||
|
badgeWarningColorText: isDark ? '#fcd34d' : '#92400e', // amber-300 / amber-800
|
||||||
|
badgeDangerColorBackground: isDark ? '#991b1b' : '#fee2e2', // red-800 / red-100
|
||||||
|
badgeDangerColorText: isDark ? '#fca5a5' : '#991b1b', // red-300 / red-800
|
||||||
|
|
||||||
|
// Offset background (used for layered sections)
|
||||||
|
offsetBackgroundColor: isDark ? '#111827' : '#f9fafb', // gray-900 / gray-50
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
interface ConnectOnboardingEmbedProps {
|
interface ConnectOnboardingEmbedProps {
|
||||||
connectAccount: ConnectAccountInfo | null;
|
connectAccount: ConnectAccountInfo | null;
|
||||||
@@ -39,13 +98,62 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
|||||||
onError,
|
onError,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const isDark = useDarkMode();
|
||||||
const [stripeConnectInstance, setStripeConnectInstance] = useState<StripeConnectInstance | null>(null);
|
const [stripeConnectInstance, setStripeConnectInstance] = useState<StripeConnectInstance | null>(null);
|
||||||
const [loadingState, setLoadingState] = useState<LoadingState>('idle');
|
const [loadingState, setLoadingState] = useState<LoadingState>('idle');
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Track the theme that was used when initializing
|
||||||
|
const initializedThemeRef = useRef<boolean | null>(null);
|
||||||
|
// Flag to trigger auto-reinitialize
|
||||||
|
const [needsReinit, setNeedsReinit] = useState(false);
|
||||||
|
|
||||||
const isActive = connectAccount?.status === 'active' && connectAccount?.charges_enabled;
|
const isActive = connectAccount?.status === 'active' && connectAccount?.charges_enabled;
|
||||||
|
|
||||||
// Initialize Stripe Connect
|
// Detect theme changes when onboarding is already open
|
||||||
|
useEffect(() => {
|
||||||
|
if (loadingState === 'ready' && initializedThemeRef.current !== null && initializedThemeRef.current !== isDark) {
|
||||||
|
// Theme changed while onboarding is open - trigger reinitialize
|
||||||
|
setNeedsReinit(true);
|
||||||
|
}
|
||||||
|
}, [isDark, loadingState]);
|
||||||
|
|
||||||
|
// Handle reinitialization
|
||||||
|
useEffect(() => {
|
||||||
|
if (needsReinit) {
|
||||||
|
setStripeConnectInstance(null);
|
||||||
|
initializedThemeRef.current = null;
|
||||||
|
setNeedsReinit(false);
|
||||||
|
// Re-run initialization
|
||||||
|
(async () => {
|
||||||
|
setLoadingState('loading');
|
||||||
|
setErrorMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await createAccountSession();
|
||||||
|
const { client_secret, publishable_key } = response.data;
|
||||||
|
|
||||||
|
const instance = await loadConnectAndInitialize({
|
||||||
|
publishableKey: publishable_key,
|
||||||
|
fetchClientSecret: async () => client_secret,
|
||||||
|
appearance: getAppearance(isDark),
|
||||||
|
});
|
||||||
|
|
||||||
|
setStripeConnectInstance(instance);
|
||||||
|
setLoadingState('ready');
|
||||||
|
initializedThemeRef.current = isDark;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to reinitialize Stripe Connect:', err);
|
||||||
|
const message = err.response?.data?.error || err.message || t('payments.failedToInitializePayment');
|
||||||
|
setErrorMessage(message);
|
||||||
|
setLoadingState('error');
|
||||||
|
onError?.(message);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
}, [needsReinit, isDark, t, onError]);
|
||||||
|
|
||||||
|
// Initialize Stripe Connect (user-triggered)
|
||||||
const initializeStripeConnect = useCallback(async () => {
|
const initializeStripeConnect = useCallback(async () => {
|
||||||
if (loadingState === 'loading' || loadingState === 'ready') return;
|
if (loadingState === 'loading' || loadingState === 'ready') return;
|
||||||
|
|
||||||
@@ -57,27 +165,16 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
|||||||
const response = await createAccountSession();
|
const response = await createAccountSession();
|
||||||
const { client_secret, publishable_key } = response.data;
|
const { client_secret, publishable_key } = response.data;
|
||||||
|
|
||||||
// Initialize the Connect instance
|
// Initialize the Connect instance with theme-aware appearance
|
||||||
const instance = await loadConnectAndInitialize({
|
const instance = await loadConnectAndInitialize({
|
||||||
publishableKey: publishable_key,
|
publishableKey: publishable_key,
|
||||||
fetchClientSecret: async () => client_secret,
|
fetchClientSecret: async () => client_secret,
|
||||||
appearance: {
|
appearance: getAppearance(isDark),
|
||||||
overlays: 'drawer',
|
|
||||||
variables: {
|
|
||||||
colorPrimary: '#635BFF',
|
|
||||||
colorBackground: '#ffffff',
|
|
||||||
colorText: '#1a1a1a',
|
|
||||||
colorDanger: '#df1b41',
|
|
||||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
||||||
fontSizeBase: '14px',
|
|
||||||
spacingUnit: '12px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setStripeConnectInstance(instance);
|
setStripeConnectInstance(instance);
|
||||||
setLoadingState('ready');
|
setLoadingState('ready');
|
||||||
|
initializedThemeRef.current = isDark;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to initialize Stripe Connect:', err);
|
console.error('Failed to initialize Stripe Connect:', err);
|
||||||
const message = err.response?.data?.error || err.message || t('payments.failedToInitializePayment');
|
const message = err.response?.data?.error || err.message || t('payments.failedToInitializePayment');
|
||||||
@@ -85,7 +182,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
|||||||
setLoadingState('error');
|
setLoadingState('error');
|
||||||
onError?.(message);
|
onError?.(message);
|
||||||
}
|
}
|
||||||
}, [loadingState, onError, t]);
|
}, [loadingState, onError, t, isDark]);
|
||||||
|
|
||||||
// Handle onboarding completion
|
// Handle onboarding completion
|
||||||
const handleOnboardingExit = useCallback(async () => {
|
const handleOnboardingExit = useCallback(async () => {
|
||||||
@@ -242,7 +339,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={initializeStripeConnect}
|
onClick={initializeStripeConnect}
|
||||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium text-white bg-[#635BFF] rounded-lg hover:bg-[#5851ea] transition-colors"
|
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium text-white bg-brand-500 rounded-lg hover:bg-brand-600 transition-colors"
|
||||||
>
|
>
|
||||||
<CreditCard size={18} />
|
<CreditCard size={18} />
|
||||||
{t('payments.startPaymentSetup')}
|
{t('payments.startPaymentSetup')}
|
||||||
@@ -255,7 +352,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
|||||||
if (loadingState === 'loading') {
|
if (loadingState === 'loading') {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
<Loader2 className="animate-spin text-[#635BFF] mb-4" size={40} />
|
<Loader2 className="animate-spin text-brand-500 mb-4" size={40} />
|
||||||
<p className="text-gray-600 dark:text-gray-400">{t('payments.initializingPaymentSetup')}</p>
|
<p className="text-gray-600 dark:text-gray-400">{t('payments.initializingPaymentSetup')}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,352 +0,0 @@
|
|||||||
/**
|
|
||||||
* CreateAppointmentModal Component
|
|
||||||
*
|
|
||||||
* Modal for creating new appointments with customer, service, and participant selection.
|
|
||||||
* Supports both linked customers and participants with external email addresses.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useMemo, useCallback } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { X, Search, User as UserIcon, Calendar, Clock, Users } from 'lucide-react';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { Resource, Service, ParticipantInput } from '../types';
|
|
||||||
import { useCustomers } from '../hooks/useCustomers';
|
|
||||||
import { ParticipantSelector } from './ParticipantSelector';
|
|
||||||
|
|
||||||
interface CreateAppointmentModalProps {
|
|
||||||
resources: Resource[];
|
|
||||||
services: Service[];
|
|
||||||
initialDate?: Date;
|
|
||||||
initialResourceId?: string | null;
|
|
||||||
onCreate: (appointmentData: {
|
|
||||||
serviceId: string;
|
|
||||||
customerId: string;
|
|
||||||
startTime: Date;
|
|
||||||
resourceId?: string | null;
|
|
||||||
durationMinutes: number;
|
|
||||||
notes?: string;
|
|
||||||
participantsInput?: ParticipantInput[];
|
|
||||||
}) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
isCreating?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CreateAppointmentModal: React.FC<CreateAppointmentModalProps> = ({
|
|
||||||
resources,
|
|
||||||
services,
|
|
||||||
initialDate,
|
|
||||||
initialResourceId,
|
|
||||||
onCreate,
|
|
||||||
onClose,
|
|
||||||
isCreating = false,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// Form state
|
|
||||||
const [selectedServiceId, setSelectedServiceId] = useState('');
|
|
||||||
const [selectedCustomerId, setSelectedCustomerId] = useState('');
|
|
||||||
const [customerSearch, setCustomerSearch] = useState('');
|
|
||||||
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
|
|
||||||
const [selectedDateTime, setSelectedDateTime] = useState(() => {
|
|
||||||
// Default to initial date or now, rounded to nearest 15 min
|
|
||||||
const date = initialDate || new Date();
|
|
||||||
const minutes = Math.ceil(date.getMinutes() / 15) * 15;
|
|
||||||
date.setMinutes(minutes, 0, 0);
|
|
||||||
// Convert to datetime-local format
|
|
||||||
const localDateTime = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
|
|
||||||
.toISOString()
|
|
||||||
.slice(0, 16);
|
|
||||||
return localDateTime;
|
|
||||||
});
|
|
||||||
const [selectedResourceId, setSelectedResourceId] = useState(initialResourceId || '');
|
|
||||||
const [duration, setDuration] = useState(30);
|
|
||||||
const [notes, setNotes] = useState('');
|
|
||||||
const [participants, setParticipants] = useState<ParticipantInput[]>([]);
|
|
||||||
|
|
||||||
// Fetch customers for search
|
|
||||||
const { data: customers = [] } = useCustomers({ search: customerSearch });
|
|
||||||
|
|
||||||
// Get selected customer details
|
|
||||||
const selectedCustomer = useMemo(() => {
|
|
||||||
return customers.find(c => c.id === selectedCustomerId);
|
|
||||||
}, [customers, selectedCustomerId]);
|
|
||||||
|
|
||||||
// Get selected service details
|
|
||||||
const selectedService = useMemo(() => {
|
|
||||||
return services.find(s => s.id === selectedServiceId);
|
|
||||||
}, [services, selectedServiceId]);
|
|
||||||
|
|
||||||
// When service changes, update duration to service default
|
|
||||||
const handleServiceChange = useCallback((serviceId: string) => {
|
|
||||||
setSelectedServiceId(serviceId);
|
|
||||||
const service = services.find(s => s.id === serviceId);
|
|
||||||
if (service) {
|
|
||||||
setDuration(service.durationMinutes);
|
|
||||||
}
|
|
||||||
}, [services]);
|
|
||||||
|
|
||||||
// Handle customer selection from search
|
|
||||||
const handleSelectCustomer = useCallback((customerId: string, customerName: string) => {
|
|
||||||
setSelectedCustomerId(customerId);
|
|
||||||
setCustomerSearch(customerName);
|
|
||||||
setShowCustomerDropdown(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Filter customers based on search
|
|
||||||
const filteredCustomers = useMemo(() => {
|
|
||||||
if (!customerSearch.trim()) return [];
|
|
||||||
return customers.slice(0, 10);
|
|
||||||
}, [customers, customerSearch]);
|
|
||||||
|
|
||||||
// Validation
|
|
||||||
const canCreate = useMemo(() => {
|
|
||||||
return selectedServiceId && selectedCustomerId && selectedDateTime && duration >= 15;
|
|
||||||
}, [selectedServiceId, selectedCustomerId, selectedDateTime, duration]);
|
|
||||||
|
|
||||||
// Handle create
|
|
||||||
const handleCreate = useCallback(() => {
|
|
||||||
if (!canCreate) return;
|
|
||||||
|
|
||||||
const startTime = new Date(selectedDateTime);
|
|
||||||
|
|
||||||
onCreate({
|
|
||||||
serviceId: selectedServiceId,
|
|
||||||
customerId: selectedCustomerId,
|
|
||||||
startTime,
|
|
||||||
resourceId: selectedResourceId || null,
|
|
||||||
durationMinutes: duration,
|
|
||||||
notes: notes.trim() || undefined,
|
|
||||||
participantsInput: participants.length > 0 ? participants : undefined,
|
|
||||||
});
|
|
||||||
}, [canCreate, selectedServiceId, selectedCustomerId, selectedDateTime, selectedResourceId, duration, notes, participants, onCreate]);
|
|
||||||
|
|
||||||
const modalContent = (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-full max-w-lg bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden max-h-[90vh] flex flex-col"
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-brand-50 to-brand-100 dark:from-brand-900/30 dark:to-brand-800/30">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-2 bg-brand-500 rounded-lg">
|
|
||||||
<Calendar className="w-5 h-5 text-white" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
||||||
{t('scheduler.newAppointment', 'New Appointment')}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-1 text-gray-400 hover:bg-white/50 dark:hover:bg-gray-700/50 rounded-full transition-colors"
|
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scrollable content */}
|
|
||||||
<div className="overflow-y-auto flex-1 p-6 space-y-5">
|
|
||||||
{/* Service Selection */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
{t('services.title', 'Service')} <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={selectedServiceId}
|
|
||||||
onChange={(e) => handleServiceChange(e.target.value)}
|
|
||||||
className="w-full px-3 py-2.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
|
||||||
>
|
|
||||||
<option value="">{t('scheduler.selectService', 'Select a service...')}</option>
|
|
||||||
{services.filter(s => s.is_active !== false).map(service => (
|
|
||||||
<option key={service.id} value={service.id}>
|
|
||||||
{service.name} ({service.durationMinutes} min)
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Customer Selection */}
|
|
||||||
<div className="relative">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
{t('customers.title', 'Customer')} <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={customerSearch}
|
|
||||||
onChange={(e) => {
|
|
||||||
setCustomerSearch(e.target.value);
|
|
||||||
setShowCustomerDropdown(true);
|
|
||||||
if (!e.target.value.trim()) {
|
|
||||||
setSelectedCustomerId('');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onFocus={() => setShowCustomerDropdown(true)}
|
|
||||||
placeholder={t('customers.searchPlaceholder', 'Search customers by name or email...')}
|
|
||||||
className="w-full pl-10 pr-4 py-2.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
|
||||||
/>
|
|
||||||
{selectedCustomer && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedCustomerId('');
|
|
||||||
setCustomerSearch('');
|
|
||||||
}}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Customer search results dropdown */}
|
|
||||||
{showCustomerDropdown && customerSearch.trim() && (
|
|
||||||
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
|
||||||
{filteredCustomers.length === 0 ? (
|
|
||||||
<div className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{t('common.noResults', 'No results found')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredCustomers.map((customer) => (
|
|
||||||
<button
|
|
||||||
key={customer.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleSelectCustomer(customer.id, customer.name)}
|
|
||||||
className="w-full px-4 py-2 flex items-center gap-3 hover:bg-gray-100 dark:hover:bg-gray-700 text-left"
|
|
||||||
>
|
|
||||||
<div className="flex-shrink-0 w-8 h-8 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center">
|
|
||||||
<UserIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-grow min-w-0">
|
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
|
||||||
{customer.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
|
||||||
{customer.email}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Click outside to close dropdown */}
|
|
||||||
{showCustomerDropdown && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-40"
|
|
||||||
onClick={() => setShowCustomerDropdown(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date, Time & Duration */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
{t('scheduler.selectDate', 'Date')} & {t('scheduler.selectTime', 'Time')} <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
value={selectedDateTime}
|
|
||||||
onChange={(e) => setSelectedDateTime(e.target.value)}
|
|
||||||
className="w-full px-3 py-2.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
<Clock className="w-4 h-4 inline mr-1" />
|
|
||||||
{t('scheduler.duration', 'Duration')} (min) <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="15"
|
|
||||||
step="15"
|
|
||||||
value={duration}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = parseInt(e.target.value);
|
|
||||||
setDuration(value >= 15 ? value : 15);
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Resource Assignment */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
{t('scheduler.selectResource', 'Assign to Resource')}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={selectedResourceId}
|
|
||||||
onChange={(e) => setSelectedResourceId(e.target.value)}
|
|
||||||
className="w-full px-3 py-2.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
|
||||||
>
|
|
||||||
<option value="">{t('scheduler.unassigned', 'Unassigned')}</option>
|
|
||||||
{resources.map(resource => (
|
|
||||||
<option key={resource.id} value={resource.id}>
|
|
||||||
{resource.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Participants Section */}
|
|
||||||
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<Users className="w-4 h-4 text-gray-500" />
|
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
{t('participants.additionalParticipants', 'Additional Participants')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ParticipantSelector
|
|
||||||
value={participants}
|
|
||||||
onChange={setParticipants}
|
|
||||||
allowedRoles={['STAFF', 'CUSTOMER', 'OBSERVER']}
|
|
||||||
allowExternalEmail={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notes */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
{t('scheduler.notes', 'Notes')}
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={notes}
|
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500 resize-none"
|
|
||||||
placeholder={t('scheduler.notesPlaceholder', 'Add notes about this appointment...')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer with action buttons */}
|
|
||||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3 bg-white dark:bg-gray-800">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={isCreating}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t('common.cancel', 'Cancel')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleCreate}
|
|
||||||
disabled={!canCreate || isCreating}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{isCreating ? t('common.creating', 'Creating...') : t('scheduler.createAppointment', 'Create Appointment')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return createPortal(modalContent, document.body);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CreateAppointmentModal;
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
/**
|
|
||||||
* EditAppointmentModal Component
|
|
||||||
*
|
|
||||||
* Modal for editing existing appointments, including participant management.
|
|
||||||
* Extracted from OwnerScheduler for reusability and enhanced with participant selector.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { X, User as UserIcon, Mail, Phone } from 'lucide-react';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { Appointment, AppointmentStatus, Resource, Service, ParticipantInput } from '../types';
|
|
||||||
import { ParticipantSelector } from './ParticipantSelector';
|
|
||||||
|
|
||||||
interface EditAppointmentModalProps {
|
|
||||||
appointment: Appointment & {
|
|
||||||
customerEmail?: string;
|
|
||||||
customerPhone?: string;
|
|
||||||
};
|
|
||||||
resources: Resource[];
|
|
||||||
services: Service[];
|
|
||||||
onSave: (updates: {
|
|
||||||
startTime?: Date;
|
|
||||||
resourceId?: string | null;
|
|
||||||
durationMinutes?: number;
|
|
||||||
status?: AppointmentStatus;
|
|
||||||
notes?: string;
|
|
||||||
participantsInput?: ParticipantInput[];
|
|
||||||
}) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
isSaving?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EditAppointmentModal: React.FC<EditAppointmentModalProps> = ({
|
|
||||||
appointment,
|
|
||||||
resources,
|
|
||||||
services,
|
|
||||||
onSave,
|
|
||||||
onClose,
|
|
||||||
isSaving = false,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// Form state
|
|
||||||
const [editDateTime, setEditDateTime] = useState('');
|
|
||||||
const [editResource, setEditResource] = useState('');
|
|
||||||
const [editDuration, setEditDuration] = useState(15);
|
|
||||||
const [editStatus, setEditStatus] = useState<AppointmentStatus>('SCHEDULED');
|
|
||||||
const [editNotes, setEditNotes] = useState('');
|
|
||||||
const [participants, setParticipants] = useState<ParticipantInput[]>([]);
|
|
||||||
|
|
||||||
// Initialize form state from appointment
|
|
||||||
useEffect(() => {
|
|
||||||
if (appointment) {
|
|
||||||
// Convert Date to datetime-local format
|
|
||||||
const startTime = appointment.startTime;
|
|
||||||
const localDateTime = new Date(startTime.getTime() - startTime.getTimezoneOffset() * 60000)
|
|
||||||
.toISOString()
|
|
||||||
.slice(0, 16);
|
|
||||||
setEditDateTime(localDateTime);
|
|
||||||
setEditResource(appointment.resourceId || '');
|
|
||||||
setEditDuration(appointment.durationMinutes || 15);
|
|
||||||
setEditStatus(appointment.status || 'SCHEDULED');
|
|
||||||
setEditNotes(appointment.notes || '');
|
|
||||||
|
|
||||||
// Initialize participants from existing appointment participants
|
|
||||||
if (appointment.participants) {
|
|
||||||
const existingParticipants: ParticipantInput[] = appointment.participants.map(p => ({
|
|
||||||
role: p.role,
|
|
||||||
userId: p.userId ? parseInt(p.userId) : undefined,
|
|
||||||
resourceId: p.resourceId ? parseInt(p.resourceId) : undefined,
|
|
||||||
externalEmail: p.externalEmail,
|
|
||||||
externalName: p.externalName,
|
|
||||||
}));
|
|
||||||
setParticipants(existingParticipants);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [appointment]);
|
|
||||||
|
|
||||||
// Get service name
|
|
||||||
const serviceName = useMemo(() => {
|
|
||||||
const service = services.find(s => s.id === appointment.serviceId);
|
|
||||||
return service?.name || 'Unknown Service';
|
|
||||||
}, [services, appointment.serviceId]);
|
|
||||||
|
|
||||||
// Check if appointment is unassigned (pending)
|
|
||||||
const isUnassigned = !appointment.resourceId;
|
|
||||||
|
|
||||||
// Handle save
|
|
||||||
const handleSave = () => {
|
|
||||||
const startTime = new Date(editDateTime);
|
|
||||||
|
|
||||||
onSave({
|
|
||||||
startTime,
|
|
||||||
resourceId: editResource || null,
|
|
||||||
durationMinutes: editDuration,
|
|
||||||
status: editStatus,
|
|
||||||
notes: editNotes,
|
|
||||||
participantsInput: participants,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validation
|
|
||||||
const canSave = useMemo(() => {
|
|
||||||
if (isUnassigned) {
|
|
||||||
// For unassigned appointments, require resource and valid duration
|
|
||||||
return editResource && editDuration >= 15;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}, [isUnassigned, editResource, editDuration]);
|
|
||||||
|
|
||||||
const modalContent = (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-full max-w-lg bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden max-h-[90vh] flex flex-col"
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-brand-50 to-brand-100 dark:from-brand-900/30 dark:to-brand-800/30">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
||||||
{isUnassigned ? t('scheduler.scheduleAppointment') : t('scheduler.editAppointment')}
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-1 text-gray-400 hover:bg-white/50 dark:hover:bg-gray-700/50 rounded-full transition-colors"
|
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scrollable content */}
|
|
||||||
<div className="overflow-y-auto flex-1 p-6 space-y-4">
|
|
||||||
{/* Customer Info */}
|
|
||||||
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
|
||||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-brand-100 dark:bg-brand-900/50 flex items-center justify-center">
|
|
||||||
<UserIcon size={20} className="text-brand-600 dark:text-brand-400" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
|
||||||
{t('customers.title', 'Customer')}
|
|
||||||
</p>
|
|
||||||
<p className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
||||||
{appointment.customerName}
|
|
||||||
</p>
|
|
||||||
{appointment.customerEmail && (
|
|
||||||
<div className="flex items-center gap-2 mt-1 text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
<Mail size={14} />
|
|
||||||
<span>{appointment.customerEmail}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{appointment.customerPhone && (
|
|
||||||
<div className="flex items-center gap-2 mt-1 text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
<Phone size={14} />
|
|
||||||
<span>{appointment.customerPhone}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Service & Status */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
|
||||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
|
|
||||||
{t('services.title', 'Service')}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">{serviceName}</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
|
||||||
<label className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1 block">
|
|
||||||
{t('scheduler.status', 'Status')}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={editStatus}
|
|
||||||
onChange={(e) => setEditStatus(e.target.value as AppointmentStatus)}
|
|
||||||
className="w-full px-2 py-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded text-sm font-semibold text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
|
||||||
>
|
|
||||||
<option value="SCHEDULED">{t('scheduler.confirmed', 'Scheduled')}</option>
|
|
||||||
<option value="EN_ROUTE">En Route</option>
|
|
||||||
<option value="IN_PROGRESS">In Progress</option>
|
|
||||||
<option value="COMPLETED">{t('scheduler.completed', 'Completed')}</option>
|
|
||||||
<option value="AWAITING_PAYMENT">Awaiting Payment</option>
|
|
||||||
<option value="CANCELLED">{t('scheduler.cancelled', 'Cancelled')}</option>
|
|
||||||
<option value="NO_SHOW">{t('scheduler.noShow', 'No Show')}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Editable Fields */}
|
|
||||||
<div className="space-y-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
|
||||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
|
||||||
{t('scheduler.scheduleDetails', 'Schedule Details')}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
{/* Date & Time Picker */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
{t('scheduler.selectDate', 'Date')} & {t('scheduler.selectTime', 'Time')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
value={editDateTime}
|
|
||||||
onChange={(e) => setEditDateTime(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Resource Selector */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
{t('scheduler.selectResource', 'Assign to Resource')}
|
|
||||||
{isUnassigned && <span className="text-red-500 ml-1">*</span>}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={editResource}
|
|
||||||
onChange={(e) => setEditResource(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
|
||||||
>
|
|
||||||
<option value="">Unassigned</option>
|
|
||||||
{resources.map(resource => (
|
|
||||||
<option key={resource.id} value={resource.id}>
|
|
||||||
{resource.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Duration Input */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
{t('scheduler.duration', 'Duration')} (minutes)
|
|
||||||
{isUnassigned && <span className="text-red-500 ml-1">*</span>}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="15"
|
|
||||||
step="15"
|
|
||||||
value={editDuration || 15}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = parseInt(e.target.value);
|
|
||||||
setEditDuration(value >= 15 ? value : 15);
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Participants Section */}
|
|
||||||
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
|
||||||
<ParticipantSelector
|
|
||||||
value={participants}
|
|
||||||
onChange={setParticipants}
|
|
||||||
allowedRoles={['STAFF', 'CUSTOMER', 'OBSERVER']}
|
|
||||||
allowExternalEmail={true}
|
|
||||||
existingParticipants={appointment.participants}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notes */}
|
|
||||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
|
||||||
<label className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1 block">
|
|
||||||
{t('scheduler.notes', 'Notes')}
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={editNotes}
|
|
||||||
onChange={(e) => setEditNotes(e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500 resize-none"
|
|
||||||
placeholder={t('scheduler.notesPlaceholder', 'Add notes about this appointment...')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer with action buttons */}
|
|
||||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3 bg-white dark:bg-gray-800">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={isSaving}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t('scheduler.cancel', 'Cancel')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!canSave || isSaving}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{isSaving ? t('common.saving', 'Saving...') : (
|
|
||||||
isUnassigned ? t('scheduler.scheduleAppointment', 'Schedule Appointment') : t('scheduler.saveChanges', 'Save Changes')
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return createPortal(modalContent, document.body);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EditAppointmentModal;
|
|
||||||
@@ -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;
|
|
||||||
254
frontend/src/components/GlobalSearch.tsx
Normal file
254
frontend/src/components/GlobalSearch.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Search, X } from 'lucide-react';
|
||||||
|
import { useNavigationSearch } from '../hooks/useNavigationSearch';
|
||||||
|
import { User } from '../types';
|
||||||
|
import { NavigationItem } from '../data/navigationSearchIndex';
|
||||||
|
|
||||||
|
interface GlobalSearchProps {
|
||||||
|
user?: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GlobalSearch: React.FC<GlobalSearchProps> = ({ user }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { query, setQuery, results, clearSearch } = useNavigationSearch({
|
||||||
|
user,
|
||||||
|
limit: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset selected index when results change
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}, [results]);
|
||||||
|
|
||||||
|
// Handle keyboard navigation
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (!isOpen || results.length === 0) {
|
||||||
|
if (e.key === 'ArrowDown' && query.trim()) {
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1));
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
if (results[selectedIndex]) {
|
||||||
|
handleSelect(results[selectedIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOpen(false);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isOpen, results, selectedIndex, query]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelect = (item: NavigationItem) => {
|
||||||
|
navigate(item.path);
|
||||||
|
clearSearch();
|
||||||
|
setIsOpen(false);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
if (e.target.value.trim()) {
|
||||||
|
setIsOpen(true);
|
||||||
|
} else {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
if (query.trim() && results.length > 0) {
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
clearSearch();
|
||||||
|
setIsOpen(false);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Group results by category
|
||||||
|
const groupedResults = results.reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
if (!acc[item.category]) {
|
||||||
|
acc[item.category] = [];
|
||||||
|
}
|
||||||
|
acc[item.category].push(item);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, NavigationItem[]>
|
||||||
|
);
|
||||||
|
|
||||||
|
const categoryOrder = ['Analytics', 'Manage', 'Communicate', 'Extend', 'Settings', 'Help'];
|
||||||
|
|
||||||
|
// Flatten for keyboard navigation index
|
||||||
|
let flatIndex = 0;
|
||||||
|
const getItemIndex = () => {
|
||||||
|
const idx = flatIndex;
|
||||||
|
flatIndex++;
|
||||||
|
return idx;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative hidden md:block w-96">
|
||||||
|
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-400 pointer-events-none">
|
||||||
|
<Search size={18} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={t('common.search')}
|
||||||
|
className="w-full py-2 pl-10 pr-10 text-sm text-gray-700 dark:text-gray-200 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 placeholder-gray-400 dark:placeholder-gray-500 transition-colors duration-200"
|
||||||
|
aria-label={t('common.search')}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-controls="global-search-results"
|
||||||
|
role="combobox"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
onClick={handleClear}
|
||||||
|
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results dropdown */}
|
||||||
|
{isOpen && results.length > 0 && (
|
||||||
|
<div
|
||||||
|
id="global-search-results"
|
||||||
|
role="listbox"
|
||||||
|
className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-96 overflow-y-auto"
|
||||||
|
>
|
||||||
|
{categoryOrder.map((category) => {
|
||||||
|
const items = groupedResults[category];
|
||||||
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={category}>
|
||||||
|
<div className="px-3 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide bg-gray-50 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700">
|
||||||
|
{category}
|
||||||
|
</div>
|
||||||
|
{items.map((item) => {
|
||||||
|
const itemIndex = getItemIndex();
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.path}
|
||||||
|
role="option"
|
||||||
|
aria-selected={selectedIndex === itemIndex}
|
||||||
|
onClick={() => handleSelect(item)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(itemIndex)}
|
||||||
|
className={`w-full flex items-start gap-3 px-3 py-2 text-left transition-colors ${
|
||||||
|
selectedIndex === itemIndex
|
||||||
|
? 'bg-brand-50 dark:bg-brand-900/20'
|
||||||
|
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-center w-8 h-8 rounded-lg shrink-0 ${
|
||||||
|
selectedIndex === itemIndex
|
||||||
|
? 'bg-brand-100 dark:bg-brand-800 text-brand-600 dark:text-brand-300'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={16} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className={`text-sm font-medium truncate ${
|
||||||
|
selectedIndex === itemIndex
|
||||||
|
? 'text-brand-700 dark:text-brand-300'
|
||||||
|
: 'text-gray-900 dark:text-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
{item.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Keyboard hint */}
|
||||||
|
<div className="px-3 py-2 text-xs text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-700/50 border-t border-gray-100 dark:border-gray-700 flex items-center gap-4">
|
||||||
|
<span>
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">↑↓</kbd>{' '}
|
||||||
|
navigate
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">↵</kbd>{' '}
|
||||||
|
select
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">esc</kbd>{' '}
|
||||||
|
close
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No results message */}
|
||||||
|
{isOpen && query.trim() && results.length === 0 && (
|
||||||
|
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4 text-center">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No pages found for "{query}"
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||||
|
Try searching for dashboard, scheduler, settings, etc.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GlobalSearch;
|
||||||
@@ -1,31 +1,113 @@
|
|||||||
/**
|
/**
|
||||||
* HelpButton Component
|
* HelpButton Component
|
||||||
*
|
*
|
||||||
* A contextual help button that appears at the top-right of pages
|
* A help button for the top bar that navigates to context-aware help pages.
|
||||||
* and links to the relevant help documentation.
|
* Automatically determines the help path based on the current route.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { HelpCircle } from 'lucide-react';
|
import { HelpCircle } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface HelpButtonProps {
|
// Map route suffixes to their help page suffixes
|
||||||
helpPath: string;
|
// These get prefixed appropriately based on context (tenant dashboard or public)
|
||||||
className?: string;
|
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 { 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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={helpPath}
|
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')}
|
title={t('common.help', 'Help')}
|
||||||
|
aria-label={t('common.help', 'Help')}
|
||||||
>
|
>
|
||||||
<HelpCircle size={18} />
|
<HelpCircle size={20} />
|
||||||
<span className="hidden sm:inline">{t('common.help', 'Help')}</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Bell, Check, CheckCheck, Trash2, X, Ticket, Calendar, MessageSquare, Clock } from 'lucide-react';
|
import { Bell, Check, CheckCheck, Trash2, X, Ticket, Calendar, MessageSquare, Clock, CreditCard } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useNotifications,
|
useNotifications,
|
||||||
useUnreadNotificationCount,
|
useUnreadNotificationCount,
|
||||||
@@ -64,6 +64,13 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle Stripe requirements notifications - navigate to payments page
|
||||||
|
if (notification.data?.type === 'stripe_requirements') {
|
||||||
|
navigate('/dashboard/payments');
|
||||||
|
setIsOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Navigate to target if available
|
// Navigate to target if available
|
||||||
if (notification.target_url) {
|
if (notification.target_url) {
|
||||||
navigate(notification.target_url);
|
navigate(notification.target_url);
|
||||||
@@ -85,6 +92,11 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
|
|||||||
return <Clock size={16} className="text-amber-500" />;
|
return <Clock size={16} className="text-amber-500" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for Stripe requirements notifications
|
||||||
|
if (notification.data?.type === 'stripe_requirements') {
|
||||||
|
return <CreditCard size={16} className="text-purple-500" />;
|
||||||
|
}
|
||||||
|
|
||||||
switch (notification.target_type) {
|
switch (notification.target_type) {
|
||||||
case 'ticket':
|
case 'ticket':
|
||||||
return <Ticket size={16} className="text-blue-500" />;
|
return <Ticket size={16} className="text-blue-500" />;
|
||||||
@@ -192,9 +204,9 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
|
|||||||
{' '}
|
{' '}
|
||||||
{notification.verb}
|
{notification.verb}
|
||||||
</p>
|
</p>
|
||||||
{notification.target_display && (
|
{(notification.target_display || notification.data?.description) && (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 truncate mt-0.5">
|
<p className="text-sm text-gray-500 dark:text-gray-400 truncate mt-0.5">
|
||||||
{notification.target_display}
|
{notification.target_display || notification.data?.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||||
@@ -213,7 +225,7 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
{notifications.length > 0 && (
|
{notifications.length > 0 && (
|
||||||
<div className="px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
<div className="px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex items-center justify-center">
|
||||||
<button
|
<button
|
||||||
onClick={handleClearAll}
|
onClick={handleClearAll}
|
||||||
disabled={clearAllMutation.isPending}
|
disabled={clearAllMutation.isPending}
|
||||||
@@ -222,15 +234,6 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
|
|||||||
<Trash2 size={12} />
|
<Trash2 size={12} />
|
||||||
{t('notifications.clearRead', 'Clear read')}
|
{t('notifications.clearRead', 'Clear read')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
navigate('/dashboard/notifications');
|
|
||||||
setIsOpen(false);
|
|
||||||
}}
|
|
||||||
className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 font-medium"
|
|
||||||
>
|
|
||||||
{t('notifications.viewAll', 'View all')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Business } from '../types';
|
|||||||
import { usePaymentConfig } from '../hooks/usePayments';
|
import { usePaymentConfig } from '../hooks/usePayments';
|
||||||
import StripeApiKeysForm from './StripeApiKeysForm';
|
import StripeApiKeysForm from './StripeApiKeysForm';
|
||||||
import ConnectOnboardingEmbed from './ConnectOnboardingEmbed';
|
import ConnectOnboardingEmbed from './ConnectOnboardingEmbed';
|
||||||
|
import StripeSettingsPanel from './StripeSettingsPanel';
|
||||||
|
|
||||||
interface PaymentSettingsSectionProps {
|
interface PaymentSettingsSectionProps {
|
||||||
business: Business;
|
business: Business;
|
||||||
@@ -260,11 +261,22 @@ const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ busines
|
|||||||
onSuccess={() => refetch()}
|
onSuccess={() => refetch()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ConnectOnboardingEmbed
|
<>
|
||||||
connectAccount={config?.connect_account || null}
|
<ConnectOnboardingEmbed
|
||||||
tier={tier}
|
connectAccount={config?.connect_account || null}
|
||||||
onComplete={() => refetch()}
|
tier={tier}
|
||||||
/>
|
onComplete={() => refetch()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Stripe Settings Panel - show when Connect account is active */}
|
||||||
|
{config?.connect_account?.charges_enabled && config?.connect_account?.stripe_account_id && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<StripeSettingsPanel
|
||||||
|
stripeAccountId={config.connect_account.stripe_account_id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Upgrade notice for free tier with deprecated keys */}
|
{/* Upgrade notice for free tier with deprecated keys */}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ interface EmailAddressFormData {
|
|||||||
domain: string;
|
domain: string;
|
||||||
color: string;
|
color: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
routing_mode: 'PLATFORM' | 'STAFF';
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
}
|
}
|
||||||
@@ -92,6 +93,7 @@ const PlatformEmailAddressManager: React.FC = () => {
|
|||||||
domain: 'smoothschedule.com',
|
domain: 'smoothschedule.com',
|
||||||
color: '#3b82f6',
|
color: '#3b82f6',
|
||||||
password: '',
|
password: '',
|
||||||
|
routing_mode: 'PLATFORM',
|
||||||
is_active: true,
|
is_active: true,
|
||||||
is_default: false,
|
is_default: false,
|
||||||
});
|
});
|
||||||
@@ -120,6 +122,7 @@ const PlatformEmailAddressManager: React.FC = () => {
|
|||||||
domain: 'smoothschedule.com',
|
domain: 'smoothschedule.com',
|
||||||
color: '#3b82f6',
|
color: '#3b82f6',
|
||||||
password: '',
|
password: '',
|
||||||
|
routing_mode: 'PLATFORM',
|
||||||
is_active: true,
|
is_active: true,
|
||||||
is_default: false,
|
is_default: false,
|
||||||
});
|
});
|
||||||
@@ -137,6 +140,7 @@ const PlatformEmailAddressManager: React.FC = () => {
|
|||||||
domain: address.domain,
|
domain: address.domain,
|
||||||
color: address.color,
|
color: address.color,
|
||||||
password: '',
|
password: '',
|
||||||
|
routing_mode: address.routing_mode || 'PLATFORM',
|
||||||
is_active: address.is_active,
|
is_active: address.is_active,
|
||||||
is_default: address.is_default,
|
is_default: address.is_default,
|
||||||
});
|
});
|
||||||
@@ -188,6 +192,7 @@ const PlatformEmailAddressManager: React.FC = () => {
|
|||||||
sender_name: formData.sender_name,
|
sender_name: formData.sender_name,
|
||||||
assigned_user_id: formData.assigned_user_id,
|
assigned_user_id: formData.assigned_user_id,
|
||||||
color: formData.color,
|
color: formData.color,
|
||||||
|
routing_mode: formData.routing_mode,
|
||||||
is_active: formData.is_active,
|
is_active: formData.is_active,
|
||||||
is_default: formData.is_default,
|
is_default: formData.is_default,
|
||||||
};
|
};
|
||||||
@@ -210,6 +215,7 @@ const PlatformEmailAddressManager: React.FC = () => {
|
|||||||
domain: formData.domain,
|
domain: formData.domain,
|
||||||
color: formData.color,
|
color: formData.color,
|
||||||
password: formData.password,
|
password: formData.password,
|
||||||
|
routing_mode: formData.routing_mode,
|
||||||
is_active: formData.is_active,
|
is_active: formData.is_active,
|
||||||
is_default: formData.is_default,
|
is_default: formData.is_default,
|
||||||
});
|
});
|
||||||
@@ -607,6 +613,27 @@ const PlatformEmailAddressManager: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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) */}
|
{/* Email Address (only show for new addresses) */}
|
||||||
{!editingAddress && (
|
{!editingAddress && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
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 { User } from '../types';
|
||||||
import SmoothScheduleLogo from './SmoothScheduleLogo';
|
import SmoothScheduleLogo from './SmoothScheduleLogo';
|
||||||
|
|
||||||
@@ -16,7 +16,9 @@ const PlatformSidebar: React.FC<PlatformSidebarProps> = ({ user, isCollapsed, to
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const getNavClass = (path: string) => {
|
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 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 collapsedClasses = isCollapsed ? 'px-3 justify-center' : 'px-3';
|
||||||
const activeClasses = 'bg-gray-700 text-white';
|
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" />
|
<Mail size={18} className="shrink-0" />
|
||||||
{!isCollapsed && <span>Email Addresses</span>}
|
{!isCollapsed && <span>Email Addresses</span>}
|
||||||
</Link>
|
</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 && (
|
{isSuperuser && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -10,16 +10,13 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
LogOut,
|
LogOut,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Briefcase,
|
|
||||||
Ticket,
|
Ticket,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Clock,
|
|
||||||
Plug,
|
Plug,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
CalendarOff,
|
CalendarOff,
|
||||||
LayoutTemplate,
|
|
||||||
MapPin,
|
|
||||||
Image,
|
Image,
|
||||||
|
BarChart3,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Business, User } from '../types';
|
import { Business, User } from '../types';
|
||||||
import { useLogout } from '../hooks/useAuth';
|
import { useLogout } from '../hooks/useAuth';
|
||||||
@@ -122,8 +119,8 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 px-3 space-y-6 overflow-y-auto pb-4">
|
<nav className="flex-1 px-3 space-y-6 overflow-y-auto pb-4">
|
||||||
{/* Core Features - Always visible */}
|
{/* Analytics Section - Dashboard and Payments */}
|
||||||
<SidebarSection isCollapsed={isCollapsed}>
|
<SidebarSection title={t('nav.sections.analytics', 'Analytics')} isCollapsed={isCollapsed}>
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
to="/dashboard"
|
to="/dashboard"
|
||||||
icon={LayoutDashboard}
|
icon={LayoutDashboard}
|
||||||
@@ -131,74 +128,55 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
exact
|
exact
|
||||||
/>
|
/>
|
||||||
{hasPermission('can_access_scheduler') && (
|
{hasPermission('can_access_payments') && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
to="/dashboard/scheduler"
|
to="/dashboard/payments"
|
||||||
icon={CalendarDays}
|
icon={CreditCard}
|
||||||
label={t('nav.scheduler')}
|
label={t('nav.payments')}
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{(isStaff && hasPermission('can_access_my_schedule')) && (
|
|
||||||
<SidebarItem
|
|
||||||
to="/dashboard/my-schedule"
|
|
||||||
icon={CalendarDays}
|
|
||||||
label={t('nav.mySchedule', 'My Schedule')}
|
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{(role === 'staff' || role === 'resource') && hasPermission('can_access_my_availability') && (
|
|
||||||
<SidebarItem
|
|
||||||
to="/dashboard/my-availability"
|
|
||||||
icon={CalendarOff}
|
|
||||||
label={t('nav.myAvailability', 'My Availability')}
|
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
|
disabled={!business.paymentsEnabled && role !== 'owner'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
|
|
||||||
{/* Manage Section - Show if user has any manage-related permission */}
|
{/* Staff-only: My Schedule and My Availability */}
|
||||||
{(canViewManagementPages ||
|
{((isStaff && hasPermission('can_access_my_schedule')) ||
|
||||||
hasPermission('can_access_site_builder') ||
|
((role === 'staff' || role === 'resource') && hasPermission('can_access_my_availability'))) && (
|
||||||
hasPermission('can_access_gallery') ||
|
<SidebarSection isCollapsed={isCollapsed}>
|
||||||
hasPermission('can_access_customers') ||
|
{(isStaff && hasPermission('can_access_my_schedule')) && (
|
||||||
hasPermission('can_access_services') ||
|
<SidebarItem
|
||||||
|
to="/dashboard/my-schedule"
|
||||||
|
icon={CalendarDays}
|
||||||
|
label={t('nav.mySchedule', 'My Schedule')}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(role === 'staff' || role === 'resource') && hasPermission('can_access_my_availability') && (
|
||||||
|
<SidebarItem
|
||||||
|
to="/dashboard/my-availability"
|
||||||
|
icon={CalendarOff}
|
||||||
|
label={t('nav.myAvailability', 'My Availability')}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SidebarSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Manage Section - Scheduler, Resources, Staff, Customers, Contracts, Time Blocks */}
|
||||||
|
{(hasPermission('can_access_scheduler') ||
|
||||||
hasPermission('can_access_resources') ||
|
hasPermission('can_access_resources') ||
|
||||||
hasPermission('can_access_staff') ||
|
hasPermission('can_access_staff') ||
|
||||||
|
hasPermission('can_access_customers') ||
|
||||||
hasPermission('can_access_contracts') ||
|
hasPermission('can_access_contracts') ||
|
||||||
hasPermission('can_access_time_blocks') ||
|
hasPermission('can_access_time_blocks') ||
|
||||||
hasPermission('can_access_locations')
|
hasPermission('can_access_gallery')
|
||||||
) && (
|
) && (
|
||||||
<SidebarSection title={t('nav.sections.manage', 'Manage')} isCollapsed={isCollapsed}>
|
<SidebarSection title={t('nav.sections.manage', 'Manage')} isCollapsed={isCollapsed}>
|
||||||
{hasPermission('can_access_site_builder') && (
|
{hasPermission('can_access_scheduler') && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
to="/dashboard/site-editor"
|
to="/dashboard/scheduler"
|
||||||
icon={LayoutTemplate}
|
icon={CalendarDays}
|
||||||
label={t('nav.siteBuilder', 'Site Builder')}
|
label={t('nav.scheduler')}
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{hasPermission('can_access_gallery') && (
|
|
||||||
<SidebarItem
|
|
||||||
to="/dashboard/gallery"
|
|
||||||
icon={Image}
|
|
||||||
label={t('nav.gallery', 'Media Gallery')}
|
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{hasPermission('can_access_customers') && (
|
|
||||||
<SidebarItem
|
|
||||||
to="/dashboard/customers"
|
|
||||||
icon={Users}
|
|
||||||
label={t('nav.customers')}
|
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{hasPermission('can_access_services') && (
|
|
||||||
<SidebarItem
|
|
||||||
to="/dashboard/services"
|
|
||||||
icon={Briefcase}
|
|
||||||
label={t('nav.services', 'Services')}
|
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -218,6 +196,22 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{hasPermission('can_access_customers') && (
|
||||||
|
<SidebarItem
|
||||||
|
to="/dashboard/customers"
|
||||||
|
icon={Users}
|
||||||
|
label={t('nav.customers')}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasPermission('can_access_gallery') && (
|
||||||
|
<SidebarItem
|
||||||
|
to="/dashboard/gallery"
|
||||||
|
icon={Image}
|
||||||
|
label={t('nav.gallery', 'Media Gallery')}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{hasPermission('can_access_contracts') && canUse('contracts') && (
|
{hasPermission('can_access_contracts') && canUse('contracts') && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
to="/dashboard/contracts"
|
to="/dashboard/contracts"
|
||||||
@@ -235,20 +229,11 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasPermission('can_access_locations') && (
|
|
||||||
<SidebarItem
|
|
||||||
to="/dashboard/locations"
|
|
||||||
icon={MapPin}
|
|
||||||
label={t('nav.locations', 'Locations')}
|
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
locked={!canUse('multi_location')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Communicate Section - Tickets + Messages */}
|
{/* Communicate Section - Messages + Tickets */}
|
||||||
{(canViewTickets || canSendMessages) && (
|
{(canSendMessages || canViewTickets) && (
|
||||||
<SidebarSection title={t('nav.sections.communicate', 'Communicate')} isCollapsed={isCollapsed}>
|
<SidebarSection title={t('nav.sections.communicate', 'Communicate')} isCollapsed={isCollapsed}>
|
||||||
{canSendMessages && (
|
{canSendMessages && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
@@ -269,19 +254,6 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Money Section - Payments */}
|
|
||||||
{hasPermission('can_access_payments') && (
|
|
||||||
<SidebarSection title={t('nav.sections.money', 'Money')} isCollapsed={isCollapsed}>
|
|
||||||
<SidebarItem
|
|
||||||
to="/dashboard/payments"
|
|
||||||
icon={CreditCard}
|
|
||||||
label={t('nav.payments')}
|
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
disabled={!business.paymentsEnabled && role !== 'owner'}
|
|
||||||
/>
|
|
||||||
</SidebarSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Extend Section - Automations */}
|
{/* Extend Section - Automations */}
|
||||||
{hasPermission('can_access_automations') && (
|
{hasPermission('can_access_automations') && (
|
||||||
<SidebarSection title={t('nav.sections.extend', 'Extend')} isCollapsed={isCollapsed}>
|
<SidebarSection title={t('nav.sections.extend', 'Extend')} isCollapsed={isCollapsed}>
|
||||||
@@ -291,6 +263,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
label={t('nav.automations', 'Automations')}
|
label={t('nav.automations', 'Automations')}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
locked={!canUse('automations')}
|
locked={!canUse('automations')}
|
||||||
|
badgeElement={<UnfinishedBadge />}
|
||||||
/>
|
/>
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
)}
|
)}
|
||||||
|
|||||||
142
frontend/src/components/StripeNotificationBanner.tsx
Normal file
142
frontend/src/components/StripeNotificationBanner.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Stripe Connect Notification Banner
|
||||||
|
*
|
||||||
|
* Displays important alerts and action items from Stripe to connected account holders.
|
||||||
|
* Shows verification requirements, upcoming deadlines, account restrictions, etc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
ConnectComponentsProvider,
|
||||||
|
ConnectNotificationBanner,
|
||||||
|
} from '@stripe/react-connect-js';
|
||||||
|
import { loadConnectAndInitialize } from '@stripe/connect-js';
|
||||||
|
import type { StripeConnectInstance } from '@stripe/connect-js';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { createAccountSession } from '../api/payments';
|
||||||
|
import { useDarkMode } from '../hooks/useDarkMode';
|
||||||
|
|
||||||
|
// Get appearance config based on dark mode
|
||||||
|
// See: https://docs.stripe.com/connect/customize-connect-embedded-components
|
||||||
|
const getAppearance = (isDark: boolean) => ({
|
||||||
|
overlays: 'drawer' as const,
|
||||||
|
variables: {
|
||||||
|
colorPrimary: '#3b82f6',
|
||||||
|
colorBackground: isDark ? '#1f2937' : '#ffffff',
|
||||||
|
colorText: isDark ? '#f9fafb' : '#111827',
|
||||||
|
colorSecondaryText: isDark ? '#9ca3af' : '#6b7280',
|
||||||
|
colorBorder: isDark ? '#374151' : '#e5e7eb',
|
||||||
|
colorDanger: '#ef4444',
|
||||||
|
fontFamily: 'Inter, system-ui, -apple-system, sans-serif',
|
||||||
|
fontSizeBase: '14px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
formBackgroundColor: isDark ? '#111827' : '#f9fafb',
|
||||||
|
formHighlightColorBorder: '#3b82f6',
|
||||||
|
buttonPrimaryColorBackground: '#3b82f6',
|
||||||
|
buttonPrimaryColorText: '#ffffff',
|
||||||
|
buttonSecondaryColorBackground: isDark ? '#374151' : '#f3f4f6',
|
||||||
|
buttonSecondaryColorText: isDark ? '#f9fafb' : '#374151',
|
||||||
|
badgeNeutralColorBackground: isDark ? '#374151' : '#f3f4f6',
|
||||||
|
badgeNeutralColorText: isDark ? '#d1d5db' : '#4b5563',
|
||||||
|
badgeSuccessColorBackground: isDark ? '#065f46' : '#d1fae5',
|
||||||
|
badgeSuccessColorText: isDark ? '#6ee7b7' : '#065f46',
|
||||||
|
badgeWarningColorBackground: isDark ? '#92400e' : '#fef3c7',
|
||||||
|
badgeWarningColorText: isDark ? '#fcd34d' : '#92400e',
|
||||||
|
badgeDangerColorBackground: isDark ? '#991b1b' : '#fee2e2',
|
||||||
|
badgeDangerColorText: isDark ? '#fca5a5' : '#991b1b',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface StripeNotificationBannerProps {
|
||||||
|
/** Called when there's an error loading the banner (optional, silently fails by default) */
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StripeNotificationBanner: React.FC<StripeNotificationBannerProps> = ({
|
||||||
|
onError,
|
||||||
|
}) => {
|
||||||
|
const isDark = useDarkMode();
|
||||||
|
const [stripeConnectInstance, setStripeConnectInstance] = useState<StripeConnectInstance | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
|
const initializedThemeRef = useRef<boolean | null>(null);
|
||||||
|
|
||||||
|
// Initialize the Stripe Connect instance
|
||||||
|
const initializeStripeConnect = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await createAccountSession();
|
||||||
|
const { client_secret, publishable_key } = response.data;
|
||||||
|
|
||||||
|
const instance = await loadConnectAndInitialize({
|
||||||
|
publishableKey: publishable_key,
|
||||||
|
fetchClientSecret: async () => client_secret,
|
||||||
|
appearance: getAppearance(isDark),
|
||||||
|
});
|
||||||
|
|
||||||
|
setStripeConnectInstance(instance);
|
||||||
|
initializedThemeRef.current = isDark;
|
||||||
|
setIsLoading(false);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[StripeNotificationBanner] Failed to initialize:', err);
|
||||||
|
setHasError(true);
|
||||||
|
setIsLoading(false);
|
||||||
|
onError?.(err.message || 'Failed to load notifications');
|
||||||
|
}
|
||||||
|
}, [isDark, onError]);
|
||||||
|
|
||||||
|
// Initialize on mount
|
||||||
|
useEffect(() => {
|
||||||
|
initializeStripeConnect();
|
||||||
|
}, [initializeStripeConnect]);
|
||||||
|
|
||||||
|
// Reinitialize on theme change
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
stripeConnectInstance &&
|
||||||
|
initializedThemeRef.current !== null &&
|
||||||
|
initializedThemeRef.current !== isDark
|
||||||
|
) {
|
||||||
|
// Theme changed, reinitialize
|
||||||
|
setStripeConnectInstance(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
initializeStripeConnect();
|
||||||
|
}
|
||||||
|
}, [isDark, stripeConnectInstance, initializeStripeConnect]);
|
||||||
|
|
||||||
|
// Handle load errors from the component itself
|
||||||
|
const handleLoadError = useCallback((loadError: { error: { message?: string }; elementTagName: string }) => {
|
||||||
|
console.error('Stripe notification banner load error:', loadError);
|
||||||
|
// Don't show error to user - just hide the banner
|
||||||
|
setHasError(true);
|
||||||
|
onError?.(loadError.error.message || 'Failed to load notification banner');
|
||||||
|
}, [onError]);
|
||||||
|
|
||||||
|
// Don't render anything if there's an error (fail silently)
|
||||||
|
if (hasError) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show subtle loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="mb-4 flex items-center justify-center py-2">
|
||||||
|
<Loader2 className="animate-spin text-gray-400" size={16} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the notification banner
|
||||||
|
if (stripeConnectInstance) {
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
<ConnectComponentsProvider connectInstance={stripeConnectInstance}>
|
||||||
|
<ConnectNotificationBanner onLoadError={handleLoadError} />
|
||||||
|
</ConnectComponentsProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StripeNotificationBanner;
|
||||||
842
frontend/src/components/StripeSettingsPanel.tsx
Normal file
842
frontend/src/components/StripeSettingsPanel.tsx
Normal file
@@ -0,0 +1,842 @@
|
|||||||
|
/**
|
||||||
|
* Stripe Settings Panel Component
|
||||||
|
*
|
||||||
|
* Comprehensive settings panel for Stripe Connect accounts.
|
||||||
|
* Allows tenants to configure payout schedules, business profile,
|
||||||
|
* branding, and view bank accounts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
Building2,
|
||||||
|
Palette,
|
||||||
|
Landmark,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
ExternalLink,
|
||||||
|
Save,
|
||||||
|
RefreshCw,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useStripeSettings, useUpdateStripeSettings, useCreateConnectLoginLink } from '../hooks/usePayments';
|
||||||
|
import type {
|
||||||
|
PayoutInterval,
|
||||||
|
WeeklyAnchor,
|
||||||
|
StripeSettingsUpdate,
|
||||||
|
} from '../api/payments';
|
||||||
|
|
||||||
|
interface StripeSettingsPanelProps {
|
||||||
|
stripeAccountId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabId = 'payouts' | 'business' | 'branding' | 'bank';
|
||||||
|
|
||||||
|
const StripeSettingsPanel: React.FC<StripeSettingsPanelProps> = ({ stripeAccountId }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [activeTab, setActiveTab] = useState<TabId>('payouts');
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: settings, isLoading, error, refetch } = useStripeSettings();
|
||||||
|
const updateMutation = useUpdateStripeSettings();
|
||||||
|
const loginLinkMutation = useCreateConnectLoginLink();
|
||||||
|
|
||||||
|
// Clear success message after 3 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (successMessage) {
|
||||||
|
const timer = setTimeout(() => setSuccessMessage(null), 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [successMessage]);
|
||||||
|
|
||||||
|
// Handle opening Stripe Dashboard
|
||||||
|
const handleOpenStripeDashboard = async () => {
|
||||||
|
try {
|
||||||
|
// Pass the current page URL as return/refresh URLs for Custom accounts
|
||||||
|
const currentUrl = window.location.href;
|
||||||
|
const result = await loginLinkMutation.mutateAsync({
|
||||||
|
return_url: currentUrl,
|
||||||
|
refresh_url: currentUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.type === 'login_link') {
|
||||||
|
// Express accounts: Open dashboard in new tab (user stays there)
|
||||||
|
window.open(result.url, '_blank');
|
||||||
|
} else {
|
||||||
|
// Custom accounts: Navigate in same window (redirects back when done)
|
||||||
|
window.location.href = result.url;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Error is shown via mutation state
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'payouts' as TabId, label: t('payments.stripeSettings.payouts'), icon: Calendar },
|
||||||
|
{ id: 'business' as TabId, label: t('payments.stripeSettings.businessProfile'), icon: Building2 },
|
||||||
|
{ id: 'branding' as TabId, label: t('payments.stripeSettings.branding'), icon: Palette },
|
||||||
|
{ id: 'bank' as TabId, label: t('payments.stripeSettings.bankAccounts'), icon: Landmark },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="animate-spin text-brand-500 mr-3" size={24} />
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">{t('payments.stripeSettings.loading')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="text-red-600 dark:text-red-400 shrink-0 mt-0.5" size={20} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-medium text-red-800 dark:text-red-300">{t('payments.stripeSettings.loadError')}</h4>
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-400 mt-1">
|
||||||
|
{error instanceof Error ? error.message : t('payments.stripeSettings.unknownError')}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="mt-3 flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-red-700 dark:text-red-300 bg-red-100 dark:bg-red-900/30 rounded-lg hover:bg-red-200 dark:hover:bg-red-900/50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
{t('common.retry')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async (updates: StripeSettingsUpdate) => {
|
||||||
|
try {
|
||||||
|
await updateMutation.mutateAsync(updates);
|
||||||
|
setSuccessMessage(t('payments.stripeSettings.savedSuccessfully'));
|
||||||
|
} catch {
|
||||||
|
// Error is handled by mutation state
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// For sub-tab links that need the static URL structure
|
||||||
|
const stripeDashboardUrl = `https://dashboard.stripe.com/${stripeAccountId.startsWith('acct_') ? stripeAccountId : ''}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header with Stripe Dashboard link */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{t('payments.stripeSettings.title')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{t('payments.stripeSettings.description')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenStripeDashboard}
|
||||||
|
disabled={loginLinkMutation.isPending}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/20 rounded-lg hover:bg-brand-100 dark:hover:bg-brand-900/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loginLinkMutation.isPending ? (
|
||||||
|
<Loader2 className="animate-spin" size={16} />
|
||||||
|
) : (
|
||||||
|
<ExternalLink size={16} />
|
||||||
|
)}
|
||||||
|
{t('payments.stripeSettings.stripeDashboard')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login link error */}
|
||||||
|
{loginLinkMutation.isError && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-2 text-red-700 dark:text-red-300">
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{loginLinkMutation.error instanceof Error
|
||||||
|
? loginLinkMutation.error.message
|
||||||
|
: t('payments.stripeSettings.loginLinkError')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success message */}
|
||||||
|
{successMessage && (
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-2 text-green-700 dark:text-green-300">
|
||||||
|
<CheckCircle size={16} />
|
||||||
|
<span className="text-sm font-medium">{successMessage}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{updateMutation.isError && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-2 text-red-700 dark:text-red-300">
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{updateMutation.error instanceof Error
|
||||||
|
? updateMutation.error.message
|
||||||
|
: t('payments.stripeSettings.saveError')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<nav className="flex -mb-px space-x-6">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`flex items-center gap-2 px-1 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
||||||
|
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<tab.icon size={16} />
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
<div className="min-h-[300px]">
|
||||||
|
{activeTab === 'payouts' && (
|
||||||
|
<PayoutsTab
|
||||||
|
settings={settings.payouts}
|
||||||
|
onSave={handleSave}
|
||||||
|
isSaving={updateMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === 'business' && (
|
||||||
|
<BusinessProfileTab
|
||||||
|
settings={settings.business_profile}
|
||||||
|
onSave={handleSave}
|
||||||
|
isSaving={updateMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === 'branding' && (
|
||||||
|
<BrandingTab
|
||||||
|
settings={settings.branding}
|
||||||
|
onSave={handleSave}
|
||||||
|
isSaving={updateMutation.isPending}
|
||||||
|
stripeDashboardUrl={stripeDashboardUrl}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === 'bank' && (
|
||||||
|
<BankAccountsTab
|
||||||
|
accounts={settings.bank_accounts}
|
||||||
|
stripeDashboardUrl={stripeDashboardUrl}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Payouts Tab
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface PayoutsTabProps {
|
||||||
|
settings: {
|
||||||
|
schedule: {
|
||||||
|
interval: PayoutInterval;
|
||||||
|
delay_days: number;
|
||||||
|
weekly_anchor: WeeklyAnchor | null;
|
||||||
|
monthly_anchor: number | null;
|
||||||
|
};
|
||||||
|
statement_descriptor: string;
|
||||||
|
};
|
||||||
|
onSave: (updates: StripeSettingsUpdate) => Promise<void>;
|
||||||
|
isSaving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PayoutsTab: React.FC<PayoutsTabProps> = ({ settings, onSave, isSaving }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [interval, setInterval] = useState<PayoutInterval>(settings.schedule.interval);
|
||||||
|
const [delayDays, setDelayDays] = useState(settings.schedule.delay_days);
|
||||||
|
const [weeklyAnchor, setWeeklyAnchor] = useState<WeeklyAnchor | null>(settings.schedule.weekly_anchor);
|
||||||
|
const [monthlyAnchor, setMonthlyAnchor] = useState<number | null>(settings.schedule.monthly_anchor);
|
||||||
|
const [statementDescriptor, setStatementDescriptor] = useState(settings.statement_descriptor);
|
||||||
|
const [descriptorError, setDescriptorError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const weekDays: WeeklyAnchor[] = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
||||||
|
|
||||||
|
const validateDescriptor = (value: string) => {
|
||||||
|
if (value.length > 22) {
|
||||||
|
setDescriptorError(t('payments.stripeSettings.descriptorTooLong'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (value && !/^[a-zA-Z0-9\s.\-]+$/.test(value)) {
|
||||||
|
setDescriptorError(t('payments.stripeSettings.descriptorInvalidChars'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setDescriptorError(null);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!validateDescriptor(statementDescriptor)) return;
|
||||||
|
|
||||||
|
const updates: StripeSettingsUpdate = {
|
||||||
|
payouts: {
|
||||||
|
schedule: {
|
||||||
|
interval,
|
||||||
|
delay_days: delayDays,
|
||||||
|
...(interval === 'weekly' && weeklyAnchor ? { weekly_anchor: weeklyAnchor } : {}),
|
||||||
|
...(interval === 'monthly' && monthlyAnchor ? { monthly_anchor: monthlyAnchor } : {}),
|
||||||
|
},
|
||||||
|
...(statementDescriptor ? { statement_descriptor: statementDescriptor } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await onSave(updates);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
{t('payments.stripeSettings.payoutsDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payout Schedule */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="font-medium text-gray-900 dark:text-white">{t('payments.stripeSettings.payoutSchedule')}</h4>
|
||||||
|
|
||||||
|
{/* Interval */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('payments.stripeSettings.payoutInterval')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={interval}
|
||||||
|
onChange={(e) => setInterval(e.target.value as PayoutInterval)}
|
||||||
|
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-brand-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="daily">{t('payments.stripeSettings.intervalDaily')}</option>
|
||||||
|
<option value="weekly">{t('payments.stripeSettings.intervalWeekly')}</option>
|
||||||
|
<option value="monthly">{t('payments.stripeSettings.intervalMonthly')}</option>
|
||||||
|
<option value="manual">{t('payments.stripeSettings.intervalManual')}</option>
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t('payments.stripeSettings.intervalHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delay Days */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('payments.stripeSettings.delayDays')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={delayDays}
|
||||||
|
onChange={(e) => setDelayDays(Number(e.target.value))}
|
||||||
|
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-brand-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
{[2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14].map((days) => (
|
||||||
|
<option key={days} value={days}>
|
||||||
|
{days} {t('payments.stripeSettings.days')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t('payments.stripeSettings.delayDaysHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weekly Anchor */}
|
||||||
|
{interval === 'weekly' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('payments.stripeSettings.weeklyAnchor')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={weeklyAnchor || 'monday'}
|
||||||
|
onChange={(e) => setWeeklyAnchor(e.target.value as WeeklyAnchor)}
|
||||||
|
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-brand-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
{weekDays.map((day) => (
|
||||||
|
<option key={day} value={day}>
|
||||||
|
{t(`payments.stripeSettings.${day}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Monthly Anchor */}
|
||||||
|
{interval === 'monthly' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('payments.stripeSettings.monthlyAnchor')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={monthlyAnchor || 1}
|
||||||
|
onChange={(e) => setMonthlyAnchor(Number(e.target.value))}
|
||||||
|
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-brand-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
{Array.from({ length: 31 }, (_, i) => i + 1).map((day) => (
|
||||||
|
<option key={day} value={day}>
|
||||||
|
{t('payments.stripeSettings.dayOfMonth', { day })}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statement Descriptor */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-gray-900 dark:text-white mb-4">{t('payments.stripeSettings.statementDescriptor')}</h4>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('payments.stripeSettings.descriptorLabel')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={statementDescriptor}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStatementDescriptor(e.target.value);
|
||||||
|
validateDescriptor(e.target.value);
|
||||||
|
}}
|
||||||
|
maxLength={22}
|
||||||
|
placeholder={t('payments.stripeSettings.descriptorPlaceholder')}
|
||||||
|
className={`w-full px-3 py-2 border rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent ${
|
||||||
|
descriptorError ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{descriptorError ? (
|
||||||
|
<p className="mt-1 text-xs text-red-500">{descriptorError}</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t('payments.stripeSettings.descriptorHint')} ({statementDescriptor.length}/22)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving || !!descriptorError}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-brand-500 rounded-lg hover:bg-brand-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="animate-spin" size={16} />
|
||||||
|
) : (
|
||||||
|
<Save size={16} />
|
||||||
|
)}
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Business Profile Tab
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface BusinessProfileTabProps {
|
||||||
|
settings: {
|
||||||
|
name: string;
|
||||||
|
support_email: string;
|
||||||
|
support_phone: string;
|
||||||
|
support_url: string;
|
||||||
|
};
|
||||||
|
onSave: (updates: StripeSettingsUpdate) => Promise<void>;
|
||||||
|
isSaving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BusinessProfileTab: React.FC<BusinessProfileTabProps> = ({ settings, onSave, isSaving }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [name, setName] = useState(settings.name);
|
||||||
|
const [supportEmail, setSupportEmail] = useState(settings.support_email);
|
||||||
|
const [supportPhone, setSupportPhone] = useState(settings.support_phone);
|
||||||
|
const [supportUrl, setSupportUrl] = useState(settings.support_url);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const updates: StripeSettingsUpdate = {
|
||||||
|
business_profile: {
|
||||||
|
name,
|
||||||
|
support_email: supportEmail,
|
||||||
|
support_phone: supportPhone,
|
||||||
|
support_url: supportUrl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await onSave(updates);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
{t('payments.stripeSettings.businessProfileDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{/* Business Name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('payments.stripeSettings.businessName')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
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-brand-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Support Email */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('payments.stripeSettings.supportEmail')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={supportEmail}
|
||||||
|
onChange={(e) => setSupportEmail(e.target.value)}
|
||||||
|
placeholder="support@yourbusiness.com"
|
||||||
|
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-brand-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t('payments.stripeSettings.supportEmailHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Support Phone */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('payments.stripeSettings.supportPhone')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={supportPhone}
|
||||||
|
onChange={(e) => setSupportPhone(e.target.value)}
|
||||||
|
placeholder="+1 (555) 123-4567"
|
||||||
|
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-brand-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Support URL */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('payments.stripeSettings.supportUrl')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={supportUrl}
|
||||||
|
onChange={(e) => setSupportUrl(e.target.value)}
|
||||||
|
placeholder="https://yourbusiness.com/support"
|
||||||
|
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-brand-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t('payments.stripeSettings.supportUrlHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-brand-500 rounded-lg hover:bg-brand-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="animate-spin" size={16} />
|
||||||
|
) : (
|
||||||
|
<Save size={16} />
|
||||||
|
)}
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Branding Tab
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface BrandingTabProps {
|
||||||
|
settings: {
|
||||||
|
primary_color: string;
|
||||||
|
secondary_color: string;
|
||||||
|
icon: string;
|
||||||
|
logo: string;
|
||||||
|
};
|
||||||
|
onSave: (updates: StripeSettingsUpdate) => Promise<void>;
|
||||||
|
isSaving: boolean;
|
||||||
|
stripeDashboardUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BrandingTab: React.FC<BrandingTabProps> = ({ settings, onSave, isSaving, stripeDashboardUrl }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [primaryColor, setPrimaryColor] = useState(settings.primary_color || '#3b82f6');
|
||||||
|
const [secondaryColor, setSecondaryColor] = useState(settings.secondary_color || '#10b981');
|
||||||
|
const [colorError, setColorError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const validateColor = (color: string): boolean => {
|
||||||
|
if (!color) return true;
|
||||||
|
return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(color);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (primaryColor && !validateColor(primaryColor)) {
|
||||||
|
setColorError(t('payments.stripeSettings.invalidColorFormat'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (secondaryColor && !validateColor(secondaryColor)) {
|
||||||
|
setColorError(t('payments.stripeSettings.invalidColorFormat'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setColorError(null);
|
||||||
|
|
||||||
|
const updates: StripeSettingsUpdate = {
|
||||||
|
branding: {
|
||||||
|
primary_color: primaryColor,
|
||||||
|
secondary_color: secondaryColor,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await onSave(updates);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
{t('payments.stripeSettings.brandingDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{colorError && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-300">{colorError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
|
{/* Primary Color */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('payments.stripeSettings.primaryColor')}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={primaryColor || '#3b82f6'}
|
||||||
|
onChange={(e) => setPrimaryColor(e.target.value)}
|
||||||
|
className="h-10 w-14 rounded border border-gray-300 dark:border-gray-600 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={primaryColor}
|
||||||
|
onChange={(e) => setPrimaryColor(e.target.value)}
|
||||||
|
placeholder="#3b82f6"
|
||||||
|
className="flex-1 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-brand-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Secondary Color */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('payments.stripeSettings.secondaryColor')}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={secondaryColor || '#10b981'}
|
||||||
|
onChange={(e) => setSecondaryColor(e.target.value)}
|
||||||
|
className="h-10 w-14 rounded border border-gray-300 dark:border-gray-600 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={secondaryColor}
|
||||||
|
onChange={(e) => setSecondaryColor(e.target.value)}
|
||||||
|
placeholder="#10b981"
|
||||||
|
className="flex-1 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-brand-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logo & Icon Info */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||||
|
<h4 className="font-medium text-gray-900 dark:text-white mb-2">{t('payments.stripeSettings.logoAndIcon')}</h4>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||||
|
{t('payments.stripeSettings.logoAndIconDescription')}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={`${stripeDashboardUrl}/settings/branding`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 text-sm font-medium text-brand-600 dark:text-brand-400 hover:underline"
|
||||||
|
>
|
||||||
|
<ExternalLink size={14} />
|
||||||
|
{t('payments.stripeSettings.uploadInStripeDashboard')}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Display current logo/icon if set */}
|
||||||
|
{(settings.icon || settings.logo) && (
|
||||||
|
<div className="flex items-center gap-4 mt-4 pt-4 border-t border-gray-200 dark:border-gray-600">
|
||||||
|
{settings.icon && (
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('payments.stripeSettings.icon')}</p>
|
||||||
|
<div className="w-12 h-12 bg-gray-200 dark:bg-gray-600 rounded flex items-center justify-center">
|
||||||
|
<CheckCircle className="text-green-500" size={20} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{settings.logo && (
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('payments.stripeSettings.logo')}</p>
|
||||||
|
<div className="w-24 h-12 bg-gray-200 dark:bg-gray-600 rounded flex items-center justify-center">
|
||||||
|
<CheckCircle className="text-green-500" size={20} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-brand-500 rounded-lg hover:bg-brand-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="animate-spin" size={16} />
|
||||||
|
) : (
|
||||||
|
<Save size={16} />
|
||||||
|
)}
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Bank Accounts Tab
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface BankAccountsTabProps {
|
||||||
|
accounts: Array<{
|
||||||
|
id: string;
|
||||||
|
bank_name: string;
|
||||||
|
last4: string;
|
||||||
|
currency: string;
|
||||||
|
default_for_currency: boolean;
|
||||||
|
status: string;
|
||||||
|
}>;
|
||||||
|
stripeDashboardUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BankAccountsTab: React.FC<BankAccountsTabProps> = ({ accounts, stripeDashboardUrl }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
{t('payments.stripeSettings.bankAccountsDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{accounts.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Landmark className="mx-auto text-gray-400 mb-3" size={40} />
|
||||||
|
<h4 className="font-medium text-gray-900 dark:text-white mb-1">
|
||||||
|
{t('payments.stripeSettings.noBankAccounts')}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
{t('payments.stripeSettings.noBankAccountsDescription')}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={`${stripeDashboardUrl}/settings/payouts`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-brand-500 rounded-lg hover:bg-brand-600"
|
||||||
|
>
|
||||||
|
<ExternalLink size={16} />
|
||||||
|
{t('payments.stripeSettings.addInStripeDashboard')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<div
|
||||||
|
key={account.id}
|
||||||
|
className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-2 bg-white dark:bg-gray-600 rounded-lg">
|
||||||
|
<Landmark className="text-gray-600 dark:text-gray-300" size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{account.bank_name || t('payments.stripeSettings.bankAccount')}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
••••{account.last4} · {account.currency.toUpperCase()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{account.default_for_currency && (
|
||||||
|
<span className="px-2 py-1 text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 rounded-full">
|
||||||
|
{t('payments.stripeSettings.default')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||||
|
account.status === 'verified'
|
||||||
|
? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300'
|
||||||
|
: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{account.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<a
|
||||||
|
href={`${stripeDashboardUrl}/settings/payouts`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 text-sm font-medium text-brand-600 dark:text-brand-400 hover:underline"
|
||||||
|
>
|
||||||
|
<ExternalLink size={14} />
|
||||||
|
{t('payments.stripeSettings.manageInStripeDashboard')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StripeSettingsPanel;
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Search, Moon, Sun, Menu } from 'lucide-react';
|
import { Moon, Sun, Menu } from 'lucide-react';
|
||||||
import { User } from '../types';
|
import { User } from '../types';
|
||||||
import UserProfileDropdown from './UserProfileDropdown';
|
import UserProfileDropdown from './UserProfileDropdown';
|
||||||
import LanguageSelector from './LanguageSelector';
|
import LanguageSelector from './LanguageSelector';
|
||||||
import NotificationDropdown from './NotificationDropdown';
|
import NotificationDropdown from './NotificationDropdown';
|
||||||
import SandboxToggle from './SandboxToggle';
|
import SandboxToggle from './SandboxToggle';
|
||||||
|
import HelpButton from './HelpButton';
|
||||||
|
import GlobalSearch from './GlobalSearch';
|
||||||
import { useSandbox } from '../contexts/SandboxContext';
|
import { useSandbox } from '../contexts/SandboxContext';
|
||||||
|
import { useUserNotifications } from '../hooks/useUserNotifications';
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -20,6 +23,9 @@ const TopBar: React.FC<TopBarProps> = ({ user, isDarkMode, toggleTheme, onMenuCl
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isSandbox, sandboxEnabled, toggleSandbox, isToggling } = useSandbox();
|
const { isSandbox, sandboxEnabled, toggleSandbox, isToggling } = useSandbox();
|
||||||
|
|
||||||
|
// Connect to user notifications WebSocket for real-time updates
|
||||||
|
useUserNotifications({ enabled: !!user });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex items-center justify-between h-16 px-4 sm:px-8 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 transition-colors duration-200 shrink-0">
|
<header className="flex items-center justify-between h-16 px-4 sm:px-8 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 transition-colors duration-200 shrink-0">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -30,16 +36,7 @@ const TopBar: React.FC<TopBarProps> = ({ user, isDarkMode, toggleTheme, onMenuCl
|
|||||||
>
|
>
|
||||||
<Menu size={24} />
|
<Menu size={24} />
|
||||||
</button>
|
</button>
|
||||||
<div className="relative hidden md:block w-96">
|
<GlobalSearch user={user} />
|
||||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-400">
|
|
||||||
<Search size={18} />
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder={t('common.search')}
|
|
||||||
className="w-full py-2 pl-10 pr-4 text-sm text-gray-700 dark:text-gray-200 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 placeholder-gray-400 dark:placeholder-gray-500 transition-colors duration-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -62,6 +59,8 @@ const TopBar: React.FC<TopBarProps> = ({ user, isDarkMode, toggleTheme, onMenuCl
|
|||||||
|
|
||||||
<NotificationDropdown onTicketClick={onTicketClick} />
|
<NotificationDropdown onTicketClick={onTicketClick} />
|
||||||
|
|
||||||
|
<HelpButton />
|
||||||
|
|
||||||
<UserProfileDropdown user={user} />
|
<UserProfileDropdown user={user} />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import ApiTokensSection from '../ApiTokensSection';
|
|
||||||
|
|
||||||
// Mock react-i18next
|
|
||||||
vi.mock('react-i18next', () => ({
|
|
||||||
useTranslation: () => ({
|
|
||||||
t: (key: string, defaultValue?: string) => defaultValue || key,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the hooks
|
|
||||||
const mockTokens = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
name: 'Test Token',
|
|
||||||
key_prefix: 'abc123',
|
|
||||||
scopes: ['read:appointments', 'write:appointments'],
|
|
||||||
is_active: true,
|
|
||||||
created_at: '2024-01-01T00:00:00Z',
|
|
||||||
last_used_at: '2024-01-02T00:00:00Z',
|
|
||||||
expires_at: null,
|
|
||||||
created_by: { full_name: 'John Doe', username: 'john' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
name: 'Revoked Token',
|
|
||||||
key_prefix: 'xyz789',
|
|
||||||
scopes: ['read:resources'],
|
|
||||||
is_active: false,
|
|
||||||
created_at: '2024-01-01T00:00:00Z',
|
|
||||||
last_used_at: null,
|
|
||||||
expires_at: null,
|
|
||||||
created_by: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockUseApiTokens = vi.fn();
|
|
||||||
const mockUseCreateApiToken = vi.fn();
|
|
||||||
const mockUseRevokeApiToken = vi.fn();
|
|
||||||
const mockUseUpdateApiToken = vi.fn();
|
|
||||||
|
|
||||||
vi.mock('../../hooks/useApiTokens', () => ({
|
|
||||||
useApiTokens: () => mockUseApiTokens(),
|
|
||||||
useCreateApiToken: () => mockUseCreateApiToken(),
|
|
||||||
useRevokeApiToken: () => mockUseRevokeApiToken(),
|
|
||||||
useUpdateApiToken: () => mockUseUpdateApiToken(),
|
|
||||||
API_SCOPES: [
|
|
||||||
{ value: 'read:appointments', label: 'Read Appointments', description: 'View appointments' },
|
|
||||||
{ value: 'write:appointments', label: 'Write Appointments', description: 'Create/edit appointments' },
|
|
||||||
{ value: 'read:resources', label: 'Read Resources', description: 'View resources' },
|
|
||||||
],
|
|
||||||
SCOPE_PRESETS: {
|
|
||||||
read_only: { label: 'Read Only', description: 'View data only', scopes: ['read:appointments', 'read:resources'] },
|
|
||||||
read_write: { label: 'Read & Write', description: 'Full access', scopes: ['read:appointments', 'write:appointments', 'read:resources'] },
|
|
||||||
custom: { label: 'Custom', description: 'Select individual permissions', scopes: [] },
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const createWrapper = () => {
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: { retry: false },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return ({ children }: { children: React.ReactNode }) => (
|
|
||||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('ApiTokensSection', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockUseCreateApiToken.mockReturnValue({ mutateAsync: vi.fn(), isPending: false });
|
|
||||||
mockUseRevokeApiToken.mockReturnValue({ mutateAsync: vi.fn(), isPending: false });
|
|
||||||
mockUseUpdateApiToken.mockReturnValue({ mutateAsync: vi.fn(), isPending: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders loading state', () => {
|
|
||||||
mockUseApiTokens.mockReturnValue({ data: undefined, isLoading: true, error: null });
|
|
||||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
||||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders error state', () => {
|
|
||||||
mockUseApiTokens.mockReturnValue({ data: undefined, isLoading: false, error: new Error('Failed') });
|
|
||||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
||||||
expect(screen.getByText(/Failed to load API tokens/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders empty state when no tokens', () => {
|
|
||||||
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
|
|
||||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
||||||
expect(screen.getByText('No API tokens yet')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders tokens list', () => {
|
|
||||||
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
|
|
||||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
||||||
expect(screen.getByText('Test Token')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Revoked Token')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders section title', () => {
|
|
||||||
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
|
|
||||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
||||||
expect(screen.getByText('API Tokens')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders New Token button', () => {
|
|
||||||
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
|
|
||||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
||||||
expect(screen.getByText('New Token')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders API Docs link', () => {
|
|
||||||
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
|
|
||||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
||||||
expect(screen.getByText('API Docs')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('opens new token modal when button clicked', () => {
|
|
||||||
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
|
|
||||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
||||||
fireEvent.click(screen.getByText('New Token'));
|
|
||||||
// Modal title should appear
|
|
||||||
expect(screen.getByRole('heading', { name: 'Create API Token' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows active tokens count', () => {
|
|
||||||
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
|
|
||||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
||||||
expect(screen.getByText(/Active Tokens \(1\)/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows revoked tokens count', () => {
|
|
||||||
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
|
|
||||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
||||||
expect(screen.getByText(/Revoked Tokens \(1\)/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows token key prefix', () => {
|
|
||||||
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
|
|
||||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
||||||
expect(screen.getByText(/abc123••••••••/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows revoked badge for inactive tokens', () => {
|
|
||||||
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
|
|
||||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
||||||
expect(screen.getByText('Revoked')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders description text', () => {
|
|
||||||
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
|
|
||||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
||||||
expect(screen.getByText(/Create and manage API tokens/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders create button in empty state', () => {
|
|
||||||
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
|
|
||||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
||||||
expect(screen.getByText('Create API Token')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
284
frontend/src/components/__tests__/GlobalSearch.test.tsx
Normal file
284
frontend/src/components/__tests__/GlobalSearch.test.tsx
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
|
||||||
|
// Mock hooks before importing component
|
||||||
|
const mockNavigationSearch = vi.fn();
|
||||||
|
const mockNavigate = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('react-router-dom', async () => {
|
||||||
|
const actual = await vi.importActual('react-router-dom');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useNavigate: () => mockNavigate,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../hooks/useNavigationSearch', () => ({
|
||||||
|
useNavigationSearch: () => mockNavigationSearch(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'common.search': 'Search...',
|
||||||
|
};
|
||||||
|
return translations[key] || key;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import GlobalSearch from '../GlobalSearch';
|
||||||
|
|
||||||
|
const mockUser = {
|
||||||
|
id: '1',
|
||||||
|
email: 'test@example.com',
|
||||||
|
role: 'owner',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockResults = [
|
||||||
|
{
|
||||||
|
path: '/dashboard',
|
||||||
|
title: 'Dashboard',
|
||||||
|
description: 'View your dashboard',
|
||||||
|
category: 'Manage',
|
||||||
|
icon: () => React.createElement('span', null, 'Icon'),
|
||||||
|
keywords: ['dashboard', 'home'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
title: 'Settings',
|
||||||
|
description: 'Manage your settings',
|
||||||
|
category: 'Settings',
|
||||||
|
icon: () => React.createElement('span', null, 'Icon'),
|
||||||
|
keywords: ['settings', 'preferences'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const renderWithRouter = (ui: React.ReactElement) => {
|
||||||
|
return render(React.createElement(BrowserRouter, null, ui));
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('GlobalSearch', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockNavigationSearch.mockReturnValue({
|
||||||
|
query: '',
|
||||||
|
setQuery: vi.fn(),
|
||||||
|
results: [],
|
||||||
|
clearSearch: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders search input', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(GlobalSearch, { user: mockUser })
|
||||||
|
);
|
||||||
|
expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders search icon', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(GlobalSearch, { user: mockUser })
|
||||||
|
);
|
||||||
|
const searchIcon = document.querySelector('[class*="lucide-search"]');
|
||||||
|
expect(searchIcon).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct aria attributes', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(GlobalSearch, { user: mockUser })
|
||||||
|
);
|
||||||
|
const input = screen.getByRole('combobox');
|
||||||
|
expect(input).toHaveAttribute('aria-label', 'Search...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is hidden on mobile', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(GlobalSearch, { user: mockUser })
|
||||||
|
);
|
||||||
|
const container = document.querySelector('.hidden.md\\:block');
|
||||||
|
expect(container).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Search Interaction', () => {
|
||||||
|
it('shows clear button when query is entered', () => {
|
||||||
|
mockNavigationSearch.mockReturnValue({
|
||||||
|
query: 'test',
|
||||||
|
setQuery: vi.fn(),
|
||||||
|
results: [],
|
||||||
|
clearSearch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(GlobalSearch, { user: mockUser })
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearIcon = document.querySelector('[class*="lucide-x"]');
|
||||||
|
expect(clearIcon).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls setQuery when typing', () => {
|
||||||
|
const mockSetQuery = vi.fn();
|
||||||
|
mockNavigationSearch.mockReturnValue({
|
||||||
|
query: '',
|
||||||
|
setQuery: mockSetQuery,
|
||||||
|
results: [],
|
||||||
|
clearSearch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(GlobalSearch, { user: mockUser })
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Search...');
|
||||||
|
fireEvent.change(input, { target: { value: 'test' } });
|
||||||
|
expect(mockSetQuery).toHaveBeenCalledWith('test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows dropdown with results', () => {
|
||||||
|
mockNavigationSearch.mockReturnValue({
|
||||||
|
query: 'dash',
|
||||||
|
setQuery: vi.fn(),
|
||||||
|
results: mockResults,
|
||||||
|
clearSearch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(GlobalSearch, { user: mockUser })
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Search...');
|
||||||
|
fireEvent.focus(input);
|
||||||
|
fireEvent.change(input, { target: { value: 'dash' } });
|
||||||
|
|
||||||
|
// Input should have expanded state
|
||||||
|
expect(input).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('No Results', () => {
|
||||||
|
it('shows no results message when no matches', () => {
|
||||||
|
mockNavigationSearch.mockReturnValue({
|
||||||
|
query: 'xyz',
|
||||||
|
setQuery: vi.fn(),
|
||||||
|
results: [],
|
||||||
|
clearSearch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(GlobalSearch, { user: mockUser })
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Search...');
|
||||||
|
fireEvent.focus(input);
|
||||||
|
|
||||||
|
// Trigger the open state by changing input
|
||||||
|
fireEvent.change(input, { target: { value: 'xyz' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Keyboard Navigation', () => {
|
||||||
|
it('has keyboard hint when results shown', () => {
|
||||||
|
mockNavigationSearch.mockReturnValue({
|
||||||
|
query: 'dash',
|
||||||
|
setQuery: vi.fn(),
|
||||||
|
results: mockResults,
|
||||||
|
clearSearch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(GlobalSearch, { user: mockUser })
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Search...');
|
||||||
|
fireEvent.focus(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Clear Search', () => {
|
||||||
|
it('calls clearSearch when clear button clicked', () => {
|
||||||
|
const mockClearSearch = vi.fn();
|
||||||
|
mockNavigationSearch.mockReturnValue({
|
||||||
|
query: 'test',
|
||||||
|
setQuery: vi.fn(),
|
||||||
|
results: [],
|
||||||
|
clearSearch: mockClearSearch,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(GlobalSearch, { user: mockUser })
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearButton = document.querySelector('[class*="lucide-x"]')?.closest('button');
|
||||||
|
if (clearButton) {
|
||||||
|
fireEvent.click(clearButton);
|
||||||
|
expect(mockClearSearch).toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accessibility', () => {
|
||||||
|
it('has combobox role', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(GlobalSearch, { user: mockUser })
|
||||||
|
);
|
||||||
|
const combobox = screen.getByRole('combobox');
|
||||||
|
expect(combobox).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has aria-haspopup attribute', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(GlobalSearch, { user: mockUser })
|
||||||
|
);
|
||||||
|
const input = screen.getByRole('combobox');
|
||||||
|
expect(input).toHaveAttribute('aria-haspopup', 'listbox');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has aria-controls attribute', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(GlobalSearch, { user: mockUser })
|
||||||
|
);
|
||||||
|
const input = screen.getByRole('combobox');
|
||||||
|
expect(input).toHaveAttribute('aria-controls', 'global-search-results');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has autocomplete off', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(GlobalSearch, { user: mockUser })
|
||||||
|
);
|
||||||
|
const input = screen.getByRole('combobox');
|
||||||
|
expect(input).toHaveAttribute('autocomplete', 'off');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Styling', () => {
|
||||||
|
it('has focus styles', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(GlobalSearch, { user: mockUser })
|
||||||
|
);
|
||||||
|
const input = screen.getByPlaceholderText('Search...');
|
||||||
|
expect(input.className).toContain('focus:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has dark mode support', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(GlobalSearch, { user: mockUser })
|
||||||
|
);
|
||||||
|
const input = screen.getByPlaceholderText('Search...');
|
||||||
|
expect(input.className).toContain('dark:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has proper width', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(GlobalSearch, { user: mockUser })
|
||||||
|
);
|
||||||
|
const container = document.querySelector('.w-96');
|
||||||
|
expect(container).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import HelpButton from '../HelpButton';
|
import HelpButton from '../HelpButton';
|
||||||
|
|
||||||
// Mock react-i18next
|
// Mock react-i18next
|
||||||
@@ -11,47 +11,207 @@ vi.mock('react-i18next', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe('HelpButton', () => {
|
describe('HelpButton', () => {
|
||||||
const renderHelpButton = (props: { helpPath: string; className?: string }) => {
|
const renderWithRouter = (initialPath: string) => {
|
||||||
return render(
|
return render(
|
||||||
<BrowserRouter>
|
<MemoryRouter initialEntries={[initialPath]}>
|
||||||
<HelpButton {...props} />
|
<HelpButton />
|
||||||
</BrowserRouter>
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
it('renders help link', () => {
|
describe('tenant dashboard routes (prefixed with /dashboard)', () => {
|
||||||
renderHelpButton({ helpPath: '/help/dashboard' });
|
it('renders help link on tenant dashboard', () => {
|
||||||
const link = screen.getByRole('link');
|
renderWithRouter('/dashboard');
|
||||||
expect(link).toBeInTheDocument();
|
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', () => {
|
describe('non-dashboard routes (public/platform)', () => {
|
||||||
renderHelpButton({ helpPath: '/help/dashboard' });
|
it('links to /help/scheduler for /scheduler', () => {
|
||||||
const link = screen.getByRole('link');
|
renderWithRouter('/scheduler');
|
||||||
expect(link).toHaveAttribute('href', '/help/dashboard');
|
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', () => {
|
describe('accessibility', () => {
|
||||||
renderHelpButton({ helpPath: '/help/test' });
|
it('has aria-label', () => {
|
||||||
expect(screen.getByText('Help')).toBeInTheDocument();
|
renderWithRouter('/dashboard');
|
||||||
});
|
const link = screen.getByRole('link');
|
||||||
|
expect(link).toHaveAttribute('aria-label', 'Help');
|
||||||
|
});
|
||||||
|
|
||||||
it('has title attribute', () => {
|
it('has title attribute', () => {
|
||||||
renderHelpButton({ helpPath: '/help/test' });
|
renderWithRouter('/dashboard');
|
||||||
const link = screen.getByRole('link');
|
const link = screen.getByRole('link');
|
||||||
expect(link).toHaveAttribute('title', 'Help');
|
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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ describe('NotificationDropdown', () => {
|
|||||||
const timeOffNotification = screen.getByText('Bob Johnson').closest('button');
|
const timeOffNotification = screen.getByText('Bob Johnson').closest('button');
|
||||||
fireEvent.click(timeOffNotification!);
|
fireEvent.click(timeOffNotification!);
|
||||||
|
|
||||||
expect(mockNavigate).toHaveBeenCalledWith('/time-blocks');
|
expect(mockNavigate).toHaveBeenCalledWith('/dashboard/time-blocks');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks all notifications as read', () => {
|
it('marks all notifications as read', () => {
|
||||||
@@ -320,15 +320,6 @@ describe('NotificationDropdown', () => {
|
|||||||
expect(mockClearAll).toHaveBeenCalled();
|
expect(mockClearAll).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('navigates to notifications page when "View all" is clicked', () => {
|
|
||||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
|
||||||
|
|
||||||
const viewAllButton = screen.getByText('View all');
|
|
||||||
fireEvent.click(viewAllButton);
|
|
||||||
|
|
||||||
expect(mockNavigate).toHaveBeenCalledWith('/notifications');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Notification icons', () => {
|
describe('Notification icons', () => {
|
||||||
@@ -444,7 +435,6 @@ describe('NotificationDropdown', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||||
|
|
||||||
expect(screen.getByText('Clear read')).toBeInTheDocument();
|
expect(screen.getByText('Clear read')).toBeInTheDocument();
|
||||||
expect(screen.getByText('View all')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides footer when there are no notifications', () => {
|
it('hides footer when there are no notifications', () => {
|
||||||
@@ -457,7 +447,6 @@ describe('NotificationDropdown', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||||
|
|
||||||
expect(screen.queryByText('Clear read')).not.toBeInTheDocument();
|
expect(screen.queryByText('Clear read')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('View all')).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,453 +1,66 @@
|
|||||||
/**
|
|
||||||
* Unit tests for Portal component
|
|
||||||
*
|
|
||||||
* Tests the Portal component which uses ReactDOM.createPortal to render
|
|
||||||
* children outside the parent DOM hierarchy. This is useful for modals,
|
|
||||||
* tooltips, and other UI elements that need to escape parent stacking contexts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, afterEach } from 'vitest';
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
import { render, screen, cleanup } from '@testing-library/react';
|
import { render, cleanup } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
import Portal from '../Portal';
|
import Portal from '../Portal';
|
||||||
|
|
||||||
describe('Portal', () => {
|
describe('Portal', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
// Clean up any rendered components
|
|
||||||
cleanup();
|
cleanup();
|
||||||
|
// Clean up any portal content
|
||||||
|
const portals = document.body.querySelectorAll('[data-testid]');
|
||||||
|
portals.forEach((portal) => portal.remove());
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Basic Rendering', () => {
|
it('renders children into document.body', () => {
|
||||||
it('should render children', () => {
|
render(
|
||||||
render(
|
React.createElement(Portal, {},
|
||||||
<Portal>
|
React.createElement('div', { 'data-testid': 'portal-content' }, 'Portal Content')
|
||||||
<div data-testid="portal-content">Portal Content</div>
|
)
|
||||||
</Portal>
|
);
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument();
|
// Content should be in document.body, not inside the render container
|
||||||
expect(screen.getByText('Portal Content')).toBeInTheDocument();
|
const content = document.body.querySelector('[data-testid="portal-content"]');
|
||||||
});
|
expect(content).toBeTruthy();
|
||||||
|
expect(content?.textContent).toBe('Portal Content');
|
||||||
it('should render text content', () => {
|
|
||||||
render(<Portal>Simple text content</Portal>);
|
|
||||||
|
|
||||||
expect(screen.getByText('Simple text content')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render complex JSX children', () => {
|
|
||||||
render(
|
|
||||||
<Portal>
|
|
||||||
<div>
|
|
||||||
<h1>Title</h1>
|
|
||||||
<p>Description</p>
|
|
||||||
<button>Click me</button>
|
|
||||||
</div>
|
|
||||||
</Portal>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByRole('heading', { name: 'Title' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Description')).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Portal Behavior', () => {
|
it('renders multiple children', () => {
|
||||||
it('should render content to document.body', () => {
|
render(
|
||||||
const { container } = render(
|
React.createElement(Portal, {},
|
||||||
<div id="root">
|
React.createElement('span', { 'data-testid': 'child1' }, 'First'),
|
||||||
<Portal>
|
React.createElement('span', { 'data-testid': 'child2' }, 'Second')
|
||||||
<div data-testid="portal-content">Portal Content</div>
|
)
|
||||||
</Portal>
|
);
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const portalContent = screen.getByTestId('portal-content');
|
expect(document.body.querySelector('[data-testid="child1"]')).toBeTruthy();
|
||||||
|
expect(document.body.querySelector('[data-testid="child2"]')).toBeTruthy();
|
||||||
// Portal content should NOT be inside the container
|
|
||||||
expect(container.contains(portalContent)).toBe(false);
|
|
||||||
|
|
||||||
// Portal content SHOULD be inside document.body
|
|
||||||
expect(document.body.contains(portalContent)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should escape parent DOM hierarchy', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<div id="parent" style={{ position: 'relative', zIndex: 1 }}>
|
|
||||||
<div id="child">
|
|
||||||
<Portal>
|
|
||||||
<div data-testid="portal-content">Escaped Content</div>
|
|
||||||
</Portal>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const portalContent = screen.getByTestId('portal-content');
|
|
||||||
const parent = container.querySelector('#parent');
|
|
||||||
|
|
||||||
// Portal content should not be inside parent
|
|
||||||
expect(parent?.contains(portalContent)).toBe(false);
|
|
||||||
|
|
||||||
// Portal content should be direct child of body
|
|
||||||
expect(portalContent.parentElement).toBe(document.body);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Multiple Children', () => {
|
it('unmounts portal content when component unmounts', () => {
|
||||||
it('should render multiple children', () => {
|
const { unmount } = render(
|
||||||
render(
|
React.createElement(Portal, {},
|
||||||
<Portal>
|
React.createElement('div', { 'data-testid': 'portal-content' }, 'Content')
|
||||||
<div data-testid="child-1">First child</div>
|
)
|
||||||
<div data-testid="child-2">Second child</div>
|
);
|
||||||
<div data-testid="child-3">Third child</div>
|
|
||||||
</Portal>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('child-1')).toBeInTheDocument();
|
expect(document.body.querySelector('[data-testid="portal-content"]')).toBeTruthy();
|
||||||
expect(screen.getByTestId('child-2')).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId('child-3')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render an array of children', () => {
|
unmount();
|
||||||
const items = ['Item 1', 'Item 2', 'Item 3'];
|
|
||||||
|
|
||||||
render(
|
expect(document.body.querySelector('[data-testid="portal-content"]')).toBeNull();
|
||||||
<Portal>
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<div key={index} data-testid={`item-${index}`}>
|
|
||||||
{item}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Portal>
|
|
||||||
);
|
|
||||||
|
|
||||||
items.forEach((item, index) => {
|
|
||||||
expect(screen.getByTestId(`item-${index}`)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(item)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render nested components', () => {
|
|
||||||
const NestedComponent = () => (
|
|
||||||
<div data-testid="nested">
|
|
||||||
<span>Nested Component</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<Portal>
|
|
||||||
<NestedComponent />
|
|
||||||
<div>Other content</div>
|
|
||||||
</Portal>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('nested')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Nested Component')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Other content')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Mounting Behavior', () => {
|
it('renders nested React elements correctly', () => {
|
||||||
it('should not render before component is mounted', () => {
|
render(
|
||||||
// This test verifies the internal mounting state
|
React.createElement(Portal, {},
|
||||||
const { rerender } = render(
|
React.createElement('div', { className: 'modal' },
|
||||||
<Portal>
|
React.createElement('h1', { 'data-testid': 'modal-title' }, 'Modal Title'),
|
||||||
<div data-testid="portal-content">Content</div>
|
React.createElement('p', { 'data-testid': 'modal-body' }, 'Modal Body')
|
||||||
</Portal>
|
)
|
||||||
);
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// After initial render, content should be present
|
expect(document.body.querySelector('[data-testid="modal-title"]')?.textContent).toBe('Modal Title');
|
||||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument();
|
expect(document.body.querySelector('[data-testid="modal-body"]')?.textContent).toBe('Modal Body');
|
||||||
|
|
||||||
// Re-render should still show content
|
|
||||||
rerender(
|
|
||||||
<Portal>
|
|
||||||
<div data-testid="portal-content">Updated Content</div>
|
|
||||||
</Portal>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('Updated Content')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Multiple Portals', () => {
|
|
||||||
it('should support multiple portal instances', () => {
|
|
||||||
render(
|
|
||||||
<div>
|
|
||||||
<Portal>
|
|
||||||
<div data-testid="portal-1">Portal 1</div>
|
|
||||||
</Portal>
|
|
||||||
<Portal>
|
|
||||||
<div data-testid="portal-2">Portal 2</div>
|
|
||||||
</Portal>
|
|
||||||
<Portal>
|
|
||||||
<div data-testid="portal-3">Portal 3</div>
|
|
||||||
</Portal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('portal-1')).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId('portal-2')).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId('portal-3')).toBeInTheDocument();
|
|
||||||
|
|
||||||
// All portals should be in document.body
|
|
||||||
expect(document.body.contains(screen.getByTestId('portal-1'))).toBe(true);
|
|
||||||
expect(document.body.contains(screen.getByTestId('portal-2'))).toBe(true);
|
|
||||||
expect(document.body.contains(screen.getByTestId('portal-3'))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should keep portals separate from each other', () => {
|
|
||||||
render(
|
|
||||||
<div>
|
|
||||||
<Portal>
|
|
||||||
<div data-testid="portal-1">
|
|
||||||
<span data-testid="content-1">Content 1</span>
|
|
||||||
</div>
|
|
||||||
</Portal>
|
|
||||||
<Portal>
|
|
||||||
<div data-testid="portal-2">
|
|
||||||
<span data-testid="content-2">Content 2</span>
|
|
||||||
</div>
|
|
||||||
</Portal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const portal1 = screen.getByTestId('portal-1');
|
|
||||||
const portal2 = screen.getByTestId('portal-2');
|
|
||||||
const content1 = screen.getByTestId('content-1');
|
|
||||||
const content2 = screen.getByTestId('content-2');
|
|
||||||
|
|
||||||
// Each portal should contain only its own content
|
|
||||||
expect(portal1.contains(content1)).toBe(true);
|
|
||||||
expect(portal1.contains(content2)).toBe(false);
|
|
||||||
expect(portal2.contains(content2)).toBe(true);
|
|
||||||
expect(portal2.contains(content1)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Cleanup', () => {
|
|
||||||
it('should remove content from body when unmounted', () => {
|
|
||||||
const { unmount } = render(
|
|
||||||
<Portal>
|
|
||||||
<div data-testid="portal-content">Temporary Content</div>
|
|
||||||
</Portal>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Content should exist initially
|
|
||||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Unmount the component
|
|
||||||
unmount();
|
|
||||||
|
|
||||||
// Content should be removed from DOM
|
|
||||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should clean up multiple portals on unmount', () => {
|
|
||||||
const { unmount } = render(
|
|
||||||
<div>
|
|
||||||
<Portal>
|
|
||||||
<div data-testid="portal-1">Portal 1</div>
|
|
||||||
</Portal>
|
|
||||||
<Portal>
|
|
||||||
<div data-testid="portal-2">Portal 2</div>
|
|
||||||
</Portal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('portal-1')).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId('portal-2')).toBeInTheDocument();
|
|
||||||
|
|
||||||
unmount();
|
|
||||||
|
|
||||||
expect(screen.queryByTestId('portal-1')).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId('portal-2')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Re-rendering', () => {
|
|
||||||
it('should update content on re-render', () => {
|
|
||||||
const { rerender } = render(
|
|
||||||
<Portal>
|
|
||||||
<div data-testid="portal-content">Initial Content</div>
|
|
||||||
</Portal>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('Initial Content')).toBeInTheDocument();
|
|
||||||
|
|
||||||
rerender(
|
|
||||||
<Portal>
|
|
||||||
<div data-testid="portal-content">Updated Content</div>
|
|
||||||
</Portal>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('Updated Content')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Initial Content')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle prop changes', () => {
|
|
||||||
const TestComponent = ({ message }: { message: string }) => (
|
|
||||||
<Portal>
|
|
||||||
<div data-testid="message">{message}</div>
|
|
||||||
</Portal>
|
|
||||||
);
|
|
||||||
|
|
||||||
const { rerender } = render(<TestComponent message="First message" />);
|
|
||||||
|
|
||||||
expect(screen.getByText('First message')).toBeInTheDocument();
|
|
||||||
|
|
||||||
rerender(<TestComponent message="Second message" />);
|
|
||||||
|
|
||||||
expect(screen.getByText('Second message')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('First message')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Edge Cases', () => {
|
|
||||||
it('should handle empty children', () => {
|
|
||||||
render(<Portal>{null}</Portal>);
|
|
||||||
|
|
||||||
// Should not throw error
|
|
||||||
expect(document.body).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle undefined children', () => {
|
|
||||||
render(<Portal>{undefined}</Portal>);
|
|
||||||
|
|
||||||
// Should not throw error
|
|
||||||
expect(document.body).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle boolean children', () => {
|
|
||||||
render(
|
|
||||||
<Portal>
|
|
||||||
{false && <div>Should not render</div>}
|
|
||||||
{true && <div data-testid="should-render">Should render</div>}
|
|
||||||
</Portal>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.queryByText('Should not render')).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId('should-render')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle conditional rendering', () => {
|
|
||||||
const { rerender } = render(
|
|
||||||
<Portal>
|
|
||||||
{false && <div data-testid="conditional">Conditional Content</div>}
|
|
||||||
</Portal>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.queryByTestId('conditional')).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
rerender(
|
|
||||||
<Portal>
|
|
||||||
{true && <div data-testid="conditional">Conditional Content</div>}
|
|
||||||
</Portal>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('conditional')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Integration with Parent Components', () => {
|
|
||||||
it('should work inside modals', () => {
|
|
||||||
const Modal = ({ children }: { children: React.ReactNode }) => (
|
|
||||||
<div className="modal" data-testid="modal">
|
|
||||||
<Portal>{children}</Portal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const { container } = render(
|
|
||||||
<Modal>
|
|
||||||
<div data-testid="modal-content">Modal Content</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
|
|
||||||
const modalContent = screen.getByTestId('modal-content');
|
|
||||||
const modal = container.querySelector('[data-testid="modal"]');
|
|
||||||
|
|
||||||
// Modal content should not be inside modal container
|
|
||||||
expect(modal?.contains(modalContent)).toBe(false);
|
|
||||||
|
|
||||||
// Modal content should be in document.body
|
|
||||||
expect(document.body.contains(modalContent)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve event handlers', () => {
|
|
||||||
let clicked = false;
|
|
||||||
const handleClick = () => {
|
|
||||||
clicked = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
render(
|
|
||||||
<Portal>
|
|
||||||
<button data-testid="button" onClick={handleClick}>
|
|
||||||
Click me
|
|
||||||
</button>
|
|
||||||
</Portal>
|
|
||||||
);
|
|
||||||
|
|
||||||
const button = screen.getByTestId('button');
|
|
||||||
button.click();
|
|
||||||
|
|
||||||
expect(clicked).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve CSS classes and styles', () => {
|
|
||||||
render(
|
|
||||||
<Portal>
|
|
||||||
<div
|
|
||||||
data-testid="styled-content"
|
|
||||||
className="custom-class"
|
|
||||||
style={{ color: 'red', fontSize: '16px' }}
|
|
||||||
>
|
|
||||||
Styled Content
|
|
||||||
</div>
|
|
||||||
</Portal>
|
|
||||||
);
|
|
||||||
|
|
||||||
const styledContent = screen.getByTestId('styled-content');
|
|
||||||
|
|
||||||
expect(styledContent).toHaveClass('custom-class');
|
|
||||||
// Check styles individually - color may be normalized to rgb()
|
|
||||||
expect(styledContent.style.color).toBeTruthy();
|
|
||||||
expect(styledContent.style.fontSize).toBe('16px');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Accessibility', () => {
|
|
||||||
it('should maintain ARIA attributes', () => {
|
|
||||||
render(
|
|
||||||
<Portal>
|
|
||||||
<div
|
|
||||||
data-testid="aria-content"
|
|
||||||
role="dialog"
|
|
||||||
aria-label="Test Dialog"
|
|
||||||
aria-describedby="description"
|
|
||||||
>
|
|
||||||
<div id="description">Dialog description</div>
|
|
||||||
</div>
|
|
||||||
</Portal>
|
|
||||||
);
|
|
||||||
|
|
||||||
const content = screen.getByTestId('aria-content');
|
|
||||||
|
|
||||||
expect(content).toHaveAttribute('role', 'dialog');
|
|
||||||
expect(content).toHaveAttribute('aria-label', 'Test Dialog');
|
|
||||||
expect(content).toHaveAttribute('aria-describedby', 'description');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support semantic HTML inside portal', () => {
|
|
||||||
render(
|
|
||||||
<Portal>
|
|
||||||
<dialog open data-testid="dialog">
|
|
||||||
<h2>Dialog Title</h2>
|
|
||||||
<p>Dialog content</p>
|
|
||||||
</dialog>
|
|
||||||
</Portal>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('dialog')).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('heading', { name: 'Dialog Title' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
290
frontend/src/components/__tests__/QuotaOverageModal.test.tsx
Normal file
290
frontend/src/components/__tests__/QuotaOverageModal.test.tsx
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import QuotaOverageModal, { resetQuotaOverageModalDismissal } from '../QuotaOverageModal';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, defaultValue: string | Record<string, unknown>, params?: Record<string, unknown>) => {
|
||||||
|
if (typeof defaultValue === 'string') {
|
||||||
|
let text = defaultValue;
|
||||||
|
if (params) {
|
||||||
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
|
text = text.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), String(v));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const futureDate = new Date();
|
||||||
|
futureDate.setDate(futureDate.getDate() + 14);
|
||||||
|
|
||||||
|
const urgentDate = new Date();
|
||||||
|
urgentDate.setDate(urgentDate.getDate() + 5);
|
||||||
|
|
||||||
|
const criticalDate = new Date();
|
||||||
|
criticalDate.setDate(criticalDate.getDate() + 1);
|
||||||
|
|
||||||
|
const baseOverage = {
|
||||||
|
id: 'overage-1',
|
||||||
|
quota_type: 'MAX_RESOURCES',
|
||||||
|
display_name: 'Resources',
|
||||||
|
current_usage: 15,
|
||||||
|
allowed_limit: 10,
|
||||||
|
overage_amount: 5,
|
||||||
|
grace_period_ends_at: futureDate.toISOString(),
|
||||||
|
days_remaining: 14,
|
||||||
|
};
|
||||||
|
|
||||||
|
const urgentOverage = {
|
||||||
|
...baseOverage,
|
||||||
|
id: 'overage-2',
|
||||||
|
grace_period_ends_at: urgentDate.toISOString(),
|
||||||
|
days_remaining: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const criticalOverage = {
|
||||||
|
...baseOverage,
|
||||||
|
id: 'overage-3',
|
||||||
|
grace_period_ends_at: criticalDate.toISOString(),
|
||||||
|
days_remaining: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderWithRouter = (component: React.ReactNode) => {
|
||||||
|
return render(React.createElement(MemoryRouter, null, component));
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('QuotaOverageModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when no overages', () => {
|
||||||
|
const { container } = renderWithRouter(
|
||||||
|
React.createElement(QuotaOverageModal, {
|
||||||
|
overages: [],
|
||||||
|
onDismiss: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders modal when overages exist', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(QuotaOverageModal, {
|
||||||
|
overages: [baseOverage],
|
||||||
|
onDismiss: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Quota Exceeded')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows normal title for normal overages', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(QuotaOverageModal, {
|
||||||
|
overages: [baseOverage],
|
||||||
|
onDismiss: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Quota Exceeded')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows urgent title when days remaining <= 7', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(QuotaOverageModal, {
|
||||||
|
overages: [urgentOverage],
|
||||||
|
onDismiss: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Action Required Soon')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows critical title when days remaining <= 1', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(QuotaOverageModal, {
|
||||||
|
overages: [criticalOverage],
|
||||||
|
onDismiss: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Action Required Immediately!')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows days remaining in subtitle', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(QuotaOverageModal, {
|
||||||
|
overages: [baseOverage],
|
||||||
|
onDismiss: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(screen.getByText('14 days remaining')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "1 day remaining" for single day', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(QuotaOverageModal, {
|
||||||
|
overages: [criticalOverage],
|
||||||
|
onDismiss: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(screen.getByText('1 day remaining')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays overage details', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(QuotaOverageModal, {
|
||||||
|
overages: [baseOverage],
|
||||||
|
onDismiss: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Resources')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('15 used / 10 allowed')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('+5')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays multiple overages', () => {
|
||||||
|
const multipleOverages = [
|
||||||
|
baseOverage,
|
||||||
|
{
|
||||||
|
...baseOverage,
|
||||||
|
id: 'overage-4',
|
||||||
|
quota_type: 'MAX_SERVICES',
|
||||||
|
display_name: 'Services',
|
||||||
|
current_usage: 8,
|
||||||
|
allowed_limit: 5,
|
||||||
|
overage_amount: 3,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(QuotaOverageModal, {
|
||||||
|
overages: multipleOverages,
|
||||||
|
onDismiss: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Resources')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Services')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows grace period information', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(QuotaOverageModal, {
|
||||||
|
overages: [baseOverage],
|
||||||
|
onDismiss: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/Grace period ends on/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows manage quota link', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(QuotaOverageModal, {
|
||||||
|
overages: [baseOverage],
|
||||||
|
onDismiss: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const link = screen.getByRole('link', { name: /Manage Quota/i });
|
||||||
|
expect(link).toHaveAttribute('href', '/dashboard/settings/quota');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows remind me later button', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(QuotaOverageModal, {
|
||||||
|
overages: [baseOverage],
|
||||||
|
onDismiss: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Remind Me Later')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onDismiss when close button clicked', () => {
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(QuotaOverageModal, {
|
||||||
|
overages: [baseOverage],
|
||||||
|
onDismiss,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const closeButton = screen.getByLabelText('Close');
|
||||||
|
fireEvent.click(closeButton);
|
||||||
|
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onDismiss when remind me later clicked', () => {
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(QuotaOverageModal, {
|
||||||
|
overages: [baseOverage],
|
||||||
|
onDismiss,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText('Remind Me Later'));
|
||||||
|
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets sessionStorage when dismissed', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(QuotaOverageModal, {
|
||||||
|
overages: [baseOverage],
|
||||||
|
onDismiss: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText('Remind Me Later'));
|
||||||
|
expect(sessionStorage.getItem('quota_overage_modal_dismissed')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show modal when already dismissed', () => {
|
||||||
|
sessionStorage.setItem('quota_overage_modal_dismissed', 'true');
|
||||||
|
const { container } = renderWithRouter(
|
||||||
|
React.createElement(QuotaOverageModal, {
|
||||||
|
overages: [baseOverage],
|
||||||
|
onDismiss: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(container.querySelector('.fixed')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows warning icons', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(QuotaOverageModal, {
|
||||||
|
overages: [baseOverage],
|
||||||
|
onDismiss: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const icons = document.querySelectorAll('[class*="lucide"]');
|
||||||
|
expect(icons.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows clock icon for grace period', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
React.createElement(QuotaOverageModal, {
|
||||||
|
overages: [baseOverage],
|
||||||
|
onDismiss: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const clockIcon = document.querySelector('.lucide-clock');
|
||||||
|
expect(clockIcon).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resetQuotaOverageModalDismissal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the dismissal flag from sessionStorage', () => {
|
||||||
|
sessionStorage.setItem('quota_overage_modal_dismissed', 'true');
|
||||||
|
expect(sessionStorage.getItem('quota_overage_modal_dismissed')).toBe('true');
|
||||||
|
|
||||||
|
resetQuotaOverageModalDismissal();
|
||||||
|
expect(sessionStorage.getItem('quota_overage_modal_dismissed')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -348,7 +348,7 @@ describe('QuotaWarningBanner', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const link = screen.getByRole('link', { name: /manage quota/i });
|
const link = screen.getByRole('link', { name: /manage quota/i });
|
||||||
expect(link).toHaveAttribute('href', '/settings/quota');
|
expect(link).toHaveAttribute('href', '/dashboard/settings/quota');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display external link icon', () => {
|
it('should display external link icon', () => {
|
||||||
@@ -565,7 +565,7 @@ describe('QuotaWarningBanner', () => {
|
|||||||
// Check Manage Quota link
|
// Check Manage Quota link
|
||||||
const link = screen.getByRole('link', { name: /manage quota/i });
|
const link = screen.getByRole('link', { name: /manage quota/i });
|
||||||
expect(link).toBeInTheDocument();
|
expect(link).toBeInTheDocument();
|
||||||
expect(link).toHaveAttribute('href', '/settings/quota');
|
expect(link).toHaveAttribute('href', '/dashboard/settings/quota');
|
||||||
|
|
||||||
// Check dismiss button
|
// Check dismiss button
|
||||||
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
|
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
|
||||||
|
|||||||
419
frontend/src/components/__tests__/Sidebar.test.tsx
Normal file
419
frontend/src/components/__tests__/Sidebar.test.tsx
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import React from 'react';
|
||||||
|
import Sidebar from '../Sidebar';
|
||||||
|
import { Business, User } from '../../types';
|
||||||
|
|
||||||
|
// Mock react-i18next with proper translations
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'nav.dashboard': 'Dashboard',
|
||||||
|
'nav.payments': 'Payments',
|
||||||
|
'nav.scheduler': 'Scheduler',
|
||||||
|
'nav.resources': 'Resources',
|
||||||
|
'nav.staff': 'Staff',
|
||||||
|
'nav.customers': 'Customers',
|
||||||
|
'nav.contracts': 'Contracts',
|
||||||
|
'nav.timeBlocks': 'Time Blocks',
|
||||||
|
'nav.messages': 'Messages',
|
||||||
|
'nav.tickets': 'Tickets',
|
||||||
|
'nav.businessSettings': 'Settings',
|
||||||
|
'nav.helpDocs': 'Help',
|
||||||
|
'nav.mySchedule': 'My Schedule',
|
||||||
|
'nav.myAvailability': 'My Availability',
|
||||||
|
'nav.automations': 'Automations',
|
||||||
|
'nav.gallery': 'Gallery',
|
||||||
|
'nav.expandSidebar': 'Expand sidebar',
|
||||||
|
'nav.collapseSidebar': 'Collapse sidebar',
|
||||||
|
'nav.smoothSchedule': 'SmoothSchedule',
|
||||||
|
'nav.sections.analytics': 'Analytics',
|
||||||
|
'nav.sections.manage': 'Manage',
|
||||||
|
'nav.sections.communicate': 'Communicate',
|
||||||
|
'nav.sections.extend': 'Extend',
|
||||||
|
'auth.signOut': 'Sign Out',
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, defaultValue?: string) => translations[key] || defaultValue || key,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock useLogout hook
|
||||||
|
const mockMutate = vi.fn();
|
||||||
|
vi.mock('../../hooks/useAuth', () => ({
|
||||||
|
useLogout: () => ({
|
||||||
|
mutate: mockMutate,
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock usePlanFeatures hook
|
||||||
|
vi.mock('../../hooks/usePlanFeatures', () => ({
|
||||||
|
usePlanFeatures: () => ({
|
||||||
|
canUse: () => true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock lucide-react icons
|
||||||
|
vi.mock('lucide-react', () => ({
|
||||||
|
LayoutDashboard: () => React.createElement('span', { 'data-testid': 'icon-dashboard' }),
|
||||||
|
CalendarDays: () => React.createElement('span', { 'data-testid': 'icon-calendar' }),
|
||||||
|
Settings: () => React.createElement('span', { 'data-testid': 'icon-settings' }),
|
||||||
|
Users: () => React.createElement('span', { 'data-testid': 'icon-users' }),
|
||||||
|
CreditCard: () => React.createElement('span', { 'data-testid': 'icon-credit-card' }),
|
||||||
|
MessageSquare: () => React.createElement('span', { 'data-testid': 'icon-message' }),
|
||||||
|
LogOut: () => React.createElement('span', { 'data-testid': 'icon-logout' }),
|
||||||
|
ClipboardList: () => React.createElement('span', { 'data-testid': 'icon-clipboard' }),
|
||||||
|
Ticket: () => React.createElement('span', { 'data-testid': 'icon-ticket' }),
|
||||||
|
HelpCircle: () => React.createElement('span', { 'data-testid': 'icon-help' }),
|
||||||
|
Plug: () => React.createElement('span', { 'data-testid': 'icon-plug' }),
|
||||||
|
FileSignature: () => React.createElement('span', { 'data-testid': 'icon-file-signature' }),
|
||||||
|
CalendarOff: () => React.createElement('span', { 'data-testid': 'icon-calendar-off' }),
|
||||||
|
Image: () => React.createElement('span', { 'data-testid': 'icon-image' }),
|
||||||
|
BarChart3: () => React.createElement('span', { 'data-testid': 'icon-bar-chart' }),
|
||||||
|
ChevronDown: () => React.createElement('span', { 'data-testid': 'icon-chevron-down' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock SmoothScheduleLogo
|
||||||
|
vi.mock('../SmoothScheduleLogo', () => ({
|
||||||
|
default: ({ className }: { className?: string }) =>
|
||||||
|
React.createElement('div', { 'data-testid': 'smooth-schedule-logo', className }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock UnfinishedBadge
|
||||||
|
vi.mock('../ui/UnfinishedBadge', () => ({
|
||||||
|
default: () => React.createElement('span', { 'data-testid': 'unfinished-badge' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock SidebarComponents
|
||||||
|
vi.mock('../navigation/SidebarComponents', () => ({
|
||||||
|
SidebarSection: ({ children, title, isCollapsed }: { children: React.ReactNode; title?: string; isCollapsed: boolean }) =>
|
||||||
|
React.createElement('div', { 'data-testid': 'sidebar-section', 'data-title': title },
|
||||||
|
!isCollapsed && title && React.createElement('span', {}, title),
|
||||||
|
children
|
||||||
|
),
|
||||||
|
SidebarItem: ({
|
||||||
|
to,
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
isCollapsed,
|
||||||
|
exact,
|
||||||
|
disabled,
|
||||||
|
locked,
|
||||||
|
badgeElement,
|
||||||
|
}: any) =>
|
||||||
|
React.createElement('a', {
|
||||||
|
href: to,
|
||||||
|
'data-testid': `sidebar-item-${label.replace(/\s+/g, '-').toLowerCase()}`,
|
||||||
|
'data-disabled': disabled,
|
||||||
|
'data-locked': locked,
|
||||||
|
}, !isCollapsed && label, badgeElement),
|
||||||
|
SidebarDivider: ({ isCollapsed }: { isCollapsed: boolean }) =>
|
||||||
|
React.createElement('hr', { 'data-testid': 'sidebar-divider' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockBusiness: Business = {
|
||||||
|
id: '1',
|
||||||
|
name: 'Test Business',
|
||||||
|
subdomain: 'test',
|
||||||
|
primaryColor: '#3b82f6',
|
||||||
|
secondaryColor: '#10b981',
|
||||||
|
logoUrl: null,
|
||||||
|
logoDisplayMode: 'text-and-logo' as const,
|
||||||
|
paymentsEnabled: true,
|
||||||
|
timezone: 'America/Denver',
|
||||||
|
plan: 'professional',
|
||||||
|
created_at: '2024-01-01',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockOwnerUser: User = {
|
||||||
|
id: '1',
|
||||||
|
email: 'owner@example.com',
|
||||||
|
first_name: 'Test',
|
||||||
|
last_name: 'Owner',
|
||||||
|
display_name: 'Test Owner',
|
||||||
|
role: 'owner',
|
||||||
|
business_subdomain: 'test',
|
||||||
|
is_verified: true,
|
||||||
|
phone: null,
|
||||||
|
avatar_url: null,
|
||||||
|
effective_permissions: {},
|
||||||
|
can_send_messages: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockStaffUser: User = {
|
||||||
|
id: '2',
|
||||||
|
email: 'staff@example.com',
|
||||||
|
first_name: 'Staff',
|
||||||
|
last_name: 'Member',
|
||||||
|
display_name: 'Staff Member',
|
||||||
|
role: 'staff',
|
||||||
|
business_subdomain: 'test',
|
||||||
|
is_verified: true,
|
||||||
|
phone: null,
|
||||||
|
avatar_url: null,
|
||||||
|
effective_permissions: {
|
||||||
|
can_access_scheduler: true,
|
||||||
|
can_access_customers: true,
|
||||||
|
can_access_my_schedule: true,
|
||||||
|
can_access_settings: false,
|
||||||
|
can_access_payments: false,
|
||||||
|
can_access_staff: false,
|
||||||
|
can_access_resources: false,
|
||||||
|
can_access_tickets: true,
|
||||||
|
can_access_messages: true,
|
||||||
|
},
|
||||||
|
can_send_messages: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSidebar = (
|
||||||
|
user: User = mockOwnerUser,
|
||||||
|
business: Business = mockBusiness,
|
||||||
|
isCollapsed: boolean = false,
|
||||||
|
toggleCollapse: () => void = vi.fn()
|
||||||
|
) => {
|
||||||
|
return render(
|
||||||
|
React.createElement(
|
||||||
|
MemoryRouter,
|
||||||
|
{ initialEntries: ['/dashboard'] },
|
||||||
|
React.createElement(Sidebar, {
|
||||||
|
user,
|
||||||
|
business,
|
||||||
|
isCollapsed,
|
||||||
|
toggleCollapse,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Sidebar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Header / Logo', () => {
|
||||||
|
it('displays business name when logo display mode is text-and-logo', () => {
|
||||||
|
renderSidebar();
|
||||||
|
expect(screen.getByText('Test Business')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays business initials when no logo URL', () => {
|
||||||
|
renderSidebar();
|
||||||
|
expect(screen.getByText('TE')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays subdomain info', () => {
|
||||||
|
renderSidebar();
|
||||||
|
expect(screen.getByText('test.smoothschedule.com')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays logo when provided', () => {
|
||||||
|
const businessWithLogo = {
|
||||||
|
...mockBusiness,
|
||||||
|
logoUrl: 'https://example.com/logo.png',
|
||||||
|
};
|
||||||
|
renderSidebar(mockOwnerUser, businessWithLogo);
|
||||||
|
const logos = screen.getAllByAltText('Test Business');
|
||||||
|
expect(logos.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only displays logo when mode is logo-only', () => {
|
||||||
|
const businessLogoOnly = {
|
||||||
|
...mockBusiness,
|
||||||
|
logoUrl: 'https://example.com/logo.png',
|
||||||
|
logoDisplayMode: 'logo-only' as const,
|
||||||
|
};
|
||||||
|
renderSidebar(mockOwnerUser, businessLogoOnly);
|
||||||
|
// Should not display business name in text
|
||||||
|
expect(screen.queryByText('test.smoothschedule.com')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls toggleCollapse when header is clicked', () => {
|
||||||
|
const toggleCollapse = vi.fn();
|
||||||
|
renderSidebar(mockOwnerUser, mockBusiness, false, toggleCollapse);
|
||||||
|
|
||||||
|
// Find the button in the header area
|
||||||
|
const collapseButton = screen.getByRole('button', { name: /sidebar/i });
|
||||||
|
fireEvent.click(collapseButton);
|
||||||
|
|
||||||
|
expect(toggleCollapse).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Owner Navigation', () => {
|
||||||
|
it('displays Dashboard link', () => {
|
||||||
|
renderSidebar();
|
||||||
|
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays Payments link for owner', () => {
|
||||||
|
renderSidebar();
|
||||||
|
expect(screen.getByText('Payments')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays Scheduler link for owner', () => {
|
||||||
|
renderSidebar();
|
||||||
|
expect(screen.getByText('Scheduler')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays Resources link for owner', () => {
|
||||||
|
renderSidebar();
|
||||||
|
expect(screen.getByText('Resources')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays Staff link for owner', () => {
|
||||||
|
renderSidebar();
|
||||||
|
expect(screen.getByText('Staff')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays Customers link for owner', () => {
|
||||||
|
renderSidebar();
|
||||||
|
expect(screen.getByText('Customers')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays Contracts link for owner', () => {
|
||||||
|
renderSidebar();
|
||||||
|
expect(screen.getByText('Contracts')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays Time Blocks link for owner', () => {
|
||||||
|
renderSidebar();
|
||||||
|
expect(screen.getByText('Time Blocks')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays Messages link for owner', () => {
|
||||||
|
renderSidebar();
|
||||||
|
expect(screen.getByText('Messages')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays Settings link for owner', () => {
|
||||||
|
renderSidebar();
|
||||||
|
expect(screen.getByText('Settings')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays Help link', () => {
|
||||||
|
renderSidebar();
|
||||||
|
expect(screen.getByText('Help')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Staff Navigation', () => {
|
||||||
|
it('displays Dashboard link for staff', () => {
|
||||||
|
renderSidebar(mockStaffUser);
|
||||||
|
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays Scheduler when staff has permission', () => {
|
||||||
|
renderSidebar(mockStaffUser);
|
||||||
|
expect(screen.getByText('Scheduler')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays My Schedule when staff has permission', () => {
|
||||||
|
renderSidebar(mockStaffUser);
|
||||||
|
expect(screen.getByText('My Schedule')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays Customers when staff has permission', () => {
|
||||||
|
renderSidebar(mockStaffUser);
|
||||||
|
expect(screen.getByText('Customers')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays Tickets when staff has permission', () => {
|
||||||
|
renderSidebar(mockStaffUser);
|
||||||
|
expect(screen.getByText('Tickets')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides Settings when staff lacks permission', () => {
|
||||||
|
renderSidebar(mockStaffUser);
|
||||||
|
// Settings should NOT be visible for staff without settings permission
|
||||||
|
const settingsLinks = screen.queryAllByText('Settings');
|
||||||
|
expect(settingsLinks.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides Payments when staff lacks permission', () => {
|
||||||
|
renderSidebar(mockStaffUser);
|
||||||
|
expect(screen.queryByText('Payments')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides Staff when staff lacks permission', () => {
|
||||||
|
renderSidebar(mockStaffUser);
|
||||||
|
// The word "Staff" appears in "Staff Member" name, so we need to be specific
|
||||||
|
// Check that the Staff navigation item doesn't exist
|
||||||
|
const staffLinks = screen.queryAllByText('Staff');
|
||||||
|
// If it shows, it's from the Staff Member display name or similar
|
||||||
|
// We should check there's no navigation link to /dashboard/staff
|
||||||
|
expect(screen.queryByRole('link', { name: 'Staff' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides Resources when staff lacks permission', () => {
|
||||||
|
renderSidebar(mockStaffUser);
|
||||||
|
expect(screen.queryByText('Resources')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Collapsed State', () => {
|
||||||
|
it('hides text when collapsed', () => {
|
||||||
|
renderSidebar(mockOwnerUser, mockBusiness, true);
|
||||||
|
expect(screen.queryByText('Test Business')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('test.smoothschedule.com')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies correct width class when collapsed', () => {
|
||||||
|
const { container } = renderSidebar(mockOwnerUser, mockBusiness, true);
|
||||||
|
const sidebar = container.firstChild;
|
||||||
|
expect(sidebar).toHaveClass('w-20');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies correct width class when expanded', () => {
|
||||||
|
const { container } = renderSidebar(mockOwnerUser, mockBusiness, false);
|
||||||
|
const sidebar = container.firstChild;
|
||||||
|
expect(sidebar).toHaveClass('w-64');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Sign Out', () => {
|
||||||
|
it('calls logout mutation when sign out is clicked', () => {
|
||||||
|
renderSidebar();
|
||||||
|
|
||||||
|
const signOutButton = screen.getByRole('button', { name: /sign\s*out/i });
|
||||||
|
fireEvent.click(signOutButton);
|
||||||
|
|
||||||
|
expect(mockMutate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays SmoothSchedule logo', () => {
|
||||||
|
renderSidebar();
|
||||||
|
expect(screen.getByTestId('smooth-schedule-logo')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Sections', () => {
|
||||||
|
it('displays Analytics section', () => {
|
||||||
|
renderSidebar();
|
||||||
|
expect(screen.getByText('Analytics')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays Manage section for owner', () => {
|
||||||
|
renderSidebar();
|
||||||
|
expect(screen.getByText('Manage')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays Communicate section when user can send messages', () => {
|
||||||
|
renderSidebar();
|
||||||
|
expect(screen.getByText('Communicate')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays divider', () => {
|
||||||
|
renderSidebar();
|
||||||
|
expect(screen.getByTestId('sidebar-divider')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Feature Locking', () => {
|
||||||
|
it('displays Automations link for owner with permissions', () => {
|
||||||
|
renderSidebar();
|
||||||
|
expect(screen.getByText('Automations')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user