Files
smoothschedule/frontend/src/pages/CreateAutomation.tsx
poduck cfb626b595 Rename plugins to automations and fix scheduler/payment bugs
Major changes:
- Rename "plugins" to "automations" throughout codebase
- Move automation system to dedicated app (scheduling/automations/)
- Add new automation marketplace, creation, and management pages

Bug fixes:
- Fix payment endpoint 500 errors (use has_feature() instead of attribute)
- Fix scheduler showing "0 AM" instead of "12 AM"
- Fix scheduler business hours double-inversion display issue
- Fix scheduler scroll-to-current-time when switching views
- Fix week view centering on wrong day (use Sunday-based indexing)
- Fix capacity widget overflow with many resources
- Fix Recharts minWidth/minHeight console warnings

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 11:56:01 -05:00

569 lines
24 KiB
TypeScript

/**
* Create Automation Page
*
* Allows businesses to create custom automations with code editor,
* category selection, and visibility options.
*/
import React, { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Code,
Save,
Eye,
EyeOff,
ArrowLeft,
Info,
CheckCircle,
AlertTriangle,
Mail,
BarChart3,
Users,
Calendar,
Link as LinkIcon,
Bot,
Package,
Image,
HelpCircle,
} from 'lucide-react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import api from '../api/client';
import { AutomationCategory } from '../types';
import { usePlanFeatures } from '../hooks/usePlanFeatures';
// Category icon mapping
const categoryIcons: Record<AutomationCategory, React.ReactNode> = {
EMAIL: <Mail className="h-4 w-4" />,
REPORTS: <BarChart3 className="h-4 w-4" />,
CUSTOMER: <Users className="h-4 w-4" />,
BOOKING: <Calendar className="h-4 w-4" />,
INTEGRATION: <LinkIcon className="h-4 w-4" />,
AUTOMATION: <Bot className="h-4 w-4" />,
OTHER: <Package className="h-4 w-4" />,
};
// Category descriptions
const categoryDescriptions: Record<AutomationCategory, string> = {
EMAIL: 'Email notifications and automated messaging',
REPORTS: 'Analytics, reports, and data exports',
CUSTOMER: 'Customer engagement and retention',
BOOKING: 'Scheduling and booking automation',
INTEGRATION: 'Third-party service integrations',
AUTOMATION: 'General business automation',
OTHER: 'Miscellaneous automations',
};
// Default automation code template
const DEFAULT_AUTOMATION_CODE = `# My Custom Automation
#
# This automation runs on a schedule and can interact with your business data.
# Use template variables to make your automation configurable.
#
# Available template variables:
# {{PROMPT:variable_name:default_value:description}}
# {{CONTEXT:context_type}} - Access business context (CUSTOMERS, EVENTS, etc.)
# {{DATE:format}} - Current date in specified format
# Example: Get all customers who haven't booked in 30 days
inactive_days = int("{{PROMPT:inactive_days:30:Days of inactivity}}")
# Access customer data
customers = {{CONTEXT:CUSTOMERS}}
# Filter inactive customers
from datetime import datetime, timedelta
cutoff_date = datetime.now() - timedelta(days=inactive_days)
inactive_customers = [
c for c in customers
if not c.get('last_booking') or
datetime.fromisoformat(c['last_booking']) < cutoff_date
]
# Return results (will be logged)
result = {
'inactive_count': len(inactive_customers),
'customers': inactive_customers[:10], # First 10 for preview
'message': f"Found {len(inactive_customers)} inactive customers"
}
`;
interface FormData {
name: string;
shortDescription: string;
description: string;
category: AutomationCategory;
automationCode: string;
version: string;
logoUrl: string;
visibility: 'PRIVATE' | 'PUBLIC';
}
const CreateAutomation: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { canUse } = usePlanFeatures();
const [formData, setFormData] = useState<FormData>({
name: '',
shortDescription: '',
description: '',
category: 'AUTOMATION',
automationCode: DEFAULT_AUTOMATION_CODE,
version: '1.0.0',
logoUrl: '',
visibility: 'PRIVATE',
});
const [showPreview, setShowPreview] = useState(false);
const [extractedVariables, setExtractedVariables] = useState<any[]>([]);
// Extract template variables from code
const extractVariables = useCallback((code: string) => {
const promptPattern = /\{\{PROMPT:([^:}]+):([^:}]*):([^}]*)\}\}/g;
const contextPattern = /\{\{CONTEXT:([^}]+)\}\}/g;
const datePattern = /\{\{DATE:([^}]+)\}\}/g;
const variables: any[] = [];
let match;
while ((match = promptPattern.exec(code)) !== null) {
variables.push({
type: 'PROMPT',
name: match[1],
default: match[2],
description: match[3],
});
}
while ((match = contextPattern.exec(code)) !== null) {
variables.push({
type: 'CONTEXT',
name: match[1],
});
}
while ((match = datePattern.exec(code)) !== null) {
variables.push({
type: 'DATE',
format: match[1],
});
}
return variables;
}, []);
// Update extracted variables when code changes
const handleCodeChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newCode = e.target.value;
setFormData(prev => ({ ...prev, automationCode: newCode }));
setExtractedVariables(extractVariables(newCode));
};
// Create automation mutation
const createMutation = useMutation({
mutationFn: async (data: FormData) => {
const payload = {
name: data.name,
short_description: data.shortDescription,
description: data.description,
category: data.category,
automation_code: data.automationCode,
version: data.version,
logo_url: data.logoUrl || undefined,
visibility: data.visibility,
};
const response = await api.post('/automation-templates/', payload);
return response.data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['automation-templates'] });
navigate('/dashboard/automations/my-automations');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createMutation.mutate(formData);
};
// Check if user can create automations
const canCreateAutomations = canUse('can_create_automations');
if (!canCreateAutomations) {
return (
<div className="p-8 max-w-4xl mx-auto">
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-8 text-center">
<AlertTriangle className="h-16 w-16 mx-auto text-amber-500 mb-4" />
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
{t('automations.upgradeRequired', 'Upgrade Required')}
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
{t('automations.upgradeToCreate', 'Automation creation is available on higher-tier plans. Upgrade your subscription to create custom automations.')}
</p>
<button
onClick={() => navigate('/dashboard/settings/billing')}
className="px-6 py-3 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
>
{t('automations.viewPlans', 'View Plans')}
</button>
</div>
</div>
);
}
return (
<div className="p-8 max-w-6xl mx-auto">
{/* Header */}
<div className="mb-8">
<button
onClick={() => navigate('/dashboard/automations/my-automations')}
className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white mb-4 transition-colors"
>
<ArrowLeft size={20} />
{t('common.back', 'Back')}
</button>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
<Code className="text-brand-500" />
{t('automations.createAutomation', 'Create Custom Automation')}
</h1>
<p className="text-gray-500 dark:text-gray-400 mt-1">
{t('automations.createAutomationDescription', 'Build a custom automation for your business')}
</p>
</div>
<a
href="/help/automations"
target="_blank"
className="flex items-center gap-2 text-brand-600 dark:text-brand-400 hover:underline"
>
<HelpCircle size={18} />
{t('automations.viewDocs', 'View Documentation')}
</a>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Basic Info */}
<div className="lg:col-span-1 space-y-6">
{/* Automation Name */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{t('automations.basicInfo', 'Basic Information')}
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('automations.automationName', 'Automation Name')} *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: 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"
placeholder="e.g., Win Back Inactive Customers"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('automations.shortDescription', 'Short Description')} *
</label>
<input
type="text"
value={formData.shortDescription}
onChange={(e) => setFormData(prev => ({ ...prev, shortDescription: 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"
placeholder="Brief summary for marketplace listing"
maxLength={200}
required
/>
<p className="mt-1 text-xs text-gray-500">{formData.shortDescription.length}/200</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('automations.description', 'Full Description')}
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
rows={4}
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 resize-none"
placeholder="Detailed description of what this automation does..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('automations.category', 'Category')} *
</label>
<select
value={formData.category}
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value as AutomationCategory }))}
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"
>
{Object.entries(categoryDescriptions).map(([key, desc]) => (
<option key={key} value={key}>
{key} - {desc}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('automations.version', 'Version')}
</label>
<input
type="text"
value={formData.version}
onChange={(e) => setFormData(prev => ({ ...prev, version: 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"
placeholder="1.0.0"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<Image size={16} className="inline mr-1" />
{t('automations.logoUrl', 'Logo URL')} ({t('common.optional', 'optional')})
</label>
<input
type="url"
value={formData.logoUrl}
onChange={(e) => setFormData(prev => ({ ...prev, logoUrl: 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"
placeholder="https://example.com/logo.png"
/>
</div>
</div>
</div>
{/* Visibility */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{t('automations.visibility', 'Visibility')}
</h3>
<div className="space-y-3">
<label className="flex items-start gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<input
type="radio"
name="visibility"
value="PRIVATE"
checked={formData.visibility === 'PRIVATE'}
onChange={(e) => setFormData(prev => ({ ...prev, visibility: 'PRIVATE' }))}
className="mt-1"
/>
<div>
<div className="flex items-center gap-2">
<EyeOff size={16} className="text-gray-500" />
<span className="font-medium text-gray-900 dark:text-white">
{t('automations.private', 'Private')}
</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('automations.privateDescription', 'Only you can see and use this automation')}
</p>
</div>
</label>
<label className="flex items-start gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<input
type="radio"
name="visibility"
value="PUBLIC"
checked={formData.visibility === 'PUBLIC'}
onChange={(e) => setFormData(prev => ({ ...prev, visibility: 'PUBLIC' }))}
className="mt-1"
/>
<div>
<div className="flex items-center gap-2">
<Eye size={16} className="text-green-500" />
<span className="font-medium text-gray-900 dark:text-white">
{t('automations.public', 'Public (Marketplace)')}
</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('automations.publicDescription', 'Submit for review to be listed in the marketplace')}
</p>
</div>
</label>
</div>
{formData.visibility === 'PUBLIC' && (
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-start gap-2">
<Info size={16} className="text-blue-500 mt-0.5 flex-shrink-0" />
<p className="text-sm text-blue-800 dark:text-blue-200">
{t('automations.publicNote', 'Public automations require approval before appearing in the marketplace. Our team will review your code for security and quality.')}
</p>
</div>
</div>
)}
</div>
{/* Extracted Variables */}
{extractedVariables.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{t('automations.templateVariables', 'Detected Variables')}
</h3>
<div className="space-y-2">
{extractedVariables.map((v, idx) => (
<div key={idx} className="p-2 bg-gray-50 dark:bg-gray-900/50 rounded-lg text-sm">
<div className="flex items-center gap-2">
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${
v.type === 'PROMPT' ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300' :
v.type === 'CONTEXT' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' :
'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
}`}>
{v.type}
</span>
<span className="font-mono text-gray-900 dark:text-white">
{v.name || v.format}
</span>
</div>
{v.description && (
<p className="text-gray-500 dark:text-gray-400 mt-1 ml-4">
{v.description}
</p>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* Right Column - Code Editor */}
<div className="lg:col-span-2 space-y-6">
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Code size={20} className="text-brand-500" />
{t('automations.automationCode', 'Automation Code')}
</h3>
<button
type="button"
onClick={() => setShowPreview(!showPreview)}
className="flex items-center gap-2 text-sm text-brand-600 dark:text-brand-400 hover:underline"
>
{showPreview ? <EyeOff size={16} /> : <Eye size={16} />}
{showPreview ? t('automations.hidePreview', 'Hide Preview') : t('automations.showPreview', 'Show Preview')}
</button>
</div>
{showPreview ? (
<div className="max-h-[600px] overflow-auto">
<SyntaxHighlighter
language="python"
style={vscDarkPlus}
customStyle={{
margin: 0,
borderRadius: 0,
fontSize: '0.875rem',
}}
showLineNumbers
>
{formData.automationCode}
</SyntaxHighlighter>
</div>
) : (
<textarea
value={formData.automationCode}
onChange={handleCodeChange}
rows={25}
className="w-full px-4 py-4 bg-[#1e1e1e] text-gray-100 font-mono text-sm focus:outline-none resize-none"
placeholder="# Write your automation code here..."
spellCheck={false}
/>
)}
</div>
{/* Quick Reference */}
<div className="bg-gradient-to-r from-brand-50 to-indigo-50 dark:from-brand-900/20 dark:to-indigo-900/20 rounded-xl border border-brand-200 dark:border-brand-800 p-6">
<h4 className="font-semibold text-gray-900 dark:text-white mb-3">
{t('automations.quickReference', 'Quick Reference')}
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div>
<p className="font-medium text-gray-900 dark:text-white mb-1">Prompt Variable</p>
<code className="text-xs bg-white dark:bg-gray-800 px-2 py-1 rounded block">
{`{{PROMPT:name:default:desc}}`}
</code>
</div>
<div>
<p className="font-medium text-gray-900 dark:text-white mb-1">Context Data</p>
<code className="text-xs bg-white dark:bg-gray-800 px-2 py-1 rounded block">
{`{{CONTEXT:CUSTOMERS}}`}
</code>
</div>
<div>
<p className="font-medium text-gray-900 dark:text-white mb-1">Date Format</p>
<code className="text-xs bg-white dark:bg-gray-800 px-2 py-1 rounded block">
{`{{DATE:%Y-%m-%d}}`}
</code>
</div>
</div>
</div>
</div>
</div>
{/* Error Message */}
{createMutation.isError && (
<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-center gap-2">
<AlertTriangle className="text-red-500" size={20} />
<p className="text-red-800 dark:text-red-200">
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to create automation'}
</p>
</div>
</div>
)}
{/* Submit Buttons */}
<div className="flex items-center justify-end gap-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={() => navigate('/dashboard/automations/my-automations')}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
{t('common.cancel', 'Cancel')}
</button>
<button
type="submit"
disabled={createMutation.isPending || !formData.name || !formData.shortDescription}
className="flex items-center gap-2 px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
>
{createMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
{t('automations.creating', 'Creating...')}
</>
) : (
<>
<Save size={18} />
{formData.visibility === 'PUBLIC'
? t('automations.createAndSubmit', 'Create & Submit for Review')
: t('automations.createAutomation', 'Create Automation')}
</>
)}
</button>
</div>
</form>
</div>
);
};
export default CreateAutomation;