feat: Email templates, bulk delete, communication credits, plan features
- Add email template presets for Browse Templates tab (12 templates) - Add bulk selection and deletion for My Templates tab - Add communication credits system with Twilio integration - Add payment views for credit purchases and auto-reload - Add SMS reminder and masked calling plan permissions - Fix appointment status mapping (frontend/backend mismatch) - Clear masquerade stack on login/logout for session hygiene - Update platform settings with credit configuration - Add new migrations for Twilio and Stripe payment fields 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
125
.gemini/tmp/update_email_template.py
Normal file
125
.gemini/tmp/update_email_template.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from schedule.models import EmailTemplate
|
||||
import json
|
||||
|
||||
html_content = """
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f5f5; padding: 40px 20px;">
|
||||
<tbody><tr>
|
||||
<td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 6px; box-shadow: 0 4px 6px rgba(0,0,0,0.05), 0 1px 3px rgba(0,0,0,0.08);">
|
||||
<!-- Header -->
|
||||
<tbody><tr>
|
||||
<td style="background-color: #4f46e5; padding: 30px; text-align: center; border-radius: 6px 6px 0 0;">
|
||||
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600;">Appointment Confirmed</h1>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<p style="margin: 0 0 20px 0; color: #374151; font-size: 16px; line-height: 1.6;">
|
||||
Hello <strong>{{CUSTOMER_NAME}}</strong>,
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 30px 0; color: #374151; font-size: 16px; line-height: 1.6;">
|
||||
Your appointment has been confirmed. We look forward to seeing you!
|
||||
</p>
|
||||
|
||||
<!-- Appointment Details Card -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f5f5; border-radius: 6px; border: 1px solid #e5e7eb; margin-bottom: 30px;">
|
||||
<tbody><tr>
|
||||
<td style="padding: 20px;">
|
||||
<table width="100%" cellpadding="8" cellspacing="0">
|
||||
<tbody><tr>
|
||||
<td style="color: #6b7280; font-size: 14px; font-weight: 600;">Service:</td>
|
||||
<td style="color: #111827; font-size: 14px; text-align: right;">{{SERVICE_NAME}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color: #6b7280; font-size: 14px; font-weight: 600;">Date & Time:</td>
|
||||
<td style="color: #111827; font-size: 14px; text-align: right;">{{EVENT_START_DATETIME}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color: #6b7280; font-size: 14px; font-weight: 600;">Duration:</td>
|
||||
<td style="color: #111827; font-size: 14px; text-align: right;">{{SERVICE_DURATION}} minutes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color: #6b7280; font-size: 14px; font-weight: 600;">With:</td>
|
||||
<td style="color: #111827; font-size: 14px; text-align: right;">{{STAFF_NAME}}</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
|
||||
<!-- Call to Action Button (example - not in original but good to show professional button style) -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom: 30px;">
|
||||
<tr>
|
||||
<td align="center" style="padding-top: 10px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td align="center" bgcolor="#4f46e5" style="border-radius: 5px; background-color: #4f46e5; padding: 12px 25px;">
|
||||
<a href="{{VIEW_APPOINTMENT_LINK}}" target="_blank" style="font-size: 16px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; color: #ffffff; text-decoration: none; font-weight: 600; display: inline-block;">View My Appointment</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0 0 20px 0; color: #6b7280; font-size: 14px; line-height: 1.6;">
|
||||
If you need to reschedule or cancel, please contact us at least 24 hours in advance.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color: #f9fafb; padding: 20px 30px; text-align: center; border-radius: 0 0 6px 6px; border-top: 1px solid #e5e7eb;">
|
||||
<p style="margin: 0; color: #6b7280; font-size: 14px;">
|
||||
<strong>{{BUSINESS_NAME}}</strong><br>
|
||||
{{BUSINESS_EMAIL}} | {{BUSINESS_PHONE}}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
|
||||
</body>
|
||||
"""
|
||||
|
||||
text_content = """
|
||||
Hello {{CUSTOMER_NAME}},
|
||||
|
||||
Your appointment has been confirmed. We look forward to seeing you!
|
||||
|
||||
---
|
||||
Appointment Details:
|
||||
Service: {{SERVICE_NAME}}
|
||||
Date & Time: {{EVENT_START_DATETIME}}
|
||||
Duration: {{SERVICE_DURATION}} minutes
|
||||
With: {{STAFF_NAME}}
|
||||
---
|
||||
|
||||
If you need to reschedule or cancel, please contact us at least 24 hours in advance.
|
||||
|
||||
View your appointment: {{VIEW_APPOINTMENT_LINK}}
|
||||
|
||||
---
|
||||
{{BUSINESS_NAME}}
|
||||
{{BUSINESS_EMAIL}} | {{BUSINESS_PHONE}}
|
||||
"""
|
||||
|
||||
template_name = "Appointment Confirmed" # Assuming this is the name of the template to update
|
||||
|
||||
try:
|
||||
template = EmailTemplate.objects.get(name=template_name)
|
||||
template.html_content = html_content
|
||||
template.text_content = text_content
|
||||
template.save()
|
||||
print(f"Successfully updated template '{template_name}'.")
|
||||
except EmailTemplate.DoesNotExist:
|
||||
print(f"Error: Template '{template_name}' not found.")
|
||||
except Exception as e:
|
||||
print(f"An error occurred: {e}")
|
||||
17
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
17
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,17 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="PyCompatibilityInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="ourVersions">
|
||||
<value>
|
||||
<list size="3">
|
||||
<item index="0" class="java.lang.String" itemvalue="3.14" />
|
||||
<item index="1" class="java.lang.String" itemvalue="3.7" />
|
||||
<item index="2" class="java.lang.String" itemvalue="3.8" />
|
||||
</list>
|
||||
</value>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
||||
18
.idea/smoothschedule2.iml
generated
Normal file
18
.idea/smoothschedule2.iml
generated
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module version="4">
|
||||
<component name="PyDocumentationSettings">
|
||||
<option name="format" value="PLAIN" />
|
||||
<option name="myDocStringFormat" value="Plain" />
|
||||
</component>
|
||||
<component name="TemplatesService">
|
||||
<option name="TEMPLATE_CONFIGURATION" value="Jinja2" />
|
||||
<option name="TEMPLATE_FOLDERS">
|
||||
<list>
|
||||
<option value="$MODULE_DIR$/smoothschedule/schedule/templates" />
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="TestRunnerService">
|
||||
<option name="PROJECT_TEST_RUNNER" value="py.test" />
|
||||
</component>
|
||||
</module>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
34
email_templates/README.md
Normal file
34
email_templates/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Email Template Pack - Styles & Variations
|
||||
|
||||
This directory contains a set of uniquely styled email templates. Each category offers multiple design aesthetics to suit different business brands.
|
||||
|
||||
## Categories & Styles
|
||||
|
||||
### 1. Appointment Confirmation (`/confirmation`)
|
||||
- **Modern Blue (`modern_blue.html`)**: Clean, corporate, uses `Segoe UI`, rounded corners, and a blue hero header. Ideal for medical, tech, or professional services.
|
||||
- **Classic Serif (`classic_serif.html`)**: Elegant, uses `Georgia/Times`, borders instead of shadows, warm beige background (`#faf9f6`). Perfect for law firms, salons, or luxury brands.
|
||||
- **Bold Dark (`bold_dark.html`)**: High contrast, dark mode aesthetic (`#111111` background), bold typography (`Helvetica Neue`), vibrant pink accents. Great for gyms, modern barbershops, or nightlife venues.
|
||||
|
||||
### 2. Appointment Reminder (`/reminder`)
|
||||
- **Soft & Clean (`soft_clean.html`)**: Minimalist, uses circle imagery, ample whitespace, soft pink/rose color palette. Friendly and non-intrusive.
|
||||
- **Urgent Bold (`urgent_bold.html`)**: Uses red accents and bold `Arial Black` fonts to convey urgency. "Action Required" styling to reduce no-shows.
|
||||
- **Personal Note (`personal_note.html`)**: A simple, letter-style layout using serif fonts on a cream background. Feels like a handwritten note from the owner.
|
||||
|
||||
### 3. Marketing / Welcome (`/marketing`)
|
||||
- **Vibrant (`welcome_vibrant.html`)**: Uses a gradient top bar, bold typography, and image collages. High energy, designed to excite new customers.
|
||||
- **Minimalist Promo (`minimalist_promo.html`)**: Monochromatic, fashion-forward design with a large hero image and a prominent discount code box. High impact.
|
||||
- **Newsletter Grid (`newsletter_grid.html`)**: A classic multi-column layout for monthly updates, featuring a main story and secondary news items. Clean and readable.
|
||||
|
||||
### 4. Reports (`/reports`)
|
||||
- **Monthly Data (`monthly_data.html`)**: A utility-focused layout with a data grid, performance chart placeholder, and clean typography. Designed for clarity and readability.
|
||||
- **Weekly Snapshot (`weekly_cards.html`)**: A dashboard-style dark mode email with card-based statistics (Revenue, Bookings, etc.) for quick scanning.
|
||||
- **Staff Leaderboard (`staff_leaderboard.html`)**: A ranked list view with avatars and performance metrics to highlight top employees. Motivating and clear.
|
||||
|
||||
## Image Assets
|
||||
Templates use `https://placehold.co` for dynamic image generation to ensure immediate previewability without requiring local asset hosting.
|
||||
- **Banners**: 600x200px
|
||||
- **Icons**: 80x80px or 120x120px
|
||||
- **Colors**: Matched to the template theme (e.g., `#4f46e5` for modern blue).
|
||||
|
||||
## Usage
|
||||
Copy the HTML code from the desired style file into your email sending service or SmoothSchedule template editor. Ensure all `{{TAGS}}` are replaced with actual data.
|
||||
72
email_templates/confirmation/bold_dark.html
Normal file
72
email_templates/confirmation/bold_dark.html
Normal file
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Appointment Confirmed - Bold</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #000000; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td align="center" style="padding: 20px;">
|
||||
<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background-color: #111111; border-radius: 24px; overflow: hidden;">
|
||||
<!-- Header Image -->
|
||||
<tr>
|
||||
<td style="position: relative;">
|
||||
<img src="https://placehold.co/600x300/db2777/ffffff?text=CONFIRMED&font=montserrat" alt="Confirmed" style="width: 100%; height: auto; display: block;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 48px 40px;">
|
||||
<h1 style="margin: 0 0 16px; color: #ffffff; font-size: 32px; font-weight: 800; letter-spacing: -0.03em;">
|
||||
Ready for you, {{CUSTOMER_NAME}}.
|
||||
</h1>
|
||||
<p style="margin: 0 0 40px; color: #a1a1aa; font-size: 18px; line-height: 1.5;">
|
||||
Your slot is locked in. We've got everything prepared for your upcoming visit.
|
||||
</p>
|
||||
|
||||
<!-- Grid Layout for Details -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td width="48%" style="background-color: #18181b; padding: 24px; border-radius: 16px; vertical-align: top;">
|
||||
<p style="margin: 0 0 8px; color: #db2777; font-size: 12px; font-weight: 700; text-transform: uppercase;">Service</p>
|
||||
<p style="margin: 0; color: #ffffff; font-size: 16px; font-weight: 600;">{{APPOINTMENT_SERVICE}}</p>
|
||||
</td>
|
||||
<td width="4%"></td>
|
||||
<td width="48%" style="background-color: #18181b; padding: 24px; border-radius: 16px; vertical-align: top;">
|
||||
<p style="margin: 0 0 8px; color: #db2777; font-size: 12px; font-weight: 700; text-transform: uppercase;">When</p>
|
||||
<p style="margin: 0; color: #ffffff; font-size: 16px; font-weight: 600;">{{APPOINTMENT_DATE}}<br><span style="color: #a1a1aa; font-weight: 400;">{{APPOINTMENT_TIME}}</span></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- QR Code Placeholder -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="margin-top: 40px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<div style="background-color: #ffffff; padding: 16px; border-radius: 12px; display: inline-block;">
|
||||
<img src="https://placehold.co/150x150/000000/ffffff?text=QR+Code&font=roboto" alt="Check-in QR" style="display: block;">
|
||||
</div>
|
||||
<p style="margin: 16px 0 0; color: #52525b; font-size: 12px;">Scan at front desk to check in</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="padding: 0 40px 40px; text-align: center;">
|
||||
<p style="margin: 0; color: #52525b; font-size: 14px;">
|
||||
{{BUSINESS_NAME}} • {{BUSINESS_PHONE}}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
82
email_templates/confirmation/classic_serif.html
Normal file
82
email_templates/confirmation/classic_serif.html
Normal file
@@ -0,0 +1,82 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Appointment Confirmed - Classic</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #faf9f6; font-family: 'Georgia', 'Times New Roman', serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 0;">
|
||||
<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background-color: #ffffff; border: 1px solid #e7e5e4; border-top: 4px solid #1c1917;">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="padding: 40px 40px 20px; text-align: center;">
|
||||
<img src="https://placehold.co/120x60/1c1917/ffffff?text=LOGO&font=playfair-display" alt="Logo" style="height: 60px; width: auto;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 20px 60px 40px;">
|
||||
<h1 style="margin: 0 0 24px; color: #1c1917; font-size: 28px; font-weight: 400; text-align: center; letter-spacing: -0.02em;">
|
||||
Appointment Confirmation
|
||||
</h1>
|
||||
<p style="margin: 0 0 24px; color: #44403c; font-size: 16px; line-height: 1.8; text-align: center;">
|
||||
Dear {{CUSTOMER_NAME}},<br><br>
|
||||
We are pleased to confirm your appointment with {{BUSINESS_NAME}}. Please review the details below.
|
||||
</p>
|
||||
|
||||
<!-- Divider -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td style="border-bottom: 1px solid #e7e5e4; padding-bottom: 20px; margin-bottom: 20px;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Details -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="margin-top: 20px;">
|
||||
<tr>
|
||||
<td style="padding: 12px 0; color: #78716c; font-family: 'Arial', sans-serif; font-size: 12px; text-transform: uppercase; letter-spacing: 0.1em;">Service</td>
|
||||
<td style="padding: 12px 0; text-align: right; color: #1c1917; font-size: 16px;">{{APPOINTMENT_SERVICE}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="border-bottom: 1px solid #f5f5f4; padding: 0;" colspan="2"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px 0; color: #78716c; font-family: 'Arial', sans-serif; font-size: 12px; text-transform: uppercase; letter-spacing: 0.1em;">Date</td>
|
||||
<td style="padding: 12px 0; text-align: right; color: #1c1917; font-size: 16px;">{{APPOINTMENT_DATE}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="border-bottom: 1px solid #f5f5f4; padding: 0;" colspan="2"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px 0; color: #78716c; font-family: 'Arial', sans-serif; font-size: 12px; text-transform: uppercase; letter-spacing: 0.1em;">Time</td>
|
||||
<td style="padding: 12px 0; text-align: right; color: #1c1917; font-size: 16px;">{{APPOINTMENT_TIME}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Map / Location Image Placeholder -->
|
||||
<div style="margin-top: 30px; border: 1px solid #e7e5e4; padding: 4px;">
|
||||
<img src="https://placehold.co/500x150/f5f5f4/a8a29e?text=Location+Map&font=lora" alt="Location" style="width: 100%; height: auto; display: block;">
|
||||
</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color: #1c1917; padding: 30px; text-align: center;">
|
||||
<p style="margin: 0; color: #d6d3d1; font-family: 'Arial', sans-serif; font-size: 13px; line-height: 1.6;">
|
||||
{{BUSINESS_NAME}}<br>
|
||||
{{BUSINESS_PHONE}} | {{BUSINESS_EMAIL}}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
93
email_templates/confirmation/modern_blue.html
Normal file
93
email_templates/confirmation/modern_blue.html
Normal file
@@ -0,0 +1,93 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Appointment Confirmed - Modern</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f3f4f6; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 0;">
|
||||
<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background-color: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);">
|
||||
<!-- Hero Image Section -->
|
||||
<tr>
|
||||
<td style="background-color: #4f46e5; text-align: center;">
|
||||
<!-- Using a placeholder for the hero image -->
|
||||
<img src="https://placehold.co/600x200/4f46e5/ffffff?text=Appointment+Confirmed&font=roboto" alt="Appointment Confirmed" style="width: 100%; height: auto; display: block;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content Section -->
|
||||
<tr>
|
||||
<td style="padding: 40px;">
|
||||
<h1 style="margin: 0 0 20px; color: #111827; font-size: 24px; font-weight: 700; line-height: 1.2;">
|
||||
You're All Set, {{CUSTOMER_NAME}}!
|
||||
</h1>
|
||||
<p style="margin: 0 0 24px; color: #4b5563; font-size: 16px; line-height: 1.6;">
|
||||
We are excited to see you at <strong>{{BUSINESS_NAME}}</strong>. Your appointment has been confirmed for the following time:
|
||||
</p>
|
||||
|
||||
<!-- Appointment Card -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background-color: #f9fafb; border-radius: 12px; border: 1px solid #e5e7eb;">
|
||||
<tr>
|
||||
<td style="padding: 24px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td style="padding-bottom: 16px; border-bottom: 1px solid #e5e7eb;">
|
||||
<p style="margin: 0; color: #6b7280; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600;">Service</p>
|
||||
<p style="margin: 4px 0 0; color: #111827; font-size: 18px; font-weight: 600;">{{APPOINTMENT_SERVICE}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding-top: 16px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<p style="margin: 0; color: #6b7280; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600;">Date</p>
|
||||
<p style="margin: 4px 0 0; color: #111827; font-size: 16px;">{{APPOINTMENT_DATE}}</p>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<p style="margin: 0; color: #6b7280; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600;">Time</p>
|
||||
<p style="margin: 4px 0 0; color: #111827; font-size: 16px;">{{APPOINTMENT_TIME}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="margin-top: 32px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="#" style="display: inline-block; background-color: #4f46e5; color: #ffffff; font-size: 16px; font-weight: 600; text-decoration: none; padding: 12px 32px; border-radius: 8px; transition: background-color 0.2s;">
|
||||
Manage Appointment
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color: #1f2937; padding: 32px; text-align: center;">
|
||||
<img src="https://placehold.co/40x40/ffffff/1f2937?text=L" alt="Logo" style="width: 40px; height: 40px; border-radius: 8px; margin-bottom: 16px;">
|
||||
<p style="margin: 0 0 8px; color: #9ca3af; font-size: 14px;">
|
||||
{{BUSINESS_NAME}}
|
||||
</p>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 12px;">
|
||||
{{BUSINESS_PHONE}} • {{BUSINESS_EMAIL}}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
57
email_templates/marketing/minimalist_promo.html
Normal file
57
email_templates/marketing/minimalist_promo.html
Normal file
@@ -0,0 +1,57 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Marketing - Minimalist Promo</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #ffffff; font-family: 'Courier New', Courier, monospace;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 20px;">
|
||||
<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="border: 2px solid #000000;">
|
||||
<!-- Big Hero Image -->
|
||||
<tr>
|
||||
<td>
|
||||
<img src="https://placehold.co/600x400/000000/ffffff?text=FLASH+SALE&font=monoton" alt="Flash Sale" style="width: 100%; height: auto; display: block;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 40px; text-align: center;">
|
||||
<h1 style="margin: 0 0 20px; color: #000000; font-size: 36px; font-weight: 700; text-transform: uppercase; letter-spacing: 2px;">
|
||||
limited time only
|
||||
</h1>
|
||||
<p style="margin: 0 0 40px; color: #333333; font-size: 16px; line-height: 1.6; font-family: 'Helvetica', sans-serif;">
|
||||
Treat yourself to something special. For the next 48 hours, get exclusive access to our VIP booking slots and a special discount.
|
||||
</p>
|
||||
|
||||
<!-- Coupon Code Box -->
|
||||
<div style="border: 2px dashed #000000; padding: 20px; display: inline-block; margin-bottom: 40px;">
|
||||
<p style="margin: 0 0 5px; font-size: 12px; color: #666; font-family: sans-serif;">USE CODE:</p>
|
||||
<span style="font-size: 32px; font-weight: 900; color: #d946ef;">VIP20</span>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<a href="#" style="background-color: #000000; color: #ffffff; padding: 18px 40px; text-decoration: none; font-weight: bold; font-size: 14px; text-transform: uppercase; display: inline-block;">
|
||||
Claim Offer
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="border-top: 2px solid #000000; padding: 20px; text-align: center;">
|
||||
<p style="margin: 0; font-size: 12px; color: #000000;">
|
||||
{{BUSINESS_NAME}}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
88
email_templates/marketing/newsletter_grid.html
Normal file
88
email_templates/marketing/newsletter_grid.html
Normal file
@@ -0,0 +1,88 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Marketing - Newsletter Grid</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #e5e7eb; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<!-- Top Bar -->
|
||||
<tr>
|
||||
<td style="background-color: #374151; padding: 10px 0; text-align: center; color: #d1d5db; font-size: 12px;">
|
||||
View this email in your browser
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 20px;">
|
||||
<!-- Logo Header -->
|
||||
<table width="640" cellpadding="0" cellspacing="0" role="presentation" style="margin-bottom: 20px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<h1 style="margin: 0; color: #1f2937; font-size: 28px; font-weight: 300; letter-spacing: 1px;">{{BUSINESS_NAME}} <span style="color: #3b82f6; font-weight: 700;">MONTHLY</span></h1>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Main Feature -->
|
||||
<table width="640" cellpadding="0" cellspacing="0" role="presentation" style="background-color: #ffffff; margin-bottom: 20px;">
|
||||
<tr>
|
||||
<td>
|
||||
<img src="https://placehold.co/640x320/3b82f6/ffffff?text=New+Service+Launch&font=roboto" alt="Feature" style="width: 100%; height: auto; display: block;">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 30px;">
|
||||
<h2 style="margin: 0 0 10px; color: #111827; font-size: 24px;">Introducing Our New Premium Service</h2>
|
||||
<p style="margin: 0 0 20px; color: #4b5563; line-height: 1.6;">
|
||||
We've been listening to your feedback and are excited to announce a brand new way to experience {{BUSINESS_NAME}}. Our new premium tier offers extended hours and dedicated support.
|
||||
</p>
|
||||
<a href="#" style="color: #3b82f6; text-decoration: none; font-weight: 600;">Read more →</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Two Column Grid -->
|
||||
<table width="640" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td width="310" valign="top" style="background-color: #ffffff; padding-bottom: 20px;">
|
||||
<img src="https://placehold.co/310x200/10b981/ffffff?text=Staff+Spotlight" alt="Staff" style="width: 100%; height: auto; display: block;">
|
||||
<div style="padding: 20px;">
|
||||
<h3 style="margin: 0 0 10px; color: #111827; font-size: 18px;">Employee of the Month</h3>
|
||||
<p style="margin: 0 0 15px; color: #6b7280; font-size: 14px; line-height: 1.5;">
|
||||
Meet Sarah, our lead specialist who has gone above and beyond this month.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td width="20"><!-- Gutter --></td>
|
||||
<td width="310" valign="top" style="background-color: #ffffff; padding-bottom: 20px;">
|
||||
<img src="https://placehold.co/310x200/f59e0b/ffffff?text=Community" alt="Community" style="width: 100%; height: auto; display: block;">
|
||||
<div style="padding: 20px;">
|
||||
<h3 style="margin: 0 0 10px; color: #111827; font-size: 18px;">Community Events</h3>
|
||||
<p style="margin: 0 0 15px; color: #6b7280; font-size: 14px; line-height: 1.5;">
|
||||
Join us this weekend for our local charity drive.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Footer -->
|
||||
<table width="640" cellpadding="0" cellspacing="0" role="presentation" style="margin-top: 40px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<p style="margin: 0 0 10px; color: #9ca3af; font-size: 12px;">
|
||||
© {{TODAY}} {{BUSINESS_NAME}}. All rights reserved.
|
||||
</p>
|
||||
<p style="margin: 0; color: #9ca3af; font-size: 12px;">
|
||||
{{BUSINESS_ADDRESS}}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
77
email_templates/marketing/welcome_vibrant.html
Normal file
77
email_templates/marketing/welcome_vibrant.html
Normal file
@@ -0,0 +1,77 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Marketing - Vibrant</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #ffffff; font-family: 'Verdana', sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<!-- Colorful Top Bar -->
|
||||
<tr>
|
||||
<td height="8" style="background: linear-gradient(90deg, #8b5cf6 0%, #ec4899 100%);"></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 20px;">
|
||||
<table width="600" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<!-- Logo -->
|
||||
<tr>
|
||||
<td align="center" style="padding-bottom: 40px;">
|
||||
<img src="https://placehold.co/80x80/8b5cf6/ffffff?text=S&font=montserrat" alt="SmoothSchedule" style="display: block; border-radius: 50%;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Hero -->
|
||||
<tr>
|
||||
<td align="center">
|
||||
<h1 style="margin: 0 0 20px; color: #111827; font-size: 42px; font-weight: 900; letter-spacing: -1px;">
|
||||
Welcome to the family.
|
||||
</h1>
|
||||
<p style="margin: 0 0 40px; color: #6b7280; font-size: 18px; max-width: 480px;">
|
||||
Thanks for joining <strong>{{BUSINESS_NAME}}</strong>! We're thrilled to have you on board.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Hero Image collage -->
|
||||
<tr>
|
||||
<td style="padding-bottom: 40px;">
|
||||
<img src="https://placehold.co/600x300/f3f4f6/d1d5db?text=Lifestyle+Image+Collage" alt="Lifestyle" style="width: 100%; height: auto; border-radius: 12px; display: block;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Features Grid -->
|
||||
<tr>
|
||||
<td>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td width="33%" valign="top" style="padding-right: 10px;">
|
||||
<h3 style="margin: 0 0 8px; color: #111827; font-size: 16px;">Expert Staff</h3>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 14px; line-height: 1.5;">Top-tier professionals ready to serve.</p>
|
||||
</td>
|
||||
<td width="33%" valign="top" style="padding: 0 10px;">
|
||||
<h3 style="margin: 0 0 8px; color: #111827; font-size: 16px;">Easy Booking</h3>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 14px; line-height: 1.5;">Schedule anytime, anywhere.</p>
|
||||
</td>
|
||||
<td width="33%" valign="top" style="padding-left: 10px;">
|
||||
<h3 style="margin: 0 0 8px; color: #111827; font-size: 16px;">Best Value</h3>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 14px; line-height: 1.5;">Premium service at great rates.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- CTA -->
|
||||
<tr>
|
||||
<td align="center" style="padding-top: 60px;">
|
||||
<a href="#" style="background-color: #111827; color: #ffffff; padding: 16px 40px; border-radius: 50px; text-decoration: none; font-weight: 600; font-size: 16px;">Book Your First Visit</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
38
email_templates/reminder/personal_note.html
Normal file
38
email_templates/reminder/personal_note.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Reminder - Personal Note</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #fdfbf7; font-family: 'Georgia', serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td align="center" style="padding: 60px 20px;">
|
||||
<table width="500" cellpadding="0" cellspacing="0" role="presentation" style="background-color: #ffffff; border: 1px solid #e7e5e4; padding: 60px 40px; box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05);">
|
||||
<tr>
|
||||
<td>
|
||||
<p style="margin: 0 0 20px; font-size: 16px; color: #44403c; line-height: 1.6;">
|
||||
Dear {{CUSTOMER_NAME}},
|
||||
</p>
|
||||
<p style="margin: 0 0 20px; font-size: 16px; color: #44403c; line-height: 1.6;">
|
||||
I'm writing to confirm that we're still on for your <strong>{{APPOINTMENT_SERVICE}}</strong> tomorrow, <strong>{{APPOINTMENT_DATE}}</strong> at <strong>{{APPOINTMENT_TIME}}</strong>.
|
||||
</p>
|
||||
<p style="margin: 0 0 40px; font-size: 16px; color: #44403c; line-height: 1.6;">
|
||||
Looking forward to our session.
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 16px; color: #44403c; line-height: 1.6;">
|
||||
Warmly,<br><br>
|
||||
<span style="font-style: italic; font-size: 18px; color: #1c1917;">{{BUSINESS_NAME}}</span>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin-top: 20px; font-family: sans-serif; font-size: 12px; color: #a8a29e; text-align: center;">
|
||||
<a href="#" style="color: #a8a29e; text-decoration: underline;">Reschedule</a> or <a href="#" style="color: #a8a29e; text-decoration: underline;">Cancel</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
56
email_templates/reminder/soft_clean.html
Normal file
56
email_templates/reminder/soft_clean.html
Normal file
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Reminder - Soft & Clean</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #fff1f2; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td align="center" style="padding: 60px 0;">
|
||||
<table width="500" cellpadding="0" cellspacing="0" role="presentation" style="background-color: #ffffff; border-radius: 20px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.05), 0 10px 10px -5px rgba(0, 0, 0, 0.01);">
|
||||
<!-- Circle Image Top -->
|
||||
<tr>
|
||||
<td align="center" style="padding-top: 40px;">
|
||||
<img src="https://placehold.co/120x120/fb7185/ffffff?text=Soon&font=playfair-display" alt="Soon" style="width: 120px; height: 120px; border-radius: 50%; object-fit: cover; display: block;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 30px 50px 50px; text-align: center;">
|
||||
<h1 style="margin: 0 0 16px; color: #881337; font-size: 24px; font-weight: 600;">
|
||||
Just a Friendly Reminder
|
||||
</h1>
|
||||
<p style="margin: 0 0 32px; color: #4c0519; font-size: 16px; line-height: 1.6;">
|
||||
Hi {{CUSTOMER_NAME}}, your appointment with {{BUSINESS_NAME}} is coming up soon!
|
||||
</p>
|
||||
|
||||
<div style="background-color: #fff1f2; border-radius: 12px; padding: 20px; display: inline-block; width: 100%; box-sizing: border-box;">
|
||||
<p style="margin: 0 0 8px; color: #be123c; font-size: 18px; font-weight: 700;">{{APPOINTMENT_DATE}}</p>
|
||||
<p style="margin: 0; color: #9f1239; font-size: 24px; font-weight: 300;">{{APPOINTMENT_TIME}}</p>
|
||||
</div>
|
||||
|
||||
<p style="margin: 32px 0 0; color: #9ca3af; font-size: 13px;">
|
||||
Need to make changes? <a href="#" style="color: #fb7185; text-decoration: underline;">Reschedule here</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Simple Footer -->
|
||||
<table width="500" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td style="padding-top: 20px; text-align: center;">
|
||||
<p style="margin: 0; color: #f43f5e; font-size: 12px;">
|
||||
{{BUSINESS_NAME}}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
47
email_templates/reminder/urgent_bold.html
Normal file
47
email_templates/reminder/urgent_bold.html
Normal file
@@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Reminder - Urgent</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #fef2f2; font-family: 'Arial Black', 'Arial Bold', sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td align="center" style="padding: 20px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 600px; border-left: 8px solid #ef4444; background-color: #ffffff;">
|
||||
<tr>
|
||||
<td style="padding: 40px;">
|
||||
<p style="margin: 0 0 10px; color: #ef4444; font-size: 14px; letter-spacing: 1px; text-transform: uppercase;">Action Required</p>
|
||||
<h1 style="margin: 0 0 30px; color: #111827; font-size: 36px; line-height: 1;">
|
||||
Don't Forget<br>Your Visit.
|
||||
</h1>
|
||||
|
||||
<img src="https://placehold.co/520x250/ef4444/ffffff?text=TOMORROW&font=oswald" alt="Tomorrow" style="width: 100%; height: auto; display: block; margin-bottom: 30px;">
|
||||
|
||||
<p style="margin: 0 0 20px; color: #374151; font-family: 'Arial', sans-serif; font-size: 16px; line-height: 1.6;">
|
||||
<strong>{{CUSTOMER_NAME}}</strong>, we're holding your spot for <strong>{{APPOINTMENT_SERVICE}}</strong>.
|
||||
</p>
|
||||
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background-color: #111827; color: #ffffff; padding: 20px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<p style="margin: 0; font-size: 20px;">{{APPOINTMENT_DATE}} @ {{APPOINTMENT_TIME}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color: #f3f4f6; padding: 20px; text-align: center;">
|
||||
<p style="margin: 0; color: #6b7280; font-family: 'Arial', sans-serif; font-size: 12px;">
|
||||
{{BUSINESS_NAME}} - {{BUSINESS_PHONE}}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
74
email_templates/reports/monthly_data.html
Normal file
74
email_templates/reports/monthly_data.html
Normal file
@@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Monthly Report - Data Heavy</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: 'Roboto', 'Helvetica', sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td align="center" style="padding: 20px;">
|
||||
<!-- Header -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 800px; margin-bottom: 20px;">
|
||||
<tr>
|
||||
<td style="padding: 20px 0;">
|
||||
<h1 style="margin: 0; color: #0f172a; font-size: 20px; font-weight: 500;">
|
||||
<span style="color: #3b82f6; font-weight: 700;">Smooth</span>Schedule Report
|
||||
</h1>
|
||||
</td>
|
||||
<td align="right">
|
||||
<p style="margin: 0; color: #64748b; font-size: 14px;">{{TODAY}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Main Card -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 800px; background-color: #ffffff; border: 1px solid #e2e8f0; border-radius: 8px;">
|
||||
<tr>
|
||||
<td style="padding: 30px;">
|
||||
<h2 style="margin: 0 0 20px; color: #0f172a; font-size: 18px;">Performance Summary</h2>
|
||||
|
||||
<!-- Chart Placeholder -->
|
||||
<img src="https://placehold.co/740x200/f1f5f9/94a3b8?text=Interactive+Revenue+Chart&font=roboto" alt="Chart" style="width: 100%; height: auto; border-radius: 4px; margin-bottom: 30px;">
|
||||
|
||||
<!-- Data Grid -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse: collapse;">
|
||||
<tr style="background-color: #f8fafc;">
|
||||
<th style="text-align: left; padding: 12px; border-bottom: 2px solid #e2e8f0; color: #475569; font-size: 12px; text-transform: uppercase;">Metric</th>
|
||||
<th style="text-align: right; padding: 12px; border-bottom: 2px solid #e2e8f0; color: #475569; font-size: 12px; text-transform: uppercase;">Value</th>
|
||||
<th style="text-align: right; padding: 12px; border-bottom: 2px solid #e2e8f0; color: #475569; font-size: 12px; text-transform: uppercase;">Change</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #e2e8f0; color: #1e293b;">Total Revenue</td>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #e2e8f0; text-align: right; color: #1e293b; font-weight: 600;">$12,450</td>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #e2e8f0; text-align: right; color: #10b981;">+12% ▲</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #e2e8f0; color: #1e293b;">Appointments</td>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #e2e8f0; text-align: right; color: #1e293b; font-weight: 600;">142</td>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #e2e8f0; text-align: right; color: #10b981;">+5% ▲</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #e2e8f0; color: #1e293b;">New Customers</td>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #e2e8f0; text-align: right; color: #1e293b; font-weight: 600;">28</td>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #e2e8f0; text-align: right; color: #ef4444;">-2% ▼</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 20px 0 0; color: #64748b; font-size: 14px;">
|
||||
This report was automatically generated for <strong>{{BUSINESS_NAME}}</strong>.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color: #f1f5f9; padding: 15px 30px; text-align: center; border-top: 1px solid #e2e8f0;">
|
||||
<a href="#" style="color: #3b82f6; font-size: 14px; text-decoration: none; font-weight: 500;">View Full Report in Dashboard →</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
99
email_templates/reports/staff_leaderboard.html
Normal file
99
email_templates/reports/staff_leaderboard.html
Normal file
@@ -0,0 +1,99 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Report - Staff Leaderboard</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f3f4f6; font-family: 'Arial', sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 20px;">
|
||||
<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background-color: #ffffff; border-radius: 12px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="padding: 30px; border-bottom: 1px solid #e5e7eb;">
|
||||
<h1 style="margin: 0; color: #111827; font-size: 20px;">Staff Performance</h1>
|
||||
<p style="margin: 5px 0 0; color: #6b7280; font-size: 14px;">Top performers for {{TODAY}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- List -->
|
||||
<tr>
|
||||
<td style="padding: 0 30px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<!-- Item 1 -->
|
||||
<tr>
|
||||
<td style="padding: 20px 0; border-bottom: 1px solid #f3f4f6;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td width="50">
|
||||
<img src="https://placehold.co/40x40/10b981/ffffff?text=1" alt="Rank 1" style="border-radius: 50%; display: block;">
|
||||
</td>
|
||||
<td>
|
||||
<p style="margin: 0; font-weight: 600; color: #111827;">Sarah Johnson</p>
|
||||
<p style="margin: 2px 0 0; font-size: 12px; color: #6b7280;">32 Appointments</p>
|
||||
</td>
|
||||
<td align="right" width="100">
|
||||
<p style="margin: 0; font-weight: 700; color: #10b981;">$3,200</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Item 2 -->
|
||||
<tr>
|
||||
<td style="padding: 20px 0; border-bottom: 1px solid #f3f4f6;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td width="50">
|
||||
<img src="https://placehold.co/40x40/3b82f6/ffffff?text=2" alt="Rank 2" style="border-radius: 50%; display: block;">
|
||||
</td>
|
||||
<td>
|
||||
<p style="margin: 0; font-weight: 600; color: #111827;">Mike Chen</p>
|
||||
<p style="margin: 2px 0 0; font-size: 12px; color: #6b7280;">28 Appointments</p>
|
||||
</td>
|
||||
<td align="right" width="100">
|
||||
<p style="margin: 0; font-weight: 700; color: #111827;">$2,850</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Item 3 -->
|
||||
<tr>
|
||||
<td style="padding: 20px 0;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td width="50">
|
||||
<img src="https://placehold.co/40x40/6b7280/ffffff?text=3" alt="Rank 3" style="border-radius: 50%; display: block;">
|
||||
</td>
|
||||
<td>
|
||||
<p style="margin: 0; font-weight: 600; color: #111827;">Jessica Williams</p>
|
||||
<p style="margin: 2px 0 0; font-size: 12px; color: #6b7280;">25 Appointments</p>
|
||||
</td>
|
||||
<td align="right" width="100">
|
||||
<p style="margin: 0; font-weight: 700; color: #111827;">$2,100</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color: #f9fafb; padding: 20px 30px; border-radius: 0 0 12px 12px;">
|
||||
<p style="margin: 0; font-size: 13px; color: #6b7280; text-align: center;">
|
||||
Great work team! 🚀
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
73
email_templates/reports/weekly_cards.html
Normal file
73
email_templates/reports/weekly_cards.html
Normal file
@@ -0,0 +1,73 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Report - Weekly Snapshot</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #1e293b; font-family: 'Roboto', sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 20px;">
|
||||
<table width="600" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="padding-bottom: 30px;">
|
||||
<h1 style="margin: 0; color: #ffffff; font-size: 24px;">Weekly Snapshot</h1>
|
||||
<p style="margin: 5px 0 0; color: #94a3b8;">Week of {{TODAY}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<tr>
|
||||
<td>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<!-- Card 1 -->
|
||||
<td width="290" style="background-color: #334155; border-radius: 8px; padding: 25px; vertical-align: top;">
|
||||
<img src="https://placehold.co/40x40/3b82f6/ffffff?text=$" alt="Revenue" style="width: 40px; height: 40px; border-radius: 8px; margin-bottom: 15px;">
|
||||
<p style="margin: 0 0 5px; color: #94a3b8; font-size: 13px; text-transform: uppercase;">Revenue</p>
|
||||
<p style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700;">$4,250</p>
|
||||
<p style="margin: 5px 0 0; color: #4ade80; font-size: 12px;">↑ 15% vs last week</p>
|
||||
</td>
|
||||
<td width="20"></td>
|
||||
<!-- Card 2 -->
|
||||
<td width="290" style="background-color: #334155; border-radius: 8px; padding: 25px; vertical-align: top;">
|
||||
<img src="https://placehold.co/40x40/8b5cf6/ffffff?text=#" alt="Bookings" style="width: 40px; height: 40px; border-radius: 8px; margin-bottom: 15px;">
|
||||
<p style="margin: 0 0 5px; color: #94a3b8; font-size: 13px; text-transform: uppercase;">Bookings</p>
|
||||
<p style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700;">84</p>
|
||||
<p style="margin: 5px 0 0; color: #94a3b8; font-size: 12px;">→ Stable</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td height="20"></td></tr>
|
||||
<tr>
|
||||
<!-- Card 3 -->
|
||||
<td width="290" style="background-color: #334155; border-radius: 8px; padding: 25px; vertical-align: top;">
|
||||
<img src="https://placehold.co/40x40/f59e0b/ffffff?text=★" alt="Rating" style="width: 40px; height: 40px; border-radius: 8px; margin-bottom: 15px;">
|
||||
<p style="margin: 0 0 5px; color: #94a3b8; font-size: 13px; text-transform: uppercase;">Avg Rating</p>
|
||||
<p style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700;">4.9</p>
|
||||
</td>
|
||||
<td width="20"></td>
|
||||
<!-- Card 4 -->
|
||||
<td width="290" style="background-color: #334155; border-radius: 8px; padding: 25px; vertical-align: top;">
|
||||
<img src="https://placehold.co/40x40/ef4444/ffffff?text=!" alt="Cancellations" style="width: 40px; height: 40px; border-radius: 8px; margin-bottom: 15px;">
|
||||
<p style="margin: 0 0 5px; color: #94a3b8; font-size: 13px; text-transform: uppercase;">Cancellations</p>
|
||||
<p style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700;">3</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="padding-top: 40px; text-align: center;">
|
||||
<a href="#" style="color: #3b82f6; text-decoration: none; font-size: 14px;">View detailed analytics</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,2 +1,3 @@
|
||||
VITE_DEV_MODE=true
|
||||
VITE_API_URL=http://api.lvh.me:8000
|
||||
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51SYttT4pb5kWPtNt8n52NRQLyBGbFQ52tnG1O5o11V06m3TPUyIzf6AHOpFNQErBj4m7pOwM6VzltePrdL16IFn0004YqWhRpA
|
||||
|
||||
201
frontend/NAVIGATION_REDESIGN_PLAN.md
Normal file
201
frontend/NAVIGATION_REDESIGN_PLAN.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# Navigation Redesign Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Redesigning both the main sidebar and settings page navigation to be more organized and scalable.
|
||||
|
||||
## Current Issues
|
||||
|
||||
### Main Sidebar
|
||||
- 15+ items in a flat list with no grouping
|
||||
- Dropdowns (Plugins, Help) hide important items
|
||||
- No visual hierarchy or section headers
|
||||
- Settings isolated at bottom
|
||||
|
||||
### Settings Page
|
||||
- 7 horizontal tabs getting crowded
|
||||
- Not scalable for new settings
|
||||
- No logical grouping
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Refactor Main Sidebar (COMPLETED)
|
||||
|
||||
### New Structure with Grouped Sections
|
||||
|
||||
```
|
||||
[Logo] Business Name
|
||||
subdomain.smoothschedule
|
||||
|
||||
○ Dashboard
|
||||
○ Scheduler
|
||||
○ Tasks
|
||||
|
||||
MANAGE
|
||||
○ Customers
|
||||
○ Services
|
||||
○ Resources
|
||||
○ Staff
|
||||
|
||||
COMMUNICATE
|
||||
○ Messages
|
||||
○ Tickets
|
||||
|
||||
MONEY
|
||||
○ Payments
|
||||
|
||||
EXTEND
|
||||
○ Plugins
|
||||
○ Email Templates
|
||||
|
||||
──────────
|
||||
○ Settings
|
||||
○ Help & Docs
|
||||
──────────
|
||||
[User] Sign Out
|
||||
```
|
||||
|
||||
### Files Created/Modified
|
||||
- `src/components/navigation/SidebarComponents.tsx` - Shared components (DONE)
|
||||
- `src/components/Sidebar.tsx` - Refactor to use new components (TODO)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Settings Sidebar Layout
|
||||
|
||||
### New Settings Structure
|
||||
|
||||
Settings becomes a sub-application with its own sidebar:
|
||||
|
||||
```
|
||||
/settings
|
||||
├── /general - Business name, timezone, etc.
|
||||
├── /branding - Logo, colors, display mode
|
||||
├── /resource-types - Resource type management
|
||||
├── /domains - Custom domains
|
||||
├── /api-tokens - API access tokens
|
||||
├── /authentication - OAuth, social login
|
||||
├── /email - Email addresses for tickets
|
||||
├── /communication - SMS & calling credits
|
||||
├── /billing - Subscription, credits, invoices (future)
|
||||
```
|
||||
|
||||
### Settings Sidebar Layout
|
||||
|
||||
```
|
||||
┌──────────────────┬──────────────────────────────────────────┐
|
||||
│ ← Back to App │ │
|
||||
│ │ [Page Title] │
|
||||
│ BUSINESS │ [Page Description] │
|
||||
│ ○ General │ │
|
||||
│ ○ Branding │ [Content Area] │
|
||||
│ ○ Resource Types │ │
|
||||
│ │ │
|
||||
│ INTEGRATIONS │ │
|
||||
│ ○ Domains │ │
|
||||
│ ○ API & Webhooks │ │
|
||||
│ │ │
|
||||
│ ACCESS │ │
|
||||
│ ○ Authentication │ │
|
||||
│ │ │
|
||||
│ COMMUNICATION │ │
|
||||
│ ○ Email Setup │ │
|
||||
│ ○ SMS & Calling │ │
|
||||
│ │ │
|
||||
│ BILLING │ │
|
||||
│ ○ Credits │ │
|
||||
│ │ │
|
||||
└──────────────────┴──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Files to Create
|
||||
1. `src/components/navigation/SettingsSidebar.tsx` - Settings-specific sidebar
|
||||
2. `src/layouts/SettingsLayout.tsx` - Layout wrapper with sidebar + content
|
||||
3. Split `src/pages/Settings.tsx` into:
|
||||
- `src/pages/settings/GeneralSettings.tsx`
|
||||
- `src/pages/settings/BrandingSettings.tsx`
|
||||
- `src/pages/settings/ResourceTypesSettings.tsx`
|
||||
- `src/pages/settings/DomainsSettings.tsx`
|
||||
- `src/pages/settings/ApiTokensSettings.tsx`
|
||||
- `src/pages/settings/AuthenticationSettings.tsx`
|
||||
- `src/pages/settings/EmailSettings.tsx`
|
||||
- `src/pages/settings/CommunicationSettings.tsx`
|
||||
|
||||
### Route Updates (in App.tsx or routes file)
|
||||
|
||||
```tsx
|
||||
<Route path="/settings" element={<SettingsLayout />}>
|
||||
<Route index element={<Navigate to="/settings/general" replace />} />
|
||||
<Route path="general" element={<GeneralSettings />} />
|
||||
<Route path="branding" element={<BrandingSettings />} />
|
||||
<Route path="resource-types" element={<ResourceTypesSettings />} />
|
||||
<Route path="domains" element={<DomainsSettings />} />
|
||||
<Route path="api-tokens" element={<ApiTokensSettings />} />
|
||||
<Route path="authentication" element={<AuthenticationSettings />} />
|
||||
<Route path="email" element={<EmailSettings />} />
|
||||
<Route path="communication" element={<CommunicationSettings />} />
|
||||
</Route>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. ✅ Create shared sidebar components (`SidebarComponents.tsx`)
|
||||
2. ✅ Refactor main `Sidebar.tsx` to use grouped sections
|
||||
3. ✅ Create `SettingsLayout.tsx` (includes sidebar)
|
||||
4. ⏳ Split Settings.tsx into sub-pages
|
||||
5. ⬜ Update routes in App.tsx
|
||||
6. ⬜ Test all navigation flows
|
||||
|
||||
## Files Created So Far
|
||||
|
||||
- `src/components/navigation/SidebarComponents.tsx` - Shared nav components
|
||||
- `src/components/Sidebar.tsx` - Refactored with grouped sections
|
||||
- `src/layouts/SettingsLayout.tsx` - Settings page wrapper with sidebar
|
||||
- `src/pages/settings/` - Directory for settings sub-pages (in progress)
|
||||
|
||||
---
|
||||
|
||||
## Component APIs
|
||||
|
||||
### SidebarSection
|
||||
```tsx
|
||||
<SidebarSection title="MANAGE" isCollapsed={isCollapsed}>
|
||||
<SidebarItem to="/customers" icon={Users} label="Customers" />
|
||||
</SidebarSection>
|
||||
```
|
||||
|
||||
### SidebarItem
|
||||
```tsx
|
||||
<SidebarItem
|
||||
to="/settings"
|
||||
icon={Settings}
|
||||
label="Settings"
|
||||
isCollapsed={isCollapsed}
|
||||
exact={true}
|
||||
badge="3" // optional badge
|
||||
/>
|
||||
```
|
||||
|
||||
### SettingsSidebarSection / SettingsSidebarItem
|
||||
```tsx
|
||||
<SettingsSidebarSection title="BUSINESS">
|
||||
<SettingsSidebarItem
|
||||
to="/settings/general"
|
||||
icon={Building2}
|
||||
label="General"
|
||||
description="Business name, timezone"
|
||||
/>
|
||||
</SettingsSidebarSection>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Main sidebar uses white/transparent colors (gradient background)
|
||||
- Settings sidebar uses gray/brand colors (white background)
|
||||
- Both support collapsed state on main sidebar
|
||||
- Settings sidebar is always expanded (no collapse)
|
||||
- Mobile: Main sidebar becomes drawer, Settings sidebar becomes sheet/drawer
|
||||
25
frontend/package-lock.json
generated
25
frontend/package-lock.json
generated
@@ -12,6 +12,8 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@stripe/connect-js": "^3.3.31",
|
||||
"@stripe/react-connect-js": "^3.3.31",
|
||||
"@stripe/react-stripe-js": "^5.4.1",
|
||||
"@stripe/stripe-js": "^8.5.3",
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"axios": "^1.13.2",
|
||||
@@ -1474,6 +1476,29 @@
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@stripe/react-stripe-js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.4.1.tgz",
|
||||
"integrity": "sha512-ipeYcAHa4EPmjwfv0lFE+YDVkOQ0TMKkFWamW+BqmnSkEln/hO8rmxGPPWcd9WjqABx6Ro8Xg4pAS7evCcR9cw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@stripe/stripe-js": ">=8.0.0 <9.0.0",
|
||||
"react": ">=16.8.0 <20.0.0",
|
||||
"react-dom": ">=16.8.0 <20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@stripe/stripe-js": {
|
||||
"version": "8.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.5.3.tgz",
|
||||
"integrity": "sha512-UM0GHAxlTN7v0lCK2P6t0VOlvBIdApIQxhnM3yZ2kupQ4PpSrLsK/n/NyYKtw2NJGMaNRRD1IicWS7fSL2sFtA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz",
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@stripe/connect-js": "^3.3.31",
|
||||
"@stripe/react-connect-js": "^3.3.31",
|
||||
"@stripe/react-stripe-js": "^5.4.1",
|
||||
"@stripe/stripe-js": "^8.5.3",
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"axios": "^1.13.2",
|
||||
|
||||
@@ -72,6 +72,19 @@ const PluginMarketplace = React.lazy(() => import('./pages/PluginMarketplace'));
|
||||
const MyPlugins = React.lazy(() => import('./pages/MyPlugins')); // Import My Plugins page
|
||||
const Tasks = React.lazy(() => import('./pages/Tasks')); // Import Tasks page for scheduled plugin executions
|
||||
const EmailTemplates = React.lazy(() => import('./pages/EmailTemplates')); // Import Email Templates page
|
||||
|
||||
// Settings pages
|
||||
const SettingsLayout = React.lazy(() => import('./layouts/SettingsLayout'));
|
||||
const GeneralSettings = React.lazy(() => import('./pages/settings/GeneralSettings'));
|
||||
const BrandingSettings = React.lazy(() => import('./pages/settings/BrandingSettings'));
|
||||
const ResourceTypesSettings = React.lazy(() => import('./pages/settings/ResourceTypesSettings'));
|
||||
const DomainsSettings = React.lazy(() => import('./pages/settings/DomainsSettings'));
|
||||
const ApiSettings = React.lazy(() => import('./pages/settings/ApiSettings'));
|
||||
const AuthenticationSettings = React.lazy(() => import('./pages/settings/AuthenticationSettings'));
|
||||
const EmailSettings = React.lazy(() => import('./pages/settings/EmailSettings'));
|
||||
const CommunicationSettings = React.lazy(() => import('./pages/settings/CommunicationSettings'));
|
||||
const BillingSettings = React.lazy(() => import('./pages/settings/BillingSettings'));
|
||||
|
||||
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -537,9 +550,10 @@ const AppContent: React.FC = () => {
|
||||
<Route path="/trial-expired" element={<TrialExpired />} />
|
||||
<Route path="/upgrade" element={<Upgrade />} />
|
||||
<Route path="/profile" element={<ProfileSettings />} />
|
||||
{/* Trial-expired users can access billing settings to upgrade */}
|
||||
<Route
|
||||
path="/settings"
|
||||
element={hasAccess(['owner']) ? <Settings /> : <Navigate to="/trial-expired" />}
|
||||
path="/settings/*"
|
||||
element={hasAccess(['owner']) ? <Navigate to="/upgrade" /> : <Navigate to="/trial-expired" />}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/trial-expired" replace />} />
|
||||
</Routes>
|
||||
@@ -678,10 +692,23 @@ const AppContent: React.FC = () => {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={hasAccess(['owner']) ? <Settings /> : <Navigate to="/" />}
|
||||
/>
|
||||
{/* Settings Routes with Nested Layout */}
|
||||
{hasAccess(['owner']) ? (
|
||||
<Route path="/settings" element={<SettingsLayout />}>
|
||||
<Route index element={<Navigate to="/settings/general" replace />} />
|
||||
<Route path="general" element={<GeneralSettings />} />
|
||||
<Route path="branding" element={<BrandingSettings />} />
|
||||
<Route path="resource-types" element={<ResourceTypesSettings />} />
|
||||
<Route path="domains" element={<DomainsSettings />} />
|
||||
<Route path="api" element={<ApiSettings />} />
|
||||
<Route path="authentication" element={<AuthenticationSettings />} />
|
||||
<Route path="email" element={<EmailSettings />} />
|
||||
<Route path="sms-calling" element={<CommunicationSettings />} />
|
||||
<Route path="billing" element={<BillingSettings />} />
|
||||
</Route>
|
||||
) : (
|
||||
<Route path="/settings/*" element={<Navigate to="/" />} />
|
||||
)}
|
||||
<Route path="/profile" element={<ProfileSettings />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
|
||||
339
frontend/src/components/CreditPaymentForm.tsx
Normal file
339
frontend/src/components/CreditPaymentForm.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Credit Payment Form Component
|
||||
*
|
||||
* Uses Stripe Elements for secure card collection when purchasing
|
||||
* communication credits.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import {
|
||||
Elements,
|
||||
PaymentElement,
|
||||
useStripe,
|
||||
useElements,
|
||||
} from '@stripe/react-stripe-js';
|
||||
import { CreditCard, Loader2, X, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { useCreatePaymentIntent, useConfirmPayment } from '../hooks/useCommunicationCredits';
|
||||
|
||||
// Initialize Stripe
|
||||
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || '');
|
||||
|
||||
interface PaymentFormProps {
|
||||
amountCents: number;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
savePaymentMethod?: boolean;
|
||||
}
|
||||
|
||||
const PaymentFormInner: React.FC<PaymentFormProps> = ({
|
||||
amountCents,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
savePaymentMethod = false,
|
||||
}) => {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
const confirmPayment = useConfirmPayment();
|
||||
|
||||
const formatCurrency = (cents: number) => `$${(cents / 100).toFixed(2)}`;
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!stripe || !elements) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
// Confirm the payment with Stripe
|
||||
const { error, paymentIntent } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: window.location.href,
|
||||
},
|
||||
redirect: 'if_required',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setErrorMessage(error.message || 'Payment failed. Please try again.');
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (paymentIntent && paymentIntent.status === 'succeeded') {
|
||||
// Confirm the payment on the backend
|
||||
await confirmPayment.mutateAsync({
|
||||
payment_intent_id: paymentIntent.id,
|
||||
save_payment_method: savePaymentMethod,
|
||||
});
|
||||
setIsComplete(true);
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
}, 1500);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setErrorMessage(err.message || 'An unexpected error occurred.');
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isComplete) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Payment Successful!
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{formatCurrency(amountCents)} has been added to your credits.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 mb-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600 dark:text-gray-400">Amount</span>
|
||||
<span className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{formatCurrency(amountCents)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<PaymentElement
|
||||
options={{
|
||||
layout: 'tabs',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isProcessing}
|
||||
className="flex-1 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!stripe || isProcessing}
|
||||
className="flex-1 py-2.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CreditCard className="w-4 h-4" />
|
||||
Pay {formatCurrency(amountCents)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-center text-gray-500 dark:text-gray-400 mt-2">
|
||||
Your payment is securely processed by Stripe
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface CreditPaymentModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
amountCents: number;
|
||||
onAmountChange: (cents: number) => void;
|
||||
savePaymentMethod?: boolean;
|
||||
}
|
||||
|
||||
export const CreditPaymentModal: React.FC<CreditPaymentModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
amountCents,
|
||||
onAmountChange,
|
||||
savePaymentMethod = false,
|
||||
}) => {
|
||||
const [clientSecret, setClientSecret] = useState<string | null>(null);
|
||||
const [isLoadingIntent, setIsLoadingIntent] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showPaymentForm, setShowPaymentForm] = useState(false);
|
||||
const createPaymentIntent = useCreatePaymentIntent();
|
||||
|
||||
const formatCurrency = (cents: number) => `$${(cents / 100).toFixed(2)}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setClientSecret(null);
|
||||
setShowPaymentForm(false);
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleContinueToPayment = async () => {
|
||||
setIsLoadingIntent(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await createPaymentIntent.mutateAsync(amountCents);
|
||||
setClientSecret(result.client_secret);
|
||||
setShowPaymentForm(true);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to initialize payment. Please try again.');
|
||||
} finally {
|
||||
setIsLoadingIntent(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Add Credits
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!showPaymentForm ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[1000, 2500, 5000].map((amount) => (
|
||||
<button
|
||||
key={amount}
|
||||
onClick={() => onAmountChange(amount)}
|
||||
className={`py-3 px-4 rounded-lg border-2 transition-colors ${
|
||||
amountCents === amount
|
||||
? 'border-brand-600 bg-brand-50 dark:bg-brand-900/30 text-brand-600'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className="font-semibold">{formatCurrency(amount)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Custom amount (whole dollars only)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-900 dark:text-white font-medium">$</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={amountCents / 100}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value.replace(/[^0-9]/g, '');
|
||||
onAmountChange(Math.max(5, parseInt(val) || 5) * 100);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (!/[0-9]/.test(e.key) && !['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
className="w-full pl-8 pr-12 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500">.00</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleContinueToPayment}
|
||||
disabled={isLoadingIntent}
|
||||
className="flex-1 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoadingIntent ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CreditCard className="w-4 h-4" />
|
||||
Continue to Payment
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : clientSecret ? (
|
||||
<Elements
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret,
|
||||
appearance: {
|
||||
theme: 'stripe',
|
||||
variables: {
|
||||
colorPrimary: '#2563eb',
|
||||
colorBackground: '#ffffff',
|
||||
colorText: '#1e293b',
|
||||
colorDanger: '#dc2626',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
spacingUnit: '4px',
|
||||
borderRadius: '8px',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<PaymentFormInner
|
||||
amountCents={amountCents}
|
||||
onSuccess={onSuccess}
|
||||
onCancel={() => {
|
||||
setShowPaymentForm(false);
|
||||
setClientSecret(null);
|
||||
}}
|
||||
savePaymentMethod={savePaymentMethod}
|
||||
/>
|
||||
</Elements>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreditPaymentModal;
|
||||
@@ -97,6 +97,9 @@ export function DevQuickLogin({ embedded = false }: DevQuickLoginProps) {
|
||||
// Store token in cookie (use 'access_token' to match what client.ts expects)
|
||||
setCookie('access_token', response.data.token, 7);
|
||||
|
||||
// Clear any existing masquerade stack - this is a fresh login
|
||||
localStorage.removeItem('masquerade_stack');
|
||||
|
||||
// Fetch user data to determine redirect
|
||||
const userResponse = await apiClient.get('/auth/me/');
|
||||
const userData = userResponse.data;
|
||||
|
||||
@@ -11,10 +11,13 @@ import {
|
||||
Smartphone,
|
||||
Plus,
|
||||
AlertTriangle,
|
||||
ChevronDown
|
||||
ChevronDown,
|
||||
Sparkles,
|
||||
Check
|
||||
} from 'lucide-react';
|
||||
import api from '../api/client';
|
||||
import { EmailTemplate, EmailTemplateCategory, EmailTemplateVariableGroup } from '../types';
|
||||
import EmailTemplatePresetSelector from './EmailTemplatePresetSelector';
|
||||
|
||||
interface EmailTemplateFormProps {
|
||||
template?: EmailTemplate | null;
|
||||
@@ -44,6 +47,15 @@ const EmailTemplateForm: React.FC<EmailTemplateFormProps> = ({
|
||||
const [previewDevice, setPreviewDevice] = useState<'desktop' | 'mobile'>('desktop');
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [showVariables, setShowVariables] = useState(false);
|
||||
const [showPresetSelector, setShowPresetSelector] = useState(false);
|
||||
const [showTwoVersionsWarning, setShowTwoVersionsWarning] = useState(() => {
|
||||
// Check localStorage to see if user has dismissed the warning
|
||||
try {
|
||||
return localStorage.getItem('emailTemplates_twoVersionsWarning_dismissed') !== 'true';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch available variables
|
||||
const { data: variablesData } = useQuery<{ variables: EmailTemplateVariableGroup[] }>({
|
||||
@@ -105,6 +117,24 @@ const EmailTemplateForm: React.FC<EmailTemplateFormProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handlePresetSelect = (preset: any) => {
|
||||
setName(preset.name);
|
||||
setDescription(preset.description);
|
||||
setSubject(preset.subject);
|
||||
setHtmlContent(preset.html_content);
|
||||
setTextContent(preset.text_content);
|
||||
setShowPresetSelector(false);
|
||||
};
|
||||
|
||||
const handleDismissTwoVersionsWarning = () => {
|
||||
setShowTwoVersionsWarning(false);
|
||||
try {
|
||||
localStorage.setItem('emailTemplates_twoVersionsWarning_dismissed', 'true');
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
};
|
||||
|
||||
const categories: { value: EmailTemplateCategory; label: string }[] = [
|
||||
{ value: 'APPOINTMENT', label: t('emailTemplates.categoryAppointment', 'Appointment') },
|
||||
{ value: 'REMINDER', label: t('emailTemplates.categoryReminder', 'Reminder') },
|
||||
@@ -137,6 +167,23 @@ const EmailTemplateForm: React.FC<EmailTemplateFormProps> = ({
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{/* Choose from Preset Button */}
|
||||
{!isEditing && (
|
||||
<div className="mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPresetSelector(true)}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg hover:from-purple-700 hover:to-pink-700 transition-all shadow-md hover:shadow-lg font-medium"
|
||||
>
|
||||
<Sparkles className="h-5 w-5" />
|
||||
{t('emailTemplates.chooseFromPreset', 'Choose from Pre-designed Templates')}
|
||||
</button>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-center mt-2">
|
||||
{t('emailTemplates.presetHint', 'Start with a professionally designed template and customize it to your needs')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Left Column - Form */}
|
||||
<div className="space-y-4">
|
||||
@@ -245,12 +292,37 @@ const EmailTemplateForm: React.FC<EmailTemplateFormProps> = ({
|
||||
|
||||
{/* Content Tabs */}
|
||||
<div>
|
||||
{/* Info callout about HTML and Text versions */}
|
||||
{showTwoVersionsWarning && (
|
||||
<div className="mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">
|
||||
{t('emailTemplates.twoVersionsRequired', 'Please edit both email versions')}
|
||||
</h4>
|
||||
<p className="text-xs text-blue-800 dark:text-blue-300 leading-relaxed mb-3">
|
||||
{t('emailTemplates.twoVersionsExplanation', 'Your customers will receive one of two versions of this email depending on their email client. Edit both the HTML version (rich formatting) and the Plain Text version (simple text) below. Make sure both versions include the same information so all your customers get the complete message.')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismissTwoVersionsWarning}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 dark:bg-blue-500 text-white text-xs font-medium rounded hover:bg-blue-700 dark:hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t('emailTemplates.iUnderstand', 'I Understand')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
<div className="flex rounded-lg overflow-hidden border border-gray-300 dark:border-gray-600">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('html')}
|
||||
className={`px-4 py-2 text-sm font-medium flex items-center gap-2 ${
|
||||
className={`px-4 py-2 text-sm font-medium flex items-center gap-2 relative ${
|
||||
activeTab === 'html'
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
@@ -258,11 +330,14 @@ const EmailTemplateForm: React.FC<EmailTemplateFormProps> = ({
|
||||
>
|
||||
<Code className="h-4 w-4" />
|
||||
HTML
|
||||
{!htmlContent.trim() && (
|
||||
<span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('text')}
|
||||
className={`px-4 py-2 text-sm font-medium flex items-center gap-2 ${
|
||||
className={`px-4 py-2 text-sm font-medium flex items-center gap-2 relative ${
|
||||
activeTab === 'text'
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
@@ -270,6 +345,9 @@ const EmailTemplateForm: React.FC<EmailTemplateFormProps> = ({
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
Text
|
||||
{!textContent.trim() && (
|
||||
<span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -448,6 +526,15 @@ const EmailTemplateForm: React.FC<EmailTemplateFormProps> = ({
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Preset Selector Modal */}
|
||||
{showPresetSelector && (
|
||||
<EmailTemplatePresetSelector
|
||||
category={category}
|
||||
onSelect={handlePresetSelect}
|
||||
onClose={() => setShowPresetSelector(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
292
frontend/src/components/EmailTemplatePresetSelector.tsx
Normal file
292
frontend/src/components/EmailTemplatePresetSelector.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
X,
|
||||
Search,
|
||||
Eye,
|
||||
Check,
|
||||
Sparkles,
|
||||
Smile,
|
||||
Minus,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
import api from '../api/client';
|
||||
import { EmailTemplateCategory } from '../types';
|
||||
|
||||
interface TemplatePreset {
|
||||
name: string;
|
||||
description: string;
|
||||
style: string;
|
||||
subject: string;
|
||||
html_content: string;
|
||||
text_content: string;
|
||||
}
|
||||
|
||||
interface PresetsResponse {
|
||||
presets: Record<EmailTemplateCategory, TemplatePreset[]>;
|
||||
}
|
||||
|
||||
interface EmailTemplatePresetSelectorProps {
|
||||
category: EmailTemplateCategory;
|
||||
onSelect: (preset: TemplatePreset) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const styleIcons: Record<string, React.ReactNode> = {
|
||||
professional: <Sparkles className="h-4 w-4" />,
|
||||
friendly: <Smile className="h-4 w-4" />,
|
||||
minimalist: <Minus className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
const styleColors: Record<string, string> = {
|
||||
professional: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
friendly: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
minimalist: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||
};
|
||||
|
||||
const EmailTemplatePresetSelector: React.FC<EmailTemplatePresetSelectorProps> = ({
|
||||
category,
|
||||
onSelect,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedPreview, setSelectedPreview] = useState<TemplatePreset | null>(null);
|
||||
const [selectedStyle, setSelectedStyle] = useState<string>('all');
|
||||
|
||||
// Fetch presets
|
||||
const { data: presetsData, isLoading } = useQuery<PresetsResponse>({
|
||||
queryKey: ['email-template-presets'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/email-templates/presets/');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const presets = presetsData?.presets[category] || [];
|
||||
|
||||
// Filter presets
|
||||
const filteredPresets = presets.filter(preset => {
|
||||
const matchesSearch = searchQuery.trim() === '' ||
|
||||
preset.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
preset.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesStyle = selectedStyle === 'all' || preset.style === selectedStyle;
|
||||
|
||||
return matchesSearch && matchesStyle;
|
||||
});
|
||||
|
||||
// Get unique styles from presets
|
||||
const availableStyles = Array.from(new Set(presets.map(p => p.style)));
|
||||
|
||||
const handleSelectPreset = (preset: TemplatePreset) => {
|
||||
onSelect(preset);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('emailTemplates.selectPreset', 'Choose a Template')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('emailTemplates.presetDescription', 'Select a pre-designed template to customize')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{/* Search */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('emailTemplates.searchPresets', 'Search templates...')}
|
||||
className="w-full pl-9 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Style Filter */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedStyle('all')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedStyle === 'all'
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
All Styles
|
||||
</button>
|
||||
{availableStyles.map(style => (
|
||||
<button
|
||||
key={style}
|
||||
onClick={() => setSelectedStyle(style)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedStyle === style
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{styleIcons[style]}
|
||||
<span className="capitalize">{style}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
|
||||
</div>
|
||||
) : filteredPresets.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('emailTemplates.noPresets', 'No templates found matching your criteria')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredPresets.map((preset, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg overflow-hidden hover:shadow-lg transition-shadow cursor-pointer group"
|
||||
>
|
||||
{/* Preview Image Placeholder */}
|
||||
<div className="h-40 bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-600 dark:to-gray-700 relative overflow-hidden">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<iframe
|
||||
srcDoc={preset.html_content}
|
||||
className="w-full h-full pointer-events-none transform scale-50 origin-top-left"
|
||||
style={{ width: '200%', height: '200%' }}
|
||||
title={preset.name}
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-end justify-center pb-4">
|
||||
<button
|
||||
onClick={() => setSelectedPreview(preset)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-white/90 dark:bg-gray-800/90 text-gray-900 dark:text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white line-clamp-1">
|
||||
{preset.name}
|
||||
</h4>
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${styleColors[preset.style] || styleColors.professional}`}>
|
||||
{styleIcons[preset.style]}
|
||||
<span className="capitalize">{preset.style}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mb-3 line-clamp-2">
|
||||
{preset.description}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => handleSelectPreset(preset)}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
Use This Template
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview Modal */}
|
||||
{selectedPreview && (
|
||||
<div className="fixed inset-0 z-60 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{selectedPreview.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{selectedPreview.description}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedPreview(null)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Subject
|
||||
</label>
|
||||
<div className="p-3 bg-gray-100 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white text-sm">
|
||||
{selectedPreview.subject}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Preview
|
||||
</label>
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<iframe
|
||||
srcDoc={selectedPreview.html_content}
|
||||
className="w-full h-96 bg-white"
|
||||
title="Template Preview"
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setSelectedPreview(null)}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors font-medium"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSelectPreset(selectedPreview)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
Use This Template
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailTemplatePresetSelector;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
@@ -13,21 +13,19 @@ import {
|
||||
Briefcase,
|
||||
Ticket,
|
||||
HelpCircle,
|
||||
Code,
|
||||
ChevronDown,
|
||||
BookOpen,
|
||||
FileQuestion,
|
||||
LifeBuoy,
|
||||
Zap,
|
||||
Plug,
|
||||
Package,
|
||||
Clock,
|
||||
Store,
|
||||
Mail
|
||||
Mail,
|
||||
Plug,
|
||||
BookOpen,
|
||||
} from 'lucide-react';
|
||||
import { Business, User } from '../types';
|
||||
import { useLogout } from '../hooks/useAuth';
|
||||
import SmoothScheduleLogo from './SmoothScheduleLogo';
|
||||
import {
|
||||
SidebarSection,
|
||||
SidebarItem,
|
||||
SidebarDivider,
|
||||
} from './navigation/SidebarComponents';
|
||||
|
||||
interface SidebarProps {
|
||||
business: Business;
|
||||
@@ -38,41 +36,14 @@ interface SidebarProps {
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCollapse }) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const { role } = user;
|
||||
const logoutMutation = useLogout();
|
||||
const [isHelpOpen, setIsHelpOpen] = useState(location.pathname.startsWith('/help') || location.pathname === '/support');
|
||||
const [isPluginsOpen, setIsPluginsOpen] = useState(location.pathname.startsWith('/plugins') || location.pathname === '/plugins/marketplace');
|
||||
|
||||
const getNavClass = (path: string, exact: boolean = false, disabled: boolean = false) => {
|
||||
const isActive = exact
|
||||
? location.pathname === path
|
||||
: location.pathname.startsWith(path);
|
||||
|
||||
const baseClasses = `flex items-center gap-3 py-3 text-base font-medium rounded-lg transition-colors`;
|
||||
const collapsedClasses = isCollapsed ? 'px-3 justify-center' : 'px-4';
|
||||
const activeClasses = 'bg-white/10 text-white';
|
||||
const inactiveClasses = 'text-white/70 hover:text-white hover:bg-white/5';
|
||||
const disabledClasses = 'text-white/30 cursor-not-allowed';
|
||||
|
||||
if (disabled) {
|
||||
return `${baseClasses} ${collapsedClasses} ${disabledClasses}`;
|
||||
}
|
||||
|
||||
return `${baseClasses} ${collapsedClasses} ${isActive ? activeClasses : inactiveClasses}`;
|
||||
};
|
||||
|
||||
const canViewAdminPages = role === 'owner' || role === 'manager';
|
||||
const canViewManagementPages = role === 'owner' || role === 'manager' || role === 'staff';
|
||||
const canViewSettings = role === 'owner';
|
||||
// Tickets: owners/managers always, staff only with permission
|
||||
const canViewTickets = role === 'owner' || role === 'manager' || (role === 'staff' && user.can_access_tickets);
|
||||
|
||||
const getDashboardLink = () => {
|
||||
if (role === 'resource') return '/';
|
||||
return '/';
|
||||
};
|
||||
|
||||
const handleSignOut = () => {
|
||||
logoutMutation.mutate();
|
||||
};
|
||||
@@ -84,23 +55,22 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
background: `linear-gradient(to bottom right, ${business.primaryColor}, ${business.secondaryColor || business.primaryColor})`
|
||||
}}
|
||||
>
|
||||
{/* Header / Logo */}
|
||||
<button
|
||||
onClick={toggleCollapse}
|
||||
className={`flex items-center gap-3 w-full text-left px-6 py-8 ${isCollapsed ? 'justify-center' : ''} hover:bg-white/5 transition-colors focus:outline-none`}
|
||||
className={`flex items-center gap-3 w-full text-left px-6 py-6 ${isCollapsed ? 'justify-center' : ''} hover:bg-white/5 transition-colors focus:outline-none`}
|
||||
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{/* Logo-only mode: full width */}
|
||||
{business.logoDisplayMode === 'logo-only' && business.logoUrl ? (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<img
|
||||
src={business.logoUrl}
|
||||
alt={business.name}
|
||||
className="max-w-full max-h-16 object-contain"
|
||||
className="max-w-full max-h-12 object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Logo/Icon display */}
|
||||
{business.logoUrl && business.logoDisplayMode !== 'text-only' ? (
|
||||
<div className="flex items-center justify-center w-10 h-10 shrink-0">
|
||||
<img
|
||||
@@ -110,12 +80,13 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
/>
|
||||
</div>
|
||||
) : business.logoDisplayMode !== 'logo-only' && (
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-white rounded-lg text-brand-600 font-bold text-xl shrink-0" style={{ color: business.primaryColor }}>
|
||||
<div
|
||||
className="flex items-center justify-center w-10 h-10 bg-white rounded-lg font-bold text-xl shrink-0"
|
||||
style={{ color: business.primaryColor }}
|
||||
>
|
||||
{business.name.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text display */}
|
||||
{!isCollapsed && business.logoDisplayMode !== 'logo-only' && (
|
||||
<div className="overflow-hidden">
|
||||
<h1 className="font-bold leading-tight truncate">{business.name}</h1>
|
||||
@@ -126,219 +97,156 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
)}
|
||||
</button>
|
||||
|
||||
<nav className="flex-1 px-4 space-y-1 overflow-y-auto">
|
||||
<Link to={getDashboardLink()} className={getNavClass('/', true)} title={t('nav.dashboard')}>
|
||||
<LayoutDashboard size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.dashboard')}</span>}
|
||||
</Link>
|
||||
|
||||
<Link to="/scheduler" className={getNavClass('/scheduler')} title={t('nav.scheduler')}>
|
||||
<CalendarDays size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.scheduler')}</span>}
|
||||
</Link>
|
||||
|
||||
<Link to="/tasks" className={getNavClass('/tasks')} title={t('nav.tasks', 'Tasks')}>
|
||||
<Clock size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.tasks', 'Tasks')}</span>}
|
||||
</Link>
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-3 space-y-6 overflow-y-auto pb-4">
|
||||
{/* Core Features - Always visible */}
|
||||
<SidebarSection isCollapsed={isCollapsed}>
|
||||
<SidebarItem
|
||||
to="/"
|
||||
icon={LayoutDashboard}
|
||||
label={t('nav.dashboard')}
|
||||
isCollapsed={isCollapsed}
|
||||
exact
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/scheduler"
|
||||
icon={CalendarDays}
|
||||
label={t('nav.scheduler')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/tasks"
|
||||
icon={Clock}
|
||||
label={t('nav.tasks', 'Tasks')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</SidebarSection>
|
||||
|
||||
{/* Manage Section - Staff+ */}
|
||||
{canViewManagementPages && (
|
||||
<>
|
||||
<Link to="/customers" className={getNavClass('/customers')} title={t('nav.customers')}>
|
||||
<Users size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.customers')}</span>}
|
||||
</Link>
|
||||
<Link to="/services" className={getNavClass('/services')} title={t('nav.services', 'Services')}>
|
||||
<Briefcase size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.services', 'Services')}</span>}
|
||||
</Link>
|
||||
<Link to="/resources" className={getNavClass('/resources')} title={t('nav.resources')}>
|
||||
<ClipboardList size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.resources')}</span>}
|
||||
</Link>
|
||||
</>
|
||||
<SidebarSection title={t('nav.sections.manage', 'Manage')} isCollapsed={isCollapsed}>
|
||||
<SidebarItem
|
||||
to="/customers"
|
||||
icon={Users}
|
||||
label={t('nav.customers')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/services"
|
||||
icon={Briefcase}
|
||||
label={t('nav.services', 'Services')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/resources"
|
||||
icon={ClipboardList}
|
||||
label={t('nav.resources')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
{canViewAdminPages && (
|
||||
<SidebarItem
|
||||
to="/staff"
|
||||
icon={Users}
|
||||
label={t('nav.staff')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
</SidebarSection>
|
||||
)}
|
||||
|
||||
{canViewTickets && (
|
||||
<Link to="/tickets" className={getNavClass('/tickets')} title={t('nav.tickets')}>
|
||||
<Ticket size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.tickets')}</span>}
|
||||
</Link>
|
||||
{/* Communicate Section - Tickets + Messages */}
|
||||
{(canViewTickets || canViewAdminPages) && (
|
||||
<SidebarSection title={t('nav.sections.communicate', 'Communicate')} isCollapsed={isCollapsed}>
|
||||
{canViewAdminPages && (
|
||||
<SidebarItem
|
||||
to="/messages"
|
||||
icon={MessageSquare}
|
||||
label={t('nav.messages')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{canViewTickets && (
|
||||
<SidebarItem
|
||||
to="/tickets"
|
||||
icon={Ticket}
|
||||
label={t('nav.tickets')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
</SidebarSection>
|
||||
)}
|
||||
|
||||
{/* Money Section - Payments */}
|
||||
{canViewAdminPages && (
|
||||
<>
|
||||
{/* Payments link: always visible for owners, only visible for others if enabled */}
|
||||
{(role === 'owner' || business.paymentsEnabled) && (
|
||||
business.paymentsEnabled ? (
|
||||
<Link to="/payments" className={getNavClass('/payments')} title={t('nav.payments')}>
|
||||
<CreditCard size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.payments')}</span>}
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
className={getNavClass('/payments', false, true)}
|
||||
title={t('nav.paymentsDisabledTooltip')}
|
||||
>
|
||||
<CreditCard size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.payments')}</span>}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<Link to="/messages" className={getNavClass('/messages')} title={t('nav.messages')}>
|
||||
<MessageSquare size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.messages')}</span>}
|
||||
</Link>
|
||||
<Link to="/staff" className={getNavClass('/staff')} title={t('nav.staff')}>
|
||||
<Users size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.staff')}</span>}
|
||||
</Link>
|
||||
|
||||
{/* Plugins Dropdown */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setIsPluginsOpen(!isPluginsOpen)}
|
||||
className={`flex items-center gap-3 py-3 text-base font-medium rounded-lg transition-colors w-full ${isCollapsed ? 'px-3 justify-center' : 'px-4'} ${location.pathname.startsWith('/plugins') ? 'bg-white/10 text-white' : 'text-white/70 hover:text-white hover:bg-white/5'}`}
|
||||
title={t('nav.plugins', 'Plugins')}
|
||||
>
|
||||
<Plug size={20} className="shrink-0" />
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className="flex-1 text-left">{t('nav.plugins', 'Plugins')}</span>
|
||||
<ChevronDown size={16} className={`shrink-0 transition-transform ${isPluginsOpen ? 'rotate-180' : ''}`} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{isPluginsOpen && !isCollapsed && (
|
||||
<div className="ml-4 mt-1 space-y-1 border-l border-white/20 pl-4">
|
||||
<Link
|
||||
to="/plugins/marketplace"
|
||||
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/plugins/marketplace' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
|
||||
title={t('nav.marketplace', 'Marketplace')}
|
||||
>
|
||||
<Store size={16} className="shrink-0" />
|
||||
<span>{t('nav.marketplace', 'Marketplace')}</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/plugins/my-plugins"
|
||||
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/plugins/my-plugins' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
|
||||
title={t('nav.myPlugins', 'My Plugins')}
|
||||
>
|
||||
<Package size={16} className="shrink-0" />
|
||||
<span>{t('nav.myPlugins', 'My Plugins')}</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/email-templates"
|
||||
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/email-templates' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
|
||||
title={t('nav.emailTemplates', 'Email Templates')}
|
||||
>
|
||||
<Mail size={16} className="shrink-0" />
|
||||
<span>{t('nav.emailTemplates', 'Email Templates')}</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/help/plugins"
|
||||
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/help/plugins' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
|
||||
title={t('nav.pluginDocs', 'Plugin Documentation')}
|
||||
>
|
||||
<Zap size={16} className="shrink-0" />
|
||||
<span>{t('nav.pluginDocs', 'Plugin Docs')}</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Help Dropdown */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setIsHelpOpen(!isHelpOpen)}
|
||||
className={`flex items-center gap-3 py-3 text-base font-medium rounded-lg transition-colors w-full ${isCollapsed ? 'px-3 justify-center' : 'px-4'} ${location.pathname.startsWith('/help') || location.pathname === '/support' ? 'bg-white/10 text-white' : 'text-white/70 hover:text-white hover:bg-white/5'}`}
|
||||
title={t('nav.help', 'Help')}
|
||||
>
|
||||
<HelpCircle size={20} className="shrink-0" />
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className="flex-1 text-left">{t('nav.help', 'Help')}</span>
|
||||
<ChevronDown size={16} className={`shrink-0 transition-transform ${isHelpOpen ? 'rotate-180' : ''}`} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{isHelpOpen && !isCollapsed && (
|
||||
<div className="ml-4 mt-1 space-y-1 border-l border-white/20 pl-4">
|
||||
<Link
|
||||
to="/help/guide"
|
||||
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/help/guide' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
|
||||
title={t('nav.platformGuide', 'Platform Guide')}
|
||||
>
|
||||
<BookOpen size={16} className="shrink-0" />
|
||||
<span>{t('nav.platformGuide', 'Platform Guide')}</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/help/ticketing"
|
||||
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/help/ticketing' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
|
||||
title={t('nav.ticketingHelp', 'Ticketing System')}
|
||||
>
|
||||
<FileQuestion size={16} className="shrink-0" />
|
||||
<span>{t('nav.ticketingHelp', 'Ticketing System')}</span>
|
||||
</Link>
|
||||
{role === 'owner' && (
|
||||
<Link
|
||||
to="/help/api"
|
||||
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/help/api' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
|
||||
title={t('nav.apiDocs', 'API Documentation')}
|
||||
>
|
||||
<Code size={16} className="shrink-0" />
|
||||
<span>{t('nav.apiDocs', 'API Docs')}</span>
|
||||
</Link>
|
||||
)}
|
||||
<div className="pt-2 mt-2 border-t border-white/10">
|
||||
<Link
|
||||
to="/support"
|
||||
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/support' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
|
||||
title={t('nav.support', 'Support')}
|
||||
>
|
||||
<MessageSquare size={16} className="shrink-0" />
|
||||
<span>{t('nav.support', 'Support')}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<SidebarSection title={t('nav.sections.money', 'Money')} isCollapsed={isCollapsed}>
|
||||
<SidebarItem
|
||||
to="/payments"
|
||||
icon={CreditCard}
|
||||
label={t('nav.payments')}
|
||||
isCollapsed={isCollapsed}
|
||||
disabled={!business.paymentsEnabled && role !== 'owner'}
|
||||
/>
|
||||
</SidebarSection>
|
||||
)}
|
||||
|
||||
{canViewSettings && (
|
||||
<div className="pt-8 mt-8 border-t border-white/10">
|
||||
{canViewSettings && (
|
||||
<Link to="/settings" className={getNavClass('/settings', true)} title={t('nav.businessSettings')}>
|
||||
<Settings size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.businessSettings')}</span>}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{/* Extend Section - Plugins & Templates */}
|
||||
{canViewAdminPages && (
|
||||
<SidebarSection title={t('nav.sections.extend', 'Extend')} isCollapsed={isCollapsed}>
|
||||
<SidebarItem
|
||||
to="/plugins"
|
||||
icon={Plug}
|
||||
label={t('nav.plugins', 'Plugins')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/email-templates"
|
||||
icon={Mail}
|
||||
label={t('nav.emailTemplates', 'Email Templates')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</SidebarSection>
|
||||
)}
|
||||
|
||||
{/* Footer Section - Settings & Help */}
|
||||
<SidebarDivider isCollapsed={isCollapsed} />
|
||||
|
||||
<SidebarSection isCollapsed={isCollapsed}>
|
||||
{canViewSettings && (
|
||||
<SidebarItem
|
||||
to="/settings"
|
||||
icon={Settings}
|
||||
label={t('nav.businessSettings')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
<SidebarItem
|
||||
to="/help"
|
||||
icon={HelpCircle}
|
||||
label={t('nav.helpDocs', 'Help & Docs')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</SidebarSection>
|
||||
</nav>
|
||||
|
||||
{/* User Section */}
|
||||
<div className="p-4 border-t border-white/10">
|
||||
<a
|
||||
href={`${window.location.protocol}//${window.location.host.split('.').slice(-2).join('.')}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex items-center gap-2 text-xs text-white/60 mb-4 hover:text-white/80 transition-colors ${isCollapsed ? 'justify-center' : ''}`}
|
||||
className={`flex items-center gap-2 text-xs text-white/60 mb-3 hover:text-white/80 transition-colors ${isCollapsed ? 'justify-center' : ''}`}
|
||||
>
|
||||
<SmoothScheduleLogo className="w-6 h-6 text-white" />
|
||||
<SmoothScheduleLogo className="w-5 h-5 text-white" />
|
||||
{!isCollapsed && (
|
||||
<div>
|
||||
<span className="block">{t('common.poweredBy')}</span>
|
||||
<span className="font-semibold text-white/80">Smooth Schedule</span>
|
||||
</div>
|
||||
<span className="text-white/60">Smooth Schedule</span>
|
||||
)}
|
||||
</a>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
disabled={logoutMutation.isPending}
|
||||
className={`flex items-center gap-3 px-4 py-2 text-sm font-medium text-white/70 hover:text-white w-full transition-colors rounded-lg ${isCollapsed ? 'justify-center' : ''} disabled:opacity-50`}
|
||||
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium text-white/70 hover:text-white hover:bg-white/5 w-full transition-colors rounded-lg ${isCollapsed ? 'justify-center' : ''} disabled:opacity-50`}
|
||||
>
|
||||
<LogOut size={20} className="shrink-0" />
|
||||
<LogOut size={18} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('auth.signOut')}</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
281
frontend/src/components/navigation/SidebarComponents.tsx
Normal file
281
frontend/src/components/navigation/SidebarComponents.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Shared Sidebar Navigation Components
|
||||
*
|
||||
* Reusable building blocks for main sidebar and settings sidebar navigation.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { ChevronDown, LucideIcon } from 'lucide-react';
|
||||
|
||||
interface SidebarSectionProps {
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
isCollapsed?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Section wrapper with optional header
|
||||
*/
|
||||
export const SidebarSection: React.FC<SidebarSectionProps> = ({
|
||||
title,
|
||||
children,
|
||||
isCollapsed = false,
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<div className={`space-y-1 ${className}`}>
|
||||
{title && !isCollapsed && (
|
||||
<h3 className="px-4 pt-1 pb-1.5 text-xs font-semibold uppercase tracking-wider text-white/40">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{title && isCollapsed && (
|
||||
<div className="mx-auto w-8 border-t border-white/20 my-2" />
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SidebarItemProps {
|
||||
to: string;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
isCollapsed?: boolean;
|
||||
exact?: boolean;
|
||||
disabled?: boolean;
|
||||
badge?: string | number;
|
||||
variant?: 'default' | 'settings';
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation item with icon
|
||||
*/
|
||||
export const SidebarItem: React.FC<SidebarItemProps> = ({
|
||||
to,
|
||||
icon: Icon,
|
||||
label,
|
||||
isCollapsed = false,
|
||||
exact = false,
|
||||
disabled = false,
|
||||
badge,
|
||||
variant = 'default',
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const isActive = exact
|
||||
? location.pathname === to
|
||||
: location.pathname.startsWith(to);
|
||||
|
||||
const baseClasses = 'flex items-center gap-3 py-2.5 text-sm font-medium rounded-lg transition-colors';
|
||||
const collapsedClasses = isCollapsed ? 'px-3 justify-center' : 'px-4';
|
||||
|
||||
// Different color schemes for main nav vs settings nav
|
||||
const colorClasses = variant === 'settings'
|
||||
? isActive
|
||||
? 'bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-800'
|
||||
: isActive
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-white/70 hover:text-white hover:bg-white/5';
|
||||
|
||||
const disabledClasses = variant === 'settings'
|
||||
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
|
||||
: 'text-white/30 cursor-not-allowed';
|
||||
|
||||
const className = `${baseClasses} ${collapsedClasses} ${disabled ? disabledClasses : colorClasses}`;
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<div className={className} title={label}>
|
||||
<Icon size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span className="flex-1">{label}</span>}
|
||||
{badge && !isCollapsed && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-white/10">{badge}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={to} className={className} title={label}>
|
||||
<Icon size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span className="flex-1">{label}</span>}
|
||||
{badge && !isCollapsed && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-brand-100 text-brand-700 dark:bg-brand-900/50 dark:text-brand-400">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
interface SidebarDropdownProps {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
isCollapsed?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
isActiveWhen?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible dropdown section
|
||||
*/
|
||||
export const SidebarDropdown: React.FC<SidebarDropdownProps> = ({
|
||||
icon: Icon,
|
||||
label,
|
||||
children,
|
||||
isCollapsed = false,
|
||||
defaultOpen = false,
|
||||
isActiveWhen = [],
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const [isOpen, setIsOpen] = React.useState(
|
||||
defaultOpen || isActiveWhen.some(path => location.pathname.startsWith(path))
|
||||
);
|
||||
|
||||
const isActive = isActiveWhen.some(path => location.pathname.startsWith(path));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`flex items-center gap-3 py-2.5 text-sm font-medium rounded-lg transition-colors w-full ${
|
||||
isCollapsed ? 'px-3 justify-center' : 'px-4'
|
||||
} ${
|
||||
isActive
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-white/70 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
title={label}
|
||||
>
|
||||
<Icon size={20} className="shrink-0" />
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className="flex-1 text-left">{label}</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{isOpen && !isCollapsed && (
|
||||
<div className="ml-4 mt-1 space-y-0.5 border-l border-white/20 pl-4">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SidebarSubItemProps {
|
||||
to: string;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sub-item for dropdown menus
|
||||
*/
|
||||
export const SidebarSubItem: React.FC<SidebarSubItemProps> = ({
|
||||
to,
|
||||
icon: Icon,
|
||||
label,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const isActive = location.pathname === to;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${
|
||||
isActive
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
title={label}
|
||||
>
|
||||
<Icon size={16} className="shrink-0" />
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
interface SidebarDividerProps {
|
||||
isCollapsed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual divider between sections
|
||||
*/
|
||||
export const SidebarDivider: React.FC<SidebarDividerProps> = ({ isCollapsed }) => {
|
||||
return (
|
||||
<div className={`my-4 ${isCollapsed ? 'mx-3' : 'mx-4'} border-t border-white/10`} />
|
||||
);
|
||||
};
|
||||
|
||||
interface SettingsSidebarSectionProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Section for settings sidebar (different styling)
|
||||
*/
|
||||
export const SettingsSidebarSection: React.FC<SettingsSidebarSectionProps> = ({
|
||||
title,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<h3 className="px-4 pt-0.5 pb-1 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SettingsSidebarItemProps {
|
||||
to: string;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings navigation item with optional description
|
||||
*/
|
||||
export const SettingsSidebarItem: React.FC<SettingsSidebarItemProps> = ({
|
||||
to,
|
||||
icon: Icon,
|
||||
label,
|
||||
description,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const isActive = location.pathname === to || location.pathname.startsWith(to + '/');
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={`flex items-start gap-2.5 px-4 py-1.5 text-sm rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<Icon size={16} className="shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-medium">{label}</span>
|
||||
{description && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 truncate">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@@ -72,6 +72,9 @@ export const useLogin = () => {
|
||||
setCookie('access_token', data.access, 7);
|
||||
setCookie('refresh_token', data.refresh, 7);
|
||||
|
||||
// Clear any existing masquerade stack - this is a fresh login
|
||||
localStorage.removeItem('masquerade_stack');
|
||||
|
||||
// Set user in cache
|
||||
queryClient.setQueryData(['currentUser'], data.user);
|
||||
},
|
||||
@@ -91,6 +94,9 @@ export const useLogout = () => {
|
||||
deleteCookie('access_token');
|
||||
deleteCookie('refresh_token');
|
||||
|
||||
// Clear masquerade stack
|
||||
localStorage.removeItem('masquerade_stack');
|
||||
|
||||
// Clear user cache
|
||||
queryClient.removeQueries({ queryKey: ['currentUser'] });
|
||||
queryClient.clear();
|
||||
|
||||
195
frontend/src/hooks/useCommunicationCredits.ts
Normal file
195
frontend/src/hooks/useCommunicationCredits.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Communication Credits Hooks
|
||||
* For managing business SMS/calling credits and auto-reload settings
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
export interface CommunicationCredits {
|
||||
id: number;
|
||||
balance_cents: number;
|
||||
auto_reload_enabled: boolean;
|
||||
auto_reload_threshold_cents: number;
|
||||
auto_reload_amount_cents: number;
|
||||
low_balance_warning_cents: number;
|
||||
low_balance_warning_sent: boolean;
|
||||
stripe_payment_method_id: string;
|
||||
last_twilio_sync_at: string | null;
|
||||
total_loaded_cents: number;
|
||||
total_spent_cents: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreditTransaction {
|
||||
id: number;
|
||||
amount_cents: number;
|
||||
balance_after_cents: number;
|
||||
transaction_type: 'manual' | 'auto_reload' | 'usage' | 'refund' | 'adjustment' | 'promo';
|
||||
description: string;
|
||||
reference_type: string;
|
||||
reference_id: string;
|
||||
stripe_charge_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface UpdateCreditsSettings {
|
||||
auto_reload_enabled?: boolean;
|
||||
auto_reload_threshold_cents?: number;
|
||||
auto_reload_amount_cents?: number;
|
||||
low_balance_warning_cents?: number;
|
||||
stripe_payment_method_id?: string;
|
||||
}
|
||||
|
||||
export interface AddCreditsRequest {
|
||||
amount_cents: number;
|
||||
payment_method_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get communication credits for current business
|
||||
*/
|
||||
export const useCommunicationCredits = () => {
|
||||
return useQuery<CommunicationCredits>({
|
||||
queryKey: ['communicationCredits'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/communication-credits/');
|
||||
return data;
|
||||
},
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get credit transaction history
|
||||
*/
|
||||
export const useCreditTransactions = (page = 1, limit = 20) => {
|
||||
return useQuery<{ results: CreditTransaction[]; count: number; next: string | null; previous: string | null }>({
|
||||
queryKey: ['creditTransactions', page, limit],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/communication-credits/transactions/', {
|
||||
params: { page, limit },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to update credit settings (auto-reload, thresholds, etc.)
|
||||
*/
|
||||
export const useUpdateCreditsSettings = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (settings: UpdateCreditsSettings) => {
|
||||
const { data } = await apiClient.patch('/communication-credits/settings/', settings);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['communicationCredits'], data);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to add credits (manual top-up)
|
||||
*/
|
||||
export const useAddCredits = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (request: AddCreditsRequest) => {
|
||||
const { data } = await apiClient.post('/communication-credits/add/', request);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['communicationCredits'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['creditTransactions'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to create a payment intent for credit purchase
|
||||
*/
|
||||
export const useCreatePaymentIntent = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (amount_cents: number) => {
|
||||
const { data } = await apiClient.post('/communication-credits/create-payment-intent/', {
|
||||
amount_cents,
|
||||
});
|
||||
return data as { client_secret: string; payment_intent_id: string };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to confirm a payment after client-side processing
|
||||
*/
|
||||
export const useConfirmPayment = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (params: { payment_intent_id: string; save_payment_method?: boolean }) => {
|
||||
const { data } = await apiClient.post('/communication-credits/confirm-payment/', params);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['communicationCredits'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['creditTransactions'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to set up Stripe payment method for auto-reload
|
||||
*/
|
||||
export const useSetupPaymentMethod = () => {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await apiClient.post('/communication-credits/setup-payment-method/');
|
||||
return data as { client_secret: string };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to save a payment method after setup
|
||||
*/
|
||||
export const useSavePaymentMethod = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (payment_method_id: string) => {
|
||||
const { data } = await apiClient.post('/communication-credits/save-payment-method/', {
|
||||
payment_method_id,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['communicationCredits'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get communication usage stats
|
||||
*/
|
||||
export const useCommunicationUsageStats = () => {
|
||||
return useQuery<{
|
||||
sms_sent_this_month: number;
|
||||
voice_minutes_this_month: number;
|
||||
proxy_numbers_active: number;
|
||||
estimated_cost_cents: number;
|
||||
}>({
|
||||
queryKey: ['communicationUsageStats'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/communication-credits/usage-stats/');
|
||||
return data;
|
||||
},
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
});
|
||||
};
|
||||
@@ -44,6 +44,17 @@ export interface SubscriptionPlan {
|
||||
permissions: Record<string, boolean>;
|
||||
transaction_fee_percent: string;
|
||||
transaction_fee_fixed: string;
|
||||
// Communication pricing
|
||||
sms_enabled: boolean;
|
||||
sms_price_per_message_cents: number;
|
||||
masked_calling_enabled: boolean;
|
||||
masked_calling_price_per_minute_cents: number;
|
||||
proxy_number_enabled: boolean;
|
||||
proxy_number_monthly_fee_cents: number;
|
||||
// Default credit settings
|
||||
default_auto_reload_enabled: boolean;
|
||||
default_auto_reload_threshold_cents: number;
|
||||
default_auto_reload_amount_cents: number;
|
||||
is_active: boolean;
|
||||
is_public: boolean;
|
||||
is_most_popular: boolean;
|
||||
@@ -64,6 +75,17 @@ export interface SubscriptionPlanCreate {
|
||||
permissions?: Record<string, boolean>;
|
||||
transaction_fee_percent?: number;
|
||||
transaction_fee_fixed?: number;
|
||||
// Communication pricing
|
||||
sms_enabled?: boolean;
|
||||
sms_price_per_message_cents?: number;
|
||||
masked_calling_enabled?: boolean;
|
||||
masked_calling_price_per_minute_cents?: number;
|
||||
proxy_number_enabled?: boolean;
|
||||
proxy_number_monthly_fee_cents?: number;
|
||||
// Default credit settings
|
||||
default_auto_reload_enabled?: boolean;
|
||||
default_auto_reload_threshold_cents?: number;
|
||||
default_auto_reload_amount_cents?: number;
|
||||
is_active?: boolean;
|
||||
is_public?: boolean;
|
||||
is_most_popular?: boolean;
|
||||
|
||||
154
frontend/src/layouts/SettingsLayout.tsx
Normal file
154
frontend/src/layouts/SettingsLayout.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Settings Layout
|
||||
*
|
||||
* Provides a sidebar navigation for settings pages with grouped sections.
|
||||
* Used as a wrapper for all /settings/* routes.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Outlet, Link, useLocation, useNavigate, useOutletContext } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
Palette,
|
||||
Layers,
|
||||
Globe,
|
||||
Key,
|
||||
Lock,
|
||||
Mail,
|
||||
Phone,
|
||||
CreditCard,
|
||||
Webhook,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
SettingsSidebarSection,
|
||||
SettingsSidebarItem,
|
||||
} from '../components/navigation/SidebarComponents';
|
||||
import { Business, User } from '../types';
|
||||
|
||||
interface ParentContext {
|
||||
user: User;
|
||||
business: Business;
|
||||
updateBusiness: (updates: Partial<Business>) => void;
|
||||
}
|
||||
|
||||
const SettingsLayout: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Get context from parent route (BusinessLayout)
|
||||
const parentContext = useOutletContext<ParentContext>();
|
||||
|
||||
return (
|
||||
<div className="flex h-full bg-gray-50 dark:bg-gray-900">
|
||||
{/* Settings Sidebar */}
|
||||
<aside className="w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col shrink-0">
|
||||
{/* Back Button */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span>{t('settings.backToApp', 'Back to App')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Settings Title */}
|
||||
<div className="px-4 py-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('settings.title', 'Settings')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-2 pb-4 space-y-3 overflow-y-auto">
|
||||
{/* Business Section */}
|
||||
<SettingsSidebarSection title={t('settings.sections.business', 'Business')}>
|
||||
<SettingsSidebarItem
|
||||
to="/settings/general"
|
||||
icon={Building2}
|
||||
label={t('settings.general.title', 'General')}
|
||||
description={t('settings.general.description', 'Name, timezone, contact')}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/settings/branding"
|
||||
icon={Palette}
|
||||
label={t('settings.branding.title', 'Branding')}
|
||||
description={t('settings.branding.description', 'Logo, colors, appearance')}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/settings/resource-types"
|
||||
icon={Layers}
|
||||
label={t('settings.resourceTypes.title', 'Resource Types')}
|
||||
description={t('settings.resourceTypes.description', 'Staff, rooms, equipment')}
|
||||
/>
|
||||
</SettingsSidebarSection>
|
||||
|
||||
{/* Integrations Section */}
|
||||
<SettingsSidebarSection title={t('settings.sections.integrations', 'Integrations')}>
|
||||
<SettingsSidebarItem
|
||||
to="/settings/domains"
|
||||
icon={Globe}
|
||||
label={t('settings.domains.title', 'Domains')}
|
||||
description={t('settings.domains.description', 'Custom domain setup')}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/settings/api"
|
||||
icon={Key}
|
||||
label={t('settings.api.title', 'API & Webhooks')}
|
||||
description={t('settings.api.description', 'API tokens, webhooks')}
|
||||
/>
|
||||
</SettingsSidebarSection>
|
||||
|
||||
{/* Access Section */}
|
||||
<SettingsSidebarSection title={t('settings.sections.access', 'Access')}>
|
||||
<SettingsSidebarItem
|
||||
to="/settings/authentication"
|
||||
icon={Lock}
|
||||
label={t('settings.authentication.title', 'Authentication')}
|
||||
description={t('settings.authentication.description', 'OAuth, social login')}
|
||||
/>
|
||||
</SettingsSidebarSection>
|
||||
|
||||
{/* Communication Section */}
|
||||
<SettingsSidebarSection title={t('settings.sections.communication', 'Communication')}>
|
||||
<SettingsSidebarItem
|
||||
to="/settings/email"
|
||||
icon={Mail}
|
||||
label={t('settings.email.title', 'Email Setup')}
|
||||
description={t('settings.email.description', 'Email addresses for tickets')}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/settings/sms-calling"
|
||||
icon={Phone}
|
||||
label={t('settings.smsCalling.title', 'SMS & Calling')}
|
||||
description={t('settings.smsCalling.description', 'Credits, phone numbers')}
|
||||
/>
|
||||
</SettingsSidebarSection>
|
||||
|
||||
{/* Billing Section */}
|
||||
<SettingsSidebarSection title={t('settings.sections.billing', 'Billing')}>
|
||||
<SettingsSidebarItem
|
||||
to="/settings/billing"
|
||||
icon={CreditCard}
|
||||
label={t('settings.billing.title', 'Plan & Billing')}
|
||||
description={t('settings.billing.description', 'Subscription, invoices')}
|
||||
/>
|
||||
</SettingsSidebarSection>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Content Area */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-4xl mx-auto p-8">
|
||||
<Outlet context={parentContext} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsLayout;
|
||||
@@ -18,7 +18,15 @@ import {
|
||||
FileText,
|
||||
BarChart3,
|
||||
Package,
|
||||
AlertTriangle
|
||||
AlertTriangle,
|
||||
Sparkles,
|
||||
Smile,
|
||||
Minus,
|
||||
Grid3x3,
|
||||
List,
|
||||
Check,
|
||||
Square,
|
||||
CheckSquare
|
||||
} from 'lucide-react';
|
||||
import api from '../api/client';
|
||||
import { EmailTemplate, EmailTemplateCategory } from '../types';
|
||||
@@ -37,26 +45,52 @@ const categoryIcons: Record<EmailTemplateCategory, React.ReactNode> = {
|
||||
|
||||
// Category colors
|
||||
const categoryColors: Record<EmailTemplateCategory, string> = {
|
||||
APPOINTMENT: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
REMINDER: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
CONFIRMATION: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
||||
MARKETING: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
|
||||
NOTIFICATION: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300',
|
||||
REPORT: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300',
|
||||
OTHER: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||
APPOINTMENT: 'bg-indigo-50 text-indigo-700 ring-indigo-700/20 dark:bg-indigo-400/10 dark:text-indigo-400 ring-indigo-400/30',
|
||||
REMINDER: 'bg-orange-50 text-orange-700 ring-orange-700/20 dark:bg-orange-400/10 dark:text-orange-400 ring-orange-400/30',
|
||||
CONFIRMATION: 'bg-green-50 text-green-700 ring-green-700/20 dark:bg-green-400/10 dark:text-green-400 ring-green-400/30',
|
||||
MARKETING: 'bg-purple-50 text-purple-700 ring-purple-700/20 dark:bg-purple-400/10 dark:text-purple-400 ring-purple-400/30',
|
||||
NOTIFICATION: 'bg-sky-50 text-sky-700 ring-sky-700/20 dark:bg-sky-400/10 dark:text-sky-400 ring-sky-400/30',
|
||||
REPORT: 'bg-rose-50 text-rose-700 ring-rose-700/20 dark:bg-rose-400/10 dark:text-rose-400 ring-rose-400/30',
|
||||
OTHER: 'bg-gray-50 text-gray-700 ring-gray-700/20 dark:bg-gray-400/10 dark:text-gray-400 ring-gray-400/30',
|
||||
};
|
||||
|
||||
interface TemplatePreset {
|
||||
name: string;
|
||||
description: string;
|
||||
style: string;
|
||||
subject: string;
|
||||
html_content: string;
|
||||
text_content: string;
|
||||
}
|
||||
|
||||
const styleIcons: Record<string, React.ReactNode> = {
|
||||
professional: <Sparkles className="h-4 w-4" />,
|
||||
friendly: <Smile className="h-4 w-4" />,
|
||||
minimalist: <Minus className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
const styleColors: Record<string, string> = {
|
||||
professional: 'bg-purple-50 text-purple-700 ring-purple-700/20 dark:bg-purple-400/10 dark:text-purple-400 ring-purple-400/30',
|
||||
friendly: 'bg-green-50 text-green-700 ring-green-700/20 dark:bg-green-400/10 dark:text-green-400 ring-green-400/30',
|
||||
minimalist: 'bg-gray-50 text-gray-700 ring-gray-700/20 dark:bg-gray-400/10 dark:text-gray-400 ring-gray-400/30',
|
||||
};
|
||||
|
||||
const EmailTemplates: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [activeView, setActiveView] = useState<'my-templates' | 'browse'>('browse');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<EmailTemplateCategory | 'ALL'>('ALL');
|
||||
const [selectedStyle, setSelectedStyle] = useState<string>('all');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [templateToDelete, setTemplateToDelete] = useState<EmailTemplate | null>(null);
|
||||
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
||||
const [previewTemplate, setPreviewTemplate] = useState<EmailTemplate | null>(null);
|
||||
const [previewPreset, setPreviewPreset] = useState<TemplatePreset | null>(null);
|
||||
const [selectedTemplates, setSelectedTemplates] = useState<Set<string>>(new Set());
|
||||
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false);
|
||||
|
||||
// Fetch email templates
|
||||
const { data: templates = [], isLoading, error } = useQuery<EmailTemplate[]>({
|
||||
@@ -82,6 +116,15 @@ const EmailTemplates: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch template presets
|
||||
const { data: presetsData, isLoading: presetsLoading } = useQuery<{ presets: Record<EmailTemplateCategory, TemplatePreset[]> }>({
|
||||
queryKey: ['email-template-presets'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/email-templates/presets/');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Delete template mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (templateId: string) => {
|
||||
@@ -105,6 +148,19 @@ const EmailTemplates: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Bulk delete mutation
|
||||
const bulkDeleteMutation = useMutation({
|
||||
mutationFn: async (templateIds: string[]) => {
|
||||
// Delete templates one by one (backend may not support bulk delete)
|
||||
await Promise.all(templateIds.map(id => api.delete(`/email-templates/${id}/`)));
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
|
||||
setSelectedTemplates(new Set());
|
||||
setShowBulkDeleteModal(false);
|
||||
},
|
||||
});
|
||||
|
||||
// Filter templates
|
||||
const filteredTemplates = useMemo(() => {
|
||||
let result = templates;
|
||||
@@ -127,6 +183,47 @@ const EmailTemplates: React.FC = () => {
|
||||
return result;
|
||||
}, [templates, selectedCategory, searchQuery]);
|
||||
|
||||
// Filter presets
|
||||
const filteredPresets = useMemo(() => {
|
||||
if (!presetsData?.presets) return [];
|
||||
|
||||
let allPresets: (TemplatePreset & { category: EmailTemplateCategory })[] = [];
|
||||
|
||||
// Flatten presets from all categories
|
||||
Object.entries(presetsData.presets).forEach(([category, presets]) => {
|
||||
allPresets.push(...presets.map(p => ({ ...p, category: category as EmailTemplateCategory })));
|
||||
});
|
||||
|
||||
// Filter by category
|
||||
if (selectedCategory !== 'ALL') {
|
||||
allPresets = allPresets.filter(p => p.category === selectedCategory);
|
||||
}
|
||||
|
||||
// Filter by style
|
||||
if (selectedStyle !== 'all') {
|
||||
allPresets = allPresets.filter(p => p.style === selectedStyle);
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
allPresets = allPresets.filter(p =>
|
||||
p.name.toLowerCase().includes(query) ||
|
||||
p.description.toLowerCase().includes(query) ||
|
||||
p.subject.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return allPresets;
|
||||
}, [presetsData, selectedCategory, selectedStyle, searchQuery]);
|
||||
|
||||
// Get available styles from all presets
|
||||
const availableStyles = useMemo(() => {
|
||||
if (!presetsData?.presets) return [];
|
||||
const allPresets = Object.values(presetsData.presets).flat();
|
||||
return Array.from(new Set(allPresets.map(p => p.style)));
|
||||
}, [presetsData]);
|
||||
|
||||
const handleEdit = (template: EmailTemplate) => {
|
||||
setEditingTemplate(template);
|
||||
setShowCreateModal(true);
|
||||
@@ -156,6 +253,55 @@ const EmailTemplates: React.FC = () => {
|
||||
handleFormClose();
|
||||
};
|
||||
|
||||
// Selection handlers
|
||||
const handleSelectTemplate = (templateId: string) => {
|
||||
setSelectedTemplates(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(templateId)) {
|
||||
next.delete(templateId);
|
||||
} else {
|
||||
next.add(templateId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedTemplates.size === filteredTemplates.length) {
|
||||
// Deselect all
|
||||
setSelectedTemplates(new Set());
|
||||
} else {
|
||||
// Select all filtered templates
|
||||
setSelectedTemplates(new Set(filteredTemplates.map(t => t.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDelete = () => {
|
||||
setShowBulkDeleteModal(true);
|
||||
};
|
||||
|
||||
const confirmBulkDelete = () => {
|
||||
bulkDeleteMutation.mutate(Array.from(selectedTemplates));
|
||||
};
|
||||
|
||||
const handleUsePreset = (preset: TemplatePreset & { category: EmailTemplateCategory }) => {
|
||||
// Create a new template from the preset
|
||||
setEditingTemplate({
|
||||
id: '',
|
||||
name: preset.name,
|
||||
description: preset.description,
|
||||
subject: preset.subject,
|
||||
htmlContent: preset.html_content,
|
||||
textContent: preset.text_content,
|
||||
category: preset.category,
|
||||
scope: 'BUSINESS',
|
||||
isDefault: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as EmailTemplate);
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
@@ -188,11 +334,14 @@ const EmailTemplates: React.FC = () => {
|
||||
{t('emailTemplates.title', 'Email Templates')}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('emailTemplates.description', 'Create and manage reusable email templates for your plugins')}
|
||||
{t('emailTemplates.description', 'Browse professional templates or create your own')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
onClick={() => {
|
||||
setEditingTemplate(null);
|
||||
setShowCreateModal(true);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
@@ -200,6 +349,34 @@ const EmailTemplates: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* View Tabs */}
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveView('browse')}
|
||||
className={`flex items-center gap-2 py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeView === 'browse'
|
||||
? 'border-brand-600 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'
|
||||
}`}
|
||||
>
|
||||
<Grid3x3 className="h-5 w-5" />
|
||||
{t('emailTemplates.browseTemplates', 'Browse Templates')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('my-templates')}
|
||||
className={`flex items-center gap-2 py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeView === 'my-templates'
|
||||
? 'border-brand-600 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'
|
||||
}`}
|
||||
>
|
||||
<List className="h-5 w-5" />
|
||||
{t('emailTemplates.myTemplates', 'My Templates')} ({templates.length})
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 shadow-sm">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
@@ -233,31 +410,137 @@ const EmailTemplates: React.FC = () => {
|
||||
<option value="OTHER">{t('emailTemplates.categoryOther', 'Other')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Style Filter (Browse Mode Only) */}
|
||||
{activeView === 'browse' && availableStyles.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedStyle('all')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedStyle === 'all'
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
All Styles
|
||||
</button>
|
||||
{availableStyles.map(style => (
|
||||
<button
|
||||
key={style}
|
||||
onClick={() => setSelectedStyle(style)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedStyle === style
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{styleIcons[style]}
|
||||
<span className="capitalize">{style}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active Filters Summary */}
|
||||
{(searchQuery || selectedCategory !== 'ALL') && (
|
||||
{(searchQuery || selectedCategory !== 'ALL' || selectedStyle !== 'all') && (
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('emailTemplates.showing', 'Showing')} {filteredTemplates.length} {t('emailTemplates.results', 'results')}
|
||||
{t('emailTemplates.showing', 'Showing')} {activeView === 'browse' ? filteredPresets.length : filteredTemplates.length} {t('emailTemplates.results', 'results')}
|
||||
</span>
|
||||
{(searchQuery || selectedCategory !== 'ALL') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setSelectedCategory('ALL');
|
||||
}}
|
||||
className="text-sm text-brand-600 dark:text-brand-400 hover:underline"
|
||||
>
|
||||
{t('common.clearAll', 'Clear all')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setSelectedCategory('ALL');
|
||||
setSelectedStyle('all');
|
||||
}}
|
||||
className="text-sm text-brand-600 dark:text-brand-400 hover:underline"
|
||||
>
|
||||
{t('common.clearAll', 'Clear all')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Templates List */}
|
||||
{filteredTemplates.length === 0 ? (
|
||||
{/* Browse Templates View */}
|
||||
{activeView === 'browse' && (
|
||||
presetsLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
|
||||
</div>
|
||||
) : filteredPresets.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<Sparkles className="h-12 w-12 mx-auto text-gray-400 mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
{searchQuery || selectedCategory !== 'ALL' || selectedStyle !== 'all'
|
||||
? t('emailTemplates.noPresets', 'No templates found matching your criteria')
|
||||
: t('emailTemplates.noPresetsAvailable', 'No templates available')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredPresets.map((preset, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden shadow-sm hover:shadow-lg transition-all cursor-pointer group"
|
||||
>
|
||||
{/* Preview */}
|
||||
<div className="h-56 bg-gray-50 dark:bg-gray-700 relative overflow-hidden">
|
||||
<div className="absolute inset-0">
|
||||
<iframe
|
||||
srcDoc={preset.html_content}
|
||||
className="w-full h-full pointer-events-none"
|
||||
title={preset.name}
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-end justify-center pb-6 gap-2">
|
||||
<button
|
||||
onClick={() => setPreviewPreset(preset)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white/95 dark:bg-gray-800/95 text-gray-900 dark:text-white rounded-lg text-sm font-medium hover:bg-white dark:hover:bg-gray-800"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUsePreset(preset)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Use Template
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h4 className="text-base font-semibold text-gray-900 dark:text-white line-clamp-1 flex-1">
|
||||
{preset.name}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${categoryColors[preset.category]}`}>
|
||||
{categoryIcons[preset.category]}
|
||||
{preset.category}
|
||||
</span>
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${styleColors[preset.style] || styleColors.professional}`}>
|
||||
{styleIcons[preset.style]}
|
||||
<span className="capitalize">{preset.style}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{preset.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* My Templates List */}
|
||||
{activeView === 'my-templates' && (filteredTemplates.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<Mail className="h-12 w-12 mx-auto text-gray-400 mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
@@ -277,15 +560,92 @@ const EmailTemplates: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Bulk Actions Bar */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Select All Checkbox */}
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
{selectedTemplates.size === filteredTemplates.length && filteredTemplates.length > 0 ? (
|
||||
<CheckSquare className="h-5 w-5 text-brand-600" />
|
||||
) : selectedTemplates.size > 0 ? (
|
||||
<div className="relative">
|
||||
<Square className="h-5 w-5" />
|
||||
<Minus className="h-3 w-3 absolute top-1 left-1 text-brand-600" />
|
||||
</div>
|
||||
) : (
|
||||
<Square className="h-5 w-5" />
|
||||
)}
|
||||
<span>
|
||||
{selectedTemplates.size === 0
|
||||
? t('emailTemplates.selectAll', 'Select All')
|
||||
: selectedTemplates.size === filteredTemplates.length
|
||||
? t('emailTemplates.deselectAll', 'Deselect All')
|
||||
: t('emailTemplates.selectedCount', '{{count}} selected', { count: selectedTemplates.size })}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bulk Delete Button */}
|
||||
{selectedTemplates.size > 0 && (
|
||||
<button
|
||||
onClick={handleBulkDelete}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{t('emailTemplates.deleteSelected', 'Delete Selected')} ({selectedTemplates.size})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filteredTemplates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow overflow-hidden"
|
||||
className={`bg-white dark:bg-gray-800 rounded-xl border shadow-sm hover:shadow-md transition-shadow overflow-hidden ${
|
||||
selectedTemplates.has(template.id)
|
||||
? 'border-brand-500 ring-2 ring-brand-500/20'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
{/* Template Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex">
|
||||
{/* Checkbox */}
|
||||
<div className="flex items-center justify-center w-12 border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50">
|
||||
<button
|
||||
onClick={() => handleSelectTemplate(template.id)}
|
||||
className="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
{selectedTemplates.has(template.id) ? (
|
||||
<CheckSquare className="h-5 w-5 text-brand-600" />
|
||||
) : (
|
||||
<Square className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="w-48 h-32 bg-gray-50 dark:bg-gray-700 relative overflow-hidden flex-shrink-0">
|
||||
{template.htmlContent ? (
|
||||
<div className="absolute inset-0">
|
||||
<iframe
|
||||
srcDoc={template.htmlContent}
|
||||
className="w-full h-full pointer-events-none"
|
||||
title={template.name}
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full h-full text-gray-400 text-xs">
|
||||
No HTML Content
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 flex-1">
|
||||
<div className="flex items-start justify-between">
|
||||
{/* Template Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{template.name}
|
||||
@@ -359,11 +719,80 @@ const EmailTemplates: React.FC = () => {
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div> {/* Closes flex items-start justify-between */}
|
||||
</div> {/* Closes p-6 flex-1 */}
|
||||
</div> {/* Closes flex */}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Preset Preview Modal */}
|
||||
{previewPreset && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{previewPreset.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{previewPreset.description}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPreviewPreset(null)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Subject
|
||||
</label>
|
||||
<div className="p-3 bg-gray-100 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white text-sm">
|
||||
{previewPreset.subject}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Preview
|
||||
</label>
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<iframe
|
||||
srcDoc={previewPreset.html_content}
|
||||
className="w-full h-96 bg-white"
|
||||
title="Template Preview"
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setPreviewPreset(null)}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors font-medium"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleUsePreset(previewPreset as TemplatePreset & { category: EmailTemplateCategory });
|
||||
setPreviewPreset(null);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Use This Template
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -528,6 +957,80 @@ const EmailTemplates: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk Delete Confirmation Modal */}
|
||||
{showBulkDeleteModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full overflow-hidden">
|
||||
{/* Modal Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-red-100 dark:bg-red-900/30 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('emailTemplates.confirmBulkDelete', 'Delete Templates')}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowBulkDeleteModal(false)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-6">
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{t('emailTemplates.bulkDeleteWarning', 'Are you sure you want to delete')} <span className="font-semibold text-gray-900 dark:text-white">{selectedTemplates.size} {t('emailTemplates.templates', 'templates')}</span>?
|
||||
</p>
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-3 max-h-32 overflow-y-auto">
|
||||
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
||||
{filteredTemplates
|
||||
.filter(t => selectedTemplates.has(t.id))
|
||||
.map(t => (
|
||||
<li key={t.id} className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-gray-400 rounded-full"></span>
|
||||
{t.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-4">
|
||||
{t('emailTemplates.deleteNote', 'This action cannot be undone. Plugins using these templates may no longer work correctly.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowBulkDeleteModal(false)}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors font-medium"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmBulkDelete}
|
||||
disabled={bulkDeleteMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||
>
|
||||
{bulkDeleteMutation.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{t('common.deleting', 'Deleting...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{t('emailTemplates.deleteAll', 'Delete All')} ({selectedTemplates.size})
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -783,6 +783,7 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
|
||||
can_create_plugins: false,
|
||||
can_white_label: false,
|
||||
can_api_access: false,
|
||||
can_use_masked_phone_numbers: false,
|
||||
},
|
||||
transaction_fee_percent: plan?.transaction_fee_percent
|
||||
? parseFloat(plan.transaction_fee_percent)
|
||||
@@ -790,6 +791,17 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
|
||||
transaction_fee_fixed: plan?.transaction_fee_fixed
|
||||
? parseFloat(plan.transaction_fee_fixed)
|
||||
: 0,
|
||||
// Communication pricing
|
||||
sms_enabled: plan?.sms_enabled ?? false,
|
||||
sms_price_per_message_cents: plan?.sms_price_per_message_cents ?? 3,
|
||||
masked_calling_enabled: plan?.masked_calling_enabled ?? false,
|
||||
masked_calling_price_per_minute_cents: plan?.masked_calling_price_per_minute_cents ?? 5,
|
||||
proxy_number_enabled: plan?.proxy_number_enabled ?? false,
|
||||
proxy_number_monthly_fee_cents: plan?.proxy_number_monthly_fee_cents ?? 200,
|
||||
// Default credit settings
|
||||
default_auto_reload_enabled: plan?.default_auto_reload_enabled ?? false,
|
||||
default_auto_reload_threshold_cents: plan?.default_auto_reload_threshold_cents ?? 1000,
|
||||
default_auto_reload_amount_cents: plan?.default_auto_reload_amount_cents ?? 2500,
|
||||
is_active: plan?.is_active ?? true,
|
||||
is_public: plan?.is_public ?? true,
|
||||
is_most_popular: plan?.is_most_popular ?? false,
|
||||
@@ -888,8 +900,8 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
|
||||
}
|
||||
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"
|
||||
>
|
||||
<option value="base">Base Tier</option>
|
||||
<option value="addon">Add-on</option>
|
||||
<option value="base" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Base Tier</option>
|
||||
<option value="addon" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Add-on</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -955,14 +967,51 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, business_tier: 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"
|
||||
>
|
||||
<option value="">None</option>
|
||||
<option value="Free">Free</option>
|
||||
<option value="Professional">Professional</option>
|
||||
<option value="Business">Business</option>
|
||||
<option value="Enterprise">Enterprise</option>
|
||||
<option value="" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">None (Add-on)</option>
|
||||
<option value="Free" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Free</option>
|
||||
<option value="Starter" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Starter</option>
|
||||
<option value="Professional" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Professional</option>
|
||||
<option value="Business" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Business</option>
|
||||
<option value="Enterprise" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Enterprise</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Trial Days
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.limits?.trial_days ?? 0}
|
||||
onChange={(e) => setFormData((prev) => ({
|
||||
...prev,
|
||||
limits: { ...prev.limits, trial_days: parseInt(e.target.value) || 0 }
|
||||
}))}
|
||||
placeholder="0"
|
||||
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"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Days of free trial</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Display Order
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.limits?.display_order ?? 0}
|
||||
onChange={(e) => setFormData((prev) => ({
|
||||
...prev,
|
||||
limits: { ...prev.limits, display_order: parseInt(e.target.value) || 0 }
|
||||
}))}
|
||||
placeholder="0"
|
||||
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"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Order on pricing page</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
@@ -1001,20 +1050,220 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Communication Pricing */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white border-b pb-2 dark:border-gray-700">
|
||||
Communication Pricing
|
||||
</h3>
|
||||
|
||||
{/* SMS Settings */}
|
||||
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">SMS Reminders</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Allow businesses on this tier to send SMS reminders</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.sms_enabled || false}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, sms_enabled: e.target.checked }))}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
{formData.sms_enabled && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Price per SMS (cents)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={formData.sms_price_per_message_cents || 0}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
sms_price_per_message_cents: parseInt(e.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
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"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Current: ${((formData.sms_price_per_message_cents || 0) / 100).toFixed(2)} per message
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Masked Calling Settings */}
|
||||
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Masked Calling</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Allow anonymous calls between customers and staff</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.masked_calling_enabled || false}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, masked_calling_enabled: e.target.checked }))}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
{formData.masked_calling_enabled && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Price per minute (cents)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={formData.masked_calling_price_per_minute_cents || 0}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
masked_calling_price_per_minute_cents: parseInt(e.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
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"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Current: ${((formData.masked_calling_price_per_minute_cents || 0) / 100).toFixed(2)} per minute
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Proxy Phone Number Settings */}
|
||||
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Proxy Phone Numbers</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Dedicated phone numbers for masked communication</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.proxy_number_enabled || false}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, proxy_number_enabled: e.target.checked }))}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
{formData.proxy_number_enabled && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Monthly fee per number (cents)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={formData.proxy_number_monthly_fee_cents || 0}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
proxy_number_monthly_fee_cents: parseInt(e.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
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"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Current: ${((formData.proxy_number_monthly_fee_cents || 0) / 100).toFixed(2)} per month
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Default Credit Settings */}
|
||||
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">Default Credit Settings</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||
Default auto-reload settings for new businesses on this tier
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.default_auto_reload_enabled || false}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, default_auto_reload_enabled: e.target.checked }))}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Auto-reload enabled by default</span>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Reload threshold (cents)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="100"
|
||||
value={formData.default_auto_reload_threshold_cents || 0}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
default_auto_reload_threshold_cents: parseInt(e.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
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"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Reload when balance falls below ${((formData.default_auto_reload_threshold_cents || 0) / 100).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Reload amount (cents)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="100"
|
||||
value={formData.default_auto_reload_amount_cents || 0}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
default_auto_reload_amount_cents: parseInt(e.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
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"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Add ${((formData.default_auto_reload_amount_cents || 0) / 100).toFixed(2)} to balance
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Limits Configuration */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white border-b pb-2 dark:border-gray-700">
|
||||
Limits Configuration
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Use -1 for unlimited. These limits control what businesses on this plan can create.
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Max Users
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.limits?.max_users || 0}
|
||||
min="-1"
|
||||
value={formData.limits?.max_users ?? 0}
|
||||
onChange={(e) => handleLimitChange('max_users', 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"
|
||||
/>
|
||||
@@ -1025,12 +1274,24 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.limits?.max_resources || 0}
|
||||
min="-1"
|
||||
value={formData.limits?.max_resources ?? 0}
|
||||
onChange={(e) => handleLimitChange('max_resources', 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Max Services
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="-1"
|
||||
value={formData.limits?.max_services ?? 0}
|
||||
onChange={(e) => handleLimitChange('max_services', 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Max Appointments / Month
|
||||
@@ -1038,19 +1299,31 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
|
||||
<input
|
||||
type="number"
|
||||
min="-1"
|
||||
value={formData.limits?.max_appointments || 0}
|
||||
value={formData.limits?.max_appointments ?? 0}
|
||||
onChange={(e) => handleLimitChange('max_appointments', 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Max Email Templates
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="-1"
|
||||
value={formData.limits?.max_email_templates ?? 0}
|
||||
onChange={(e) => handleLimitChange('max_email_templates', 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Max Automated Tasks
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.limits?.max_automated_tasks || 0}
|
||||
min="-1"
|
||||
value={formData.limits?.max_automated_tasks ?? 0}
|
||||
onChange={(e) => handleLimitChange('max_automated_tasks', 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"
|
||||
/>
|
||||
@@ -1063,79 +1336,205 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white border-b pb-2 dark:border-gray-700">
|
||||
Features & Permissions
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_accept_payments || false}
|
||||
onChange={(e) => handlePermissionChange('can_accept_payments', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Stripe Payments</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.sms_reminders || false}
|
||||
onChange={(e) => handlePermissionChange('sms_reminders', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">SMS Reminders</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.advanced_reporting || false}
|
||||
onChange={(e) => handlePermissionChange('advanced_reporting', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Advanced Reporting</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.priority_support || false}
|
||||
onChange={(e) => handlePermissionChange('priority_support', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Priority Email Support</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_use_custom_domain || false}
|
||||
onChange={(e) => handlePermissionChange('can_use_custom_domain', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Custom Domains</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_create_plugins || false}
|
||||
onChange={(e) => handlePermissionChange('can_create_plugins', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Create Plugins</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_white_label || false}
|
||||
onChange={(e) => handlePermissionChange('can_white_label', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">White Labelling</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_api_access || false}
|
||||
onChange={(e) => handlePermissionChange('can_api_access', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">API Access</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Control which features are available to businesses on this plan.
|
||||
</p>
|
||||
|
||||
{/* Payments & Revenue */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Payments & Revenue</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_accept_payments || false}
|
||||
onChange={(e) => handlePermissionChange('can_accept_payments', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Online Payments</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_process_refunds || false}
|
||||
onChange={(e) => handlePermissionChange('can_process_refunds', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Process Refunds</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_create_packages || false}
|
||||
onChange={(e) => handlePermissionChange('can_create_packages', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Service Packages</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Communication */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Communication</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.sms_reminders || false}
|
||||
onChange={(e) => handlePermissionChange('sms_reminders', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">SMS Reminders</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_use_masked_phone_numbers || false}
|
||||
onChange={(e) => handlePermissionChange('can_use_masked_phone_numbers', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Masked Calling</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_use_email_templates || false}
|
||||
onChange={(e) => handlePermissionChange('can_use_email_templates', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Email Templates</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customization */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Customization</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_customize_booking_page || false}
|
||||
onChange={(e) => handlePermissionChange('can_customize_booking_page', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Custom Booking Page</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_use_custom_domain || false}
|
||||
onChange={(e) => handlePermissionChange('can_use_custom_domain', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Custom Domains</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_white_label || false}
|
||||
onChange={(e) => handlePermissionChange('can_white_label', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">White Labelling</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Features */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Advanced Features</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.advanced_reporting || false}
|
||||
onChange={(e) => handlePermissionChange('advanced_reporting', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Advanced Analytics</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_api_access || false}
|
||||
onChange={(e) => handlePermissionChange('can_api_access', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">API Access</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_create_plugins || false}
|
||||
onChange={(e) => handlePermissionChange('can_create_plugins', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Create Plugins</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_export_data || false}
|
||||
onChange={(e) => handlePermissionChange('can_export_data', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Data Export</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_use_webhooks || false}
|
||||
onChange={(e) => handlePermissionChange('can_use_webhooks', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Webhooks</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.calendar_sync || false}
|
||||
onChange={(e) => handlePermissionChange('calendar_sync', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Calendar Sync</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Support & Enterprise */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Support & Enterprise</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.priority_support || false}
|
||||
onChange={(e) => handlePermissionChange('priority_support', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Priority Support</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.dedicated_support || false}
|
||||
onChange={(e) => handlePermissionChange('dedicated_support', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Dedicated Support</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.sso_enabled || false}
|
||||
onChange={(e) => handlePermissionChange('sso_enabled', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">SSO / SAML</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
52
frontend/src/pages/settings/ApiSettings.tsx
Normal file
52
frontend/src/pages/settings/ApiSettings.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* API Settings Page
|
||||
*
|
||||
* Manage API tokens and webhooks for third-party integrations.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { Key } from 'lucide-react';
|
||||
import { Business, User } from '../../types';
|
||||
import ApiTokensSection from '../../components/ApiTokensSection';
|
||||
|
||||
const ApiSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { business, user } = useOutletContext<{
|
||||
business: Business;
|
||||
user: User;
|
||||
}>();
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
|
||||
if (!isOwner) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Only the business owner can access these settings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<Key className="text-amber-500" />
|
||||
{t('settings.api.title', 'API & Webhooks')}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
Manage API access tokens and configure webhooks for integrations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* API Tokens Section */}
|
||||
<ApiTokensSection />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiSettings;
|
||||
427
frontend/src/pages/settings/AuthenticationSettings.tsx
Normal file
427
frontend/src/pages/settings/AuthenticationSettings.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* Authentication Settings Page
|
||||
*
|
||||
* Configure OAuth providers, social login, and custom credentials.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { Lock, Users, Key, Save, Check, AlertCircle, Eye, EyeOff } from 'lucide-react';
|
||||
import { Business, User } from '../../types';
|
||||
import { useBusinessOAuthSettings, useUpdateBusinessOAuthSettings } from '../../hooks/useBusinessOAuth';
|
||||
import { useBusinessOAuthCredentials, useUpdateBusinessOAuthCredentials } from '../../hooks/useBusinessOAuthCredentials';
|
||||
|
||||
// Provider display names and icons
|
||||
const providerInfo: Record<string, { name: string; icon: string }> = {
|
||||
google: { name: 'Google', icon: '🔍' },
|
||||
apple: { name: 'Apple', icon: '🍎' },
|
||||
facebook: { name: 'Facebook', icon: '📘' },
|
||||
linkedin: { name: 'LinkedIn', icon: '💼' },
|
||||
microsoft: { name: 'Microsoft', icon: '🪟' },
|
||||
twitter: { name: 'X (Twitter)', icon: '🐦' },
|
||||
twitch: { name: 'Twitch', icon: '🎮' },
|
||||
};
|
||||
|
||||
const AuthenticationSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { business, user } = useOutletContext<{
|
||||
business: Business;
|
||||
user: User;
|
||||
}>();
|
||||
|
||||
// OAuth Settings hooks
|
||||
const { data: oauthData, isLoading: oauthLoading } = useBusinessOAuthSettings();
|
||||
const updateOAuthMutation = useUpdateBusinessOAuthSettings();
|
||||
const [oauthSettings, setOAuthSettings] = useState({
|
||||
enabledProviders: [] as string[],
|
||||
allowRegistration: false,
|
||||
autoLinkByEmail: true,
|
||||
useCustomCredentials: false,
|
||||
});
|
||||
|
||||
// OAuth Credentials hooks
|
||||
const { data: oauthCredentials, isLoading: credentialsLoading } = useBusinessOAuthCredentials();
|
||||
const updateCredentialsMutation = useUpdateBusinessOAuthCredentials();
|
||||
const [useCustomCredentials, setUseCustomCredentials] = useState(false);
|
||||
const [credentials, setCredentials] = useState<any>({
|
||||
google: { client_id: '', client_secret: '' },
|
||||
apple: { client_id: '', client_secret: '', team_id: '', key_id: '' },
|
||||
facebook: { client_id: '', client_secret: '' },
|
||||
linkedin: { client_id: '', client_secret: '' },
|
||||
microsoft: { client_id: '', client_secret: '', tenant_id: '' },
|
||||
twitter: { client_id: '', client_secret: '' },
|
||||
twitch: { client_id: '', client_secret: '' },
|
||||
});
|
||||
const [showSecrets, setShowSecrets] = useState<{ [key: string]: boolean }>({});
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
|
||||
// Update OAuth settings when data loads
|
||||
useEffect(() => {
|
||||
if (oauthData?.settings) {
|
||||
setOAuthSettings(oauthData.settings);
|
||||
}
|
||||
}, [oauthData]);
|
||||
|
||||
// Update OAuth credentials when data loads
|
||||
useEffect(() => {
|
||||
if (oauthCredentials) {
|
||||
setUseCustomCredentials(oauthCredentials.useCustomCredentials || false);
|
||||
const creds = oauthCredentials.credentials || {};
|
||||
setCredentials({
|
||||
google: creds.google || { client_id: '', client_secret: '' },
|
||||
apple: creds.apple || { client_id: '', client_secret: '', team_id: '', key_id: '' },
|
||||
facebook: creds.facebook || { client_id: '', client_secret: '' },
|
||||
linkedin: creds.linkedin || { client_id: '', client_secret: '' },
|
||||
microsoft: creds.microsoft || { client_id: '', client_secret: '', tenant_id: '' },
|
||||
twitter: creds.twitter || { client_id: '', client_secret: '' },
|
||||
twitch: creds.twitch || { client_id: '', client_secret: '' },
|
||||
});
|
||||
}
|
||||
}, [oauthCredentials]);
|
||||
|
||||
// Auto-hide toast
|
||||
useEffect(() => {
|
||||
if (showToast) {
|
||||
const timer = setTimeout(() => setShowToast(false), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
const handleOAuthSave = () => {
|
||||
updateOAuthMutation.mutate(oauthSettings, {
|
||||
onSuccess: () => {
|
||||
setShowToast(true);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const toggleProvider = (provider: string) => {
|
||||
setOAuthSettings((prev) => {
|
||||
const isEnabled = prev.enabledProviders.includes(provider);
|
||||
return {
|
||||
...prev,
|
||||
enabledProviders: isEnabled
|
||||
? prev.enabledProviders.filter((p) => p !== provider)
|
||||
: [...prev.enabledProviders, provider],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleCredentialsSave = () => {
|
||||
const updateData: any = {
|
||||
use_custom_credentials: useCustomCredentials,
|
||||
};
|
||||
|
||||
if (useCustomCredentials) {
|
||||
Object.entries(credentials).forEach(([provider, creds]: [string, any]) => {
|
||||
if (creds.client_id || creds.client_secret) {
|
||||
updateData[provider] = creds;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateCredentialsMutation.mutate(updateData, {
|
||||
onSuccess: () => {
|
||||
setShowToast(true);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateCredential = (provider: string, field: string, value: string) => {
|
||||
setCredentials((prev: any) => ({
|
||||
...prev,
|
||||
[provider]: {
|
||||
...prev[provider],
|
||||
[field]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleShowSecret = (key: string) => {
|
||||
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
if (!isOwner) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Only the business owner can access these settings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<Lock className="text-purple-500" />
|
||||
{t('settings.authentication.title', 'Authentication')}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
Configure social login and OAuth providers for customer sign-in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* OAuth & Social Login */}
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Users size={20} className="text-indigo-500" /> Social Login
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Choose which providers customers can use to sign in</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleOAuthSave}
|
||||
disabled={oauthLoading || updateOAuthMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors shadow-sm font-medium text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Save size={16} />
|
||||
{updateOAuthMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{oauthLoading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
|
||||
</div>
|
||||
) : oauthData?.availableProviders && oauthData.availableProviders.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{oauthData.availableProviders.map((provider: any) => {
|
||||
const isEnabled = oauthSettings.enabledProviders.includes(provider.id);
|
||||
const info = providerInfo[provider.id] || { name: provider.name, icon: '🔐' };
|
||||
return (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
onClick={() => toggleProvider(provider.id)}
|
||||
className={`relative p-3 rounded-lg border-2 transition-all text-left ${
|
||||
isEnabled
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
|
||||
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
{isEnabled && (
|
||||
<div className="absolute top-1.5 right-1.5 w-4 h-4 bg-brand-500 rounded-full flex items-center justify-center">
|
||||
<Check size={10} className="text-white" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{info.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">{info.name}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Allow OAuth Registration</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">New customers can create accounts via OAuth</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`${oauthSettings.allowRegistration ? 'bg-brand-600' : 'bg-gray-200 dark:bg-gray-600'} relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-brand-500`}
|
||||
role="switch"
|
||||
onClick={() => setOAuthSettings((prev) => ({ ...prev, allowRegistration: !prev.allowRegistration }))}
|
||||
>
|
||||
<span className={`${oauthSettings.allowRegistration ? 'translate-x-4' : 'translate-x-0'} pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Auto-link by Email</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Link OAuth accounts to existing accounts by email</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`${oauthSettings.autoLinkByEmail ? 'bg-brand-600' : 'bg-gray-200 dark:bg-gray-600'} relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-brand-500`}
|
||||
role="switch"
|
||||
onClick={() => setOAuthSettings((prev) => ({ ...prev, autoLinkByEmail: !prev.autoLinkByEmail }))}
|
||||
>
|
||||
<span className={`${oauthSettings.autoLinkByEmail ? 'translate-x-4' : 'translate-x-0'} pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle size={18} className="text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-amber-800 dark:text-amber-300 text-sm">No OAuth Providers Available</p>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400 mt-1">
|
||||
Contact your platform administrator to enable OAuth providers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Custom OAuth Credentials - Only shown if platform has enabled this permission */}
|
||||
{business.canManageOAuthCredentials && (
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Key size={20} className="text-purple-500" />
|
||||
Custom OAuth Credentials
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Use your own OAuth app credentials for complete branding control
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCredentialsSave}
|
||||
disabled={credentialsLoading || updateCredentialsMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors shadow-sm font-medium text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Save size={16} />
|
||||
{updateCredentialsMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{credentialsLoading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Toggle Custom Credentials */}
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Use Custom Credentials</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{useCustomCredentials ? 'Using your custom OAuth credentials' : 'Using platform shared credentials'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`${useCustomCredentials ? 'bg-brand-600' : 'bg-gray-200 dark:bg-gray-600'} relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-brand-500`}
|
||||
role="switch"
|
||||
onClick={() => setUseCustomCredentials(!useCustomCredentials)}
|
||||
>
|
||||
<span className={`${useCustomCredentials ? 'translate-x-4' : 'translate-x-0'} pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{useCustomCredentials && (
|
||||
<div className="space-y-3">
|
||||
{(['google', 'apple', 'facebook', 'linkedin', 'microsoft', 'twitter', 'twitch'] as const).map((provider) => {
|
||||
const info = providerInfo[provider];
|
||||
const providerCreds = credentials[provider];
|
||||
const hasCredentials = providerCreds.client_id || providerCreds.client_secret;
|
||||
|
||||
return (
|
||||
<details key={provider} className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<summary className="flex items-center justify-between p-3 cursor-pointer bg-gray-50 dark:bg-gray-900/50 hover:bg-gray-100 dark:hover:bg-gray-900">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{info.icon}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white text-sm">{info.name}</span>
|
||||
{hasCredentials && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded">
|
||||
Configured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</summary>
|
||||
<div className="p-3 space-y-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Client ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={providerCreds.client_id}
|
||||
onChange={(e) => updateCredential(provider, 'client_id', e.target.value)}
|
||||
placeholder={`Enter ${info.name} Client ID`}
|
||||
className="w-full px-3 py-1.5 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 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Client Secret</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSecrets[`${provider}_secret`] ? 'text' : 'password'}
|
||||
value={providerCreds.client_secret}
|
||||
onChange={(e) => updateCredential(provider, 'client_secret', e.target.value)}
|
||||
placeholder={`Enter ${info.name} Client Secret`}
|
||||
className="w-full px-3 py-1.5 pr-8 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 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleShowSecret(`${provider}_secret`)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
{showSecrets[`${provider}_secret`] ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Provider-specific fields */}
|
||||
{provider === 'apple' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Team ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={providerCreds.team_id || ''}
|
||||
onChange={(e) => updateCredential(provider, 'team_id', e.target.value)}
|
||||
placeholder="Enter Apple Team ID"
|
||||
className="w-full px-3 py-1.5 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 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Key ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={providerCreds.key_id || ''}
|
||||
onChange={(e) => updateCredential(provider, 'key_id', e.target.value)}
|
||||
placeholder="Enter Apple Key ID"
|
||||
className="w-full px-3 py-1.5 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 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{provider === 'microsoft' && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Tenant ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={providerCreds.tenant_id || ''}
|
||||
onChange={(e) => updateCredential(provider, 'tenant_id', e.target.value)}
|
||||
placeholder="Enter Microsoft Tenant ID (or 'common')"
|
||||
className="w-full px-3 py-1.5 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 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Toast */}
|
||||
{showToast && (
|
||||
<div className="fixed bottom-4 right-4 flex items-center gap-2 px-4 py-3 bg-green-500 text-white rounded-lg shadow-lg">
|
||||
<Check size={18} />
|
||||
Changes saved successfully
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthenticationSettings;
|
||||
288
frontend/src/pages/settings/BillingSettings.tsx
Normal file
288
frontend/src/pages/settings/BillingSettings.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Billing Settings Page
|
||||
*
|
||||
* Manage subscription plan, payment methods, and view invoices.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import {
|
||||
CreditCard, Crown, Plus, Trash2, Check, AlertCircle,
|
||||
FileText, ExternalLink, Wallet, Star
|
||||
} from 'lucide-react';
|
||||
import { Business, User } from '../../types';
|
||||
|
||||
// Plan details for display
|
||||
const planDetails: Record<string, { name: string; price: string; features: string[] }> = {
|
||||
Free: {
|
||||
name: 'Free',
|
||||
price: '$0/month',
|
||||
features: ['Up to 10 resources', 'Basic scheduling', 'Email support'],
|
||||
},
|
||||
Starter: {
|
||||
name: 'Starter',
|
||||
price: '$29/month',
|
||||
features: ['Up to 50 resources', 'Custom branding', 'Priority email support', 'API access'],
|
||||
},
|
||||
Professional: {
|
||||
name: 'Professional',
|
||||
price: '$79/month',
|
||||
features: ['Unlimited resources', 'Custom domains', 'Phone support', 'Advanced analytics', 'Team permissions'],
|
||||
},
|
||||
Enterprise: {
|
||||
name: 'Enterprise',
|
||||
price: 'Custom',
|
||||
features: ['All Professional features', 'Dedicated account manager', 'Custom integrations', 'SLA guarantee'],
|
||||
},
|
||||
};
|
||||
|
||||
const BillingSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { business, user } = useOutletContext<{
|
||||
business: Business;
|
||||
user: User;
|
||||
}>();
|
||||
|
||||
const [showAddCard, setShowAddCard] = useState(false);
|
||||
const isOwner = user.role === 'owner';
|
||||
|
||||
// Mock payment methods - in a real app, these would come from Stripe
|
||||
const [paymentMethods] = useState([
|
||||
{ id: 'pm_1', brand: 'visa', last4: '4242', expMonth: 12, expYear: 2025, isDefault: true },
|
||||
]);
|
||||
|
||||
const currentPlan = planDetails[business.plan || 'Free'] || planDetails.Free;
|
||||
|
||||
if (!isOwner) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Only the business owner can access these settings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<CreditCard className="text-emerald-500" />
|
||||
{t('settings.billing.title', 'Plan & Billing')}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
Manage your subscription, payment methods, and billing history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Current Plan */}
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Crown size={20} className="text-amber-500" />
|
||||
Current Plan
|
||||
</h3>
|
||||
<div className="mt-4">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{currentPlan.name}
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{currentPlan.price}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="mt-4 space-y-2">
|
||||
{currentPlan.features.map((feature, idx) => (
|
||||
<li key={idx} className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<Check size={16} className="text-green-500" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<button className="px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-indigo-500 to-purple-500 rounded-lg hover:from-indigo-600 hover:to-purple-600 transition-all flex items-center gap-2">
|
||||
<Crown size={16} />
|
||||
Upgrade Plan
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Wallet / Credits Summary */}
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2 mb-4">
|
||||
<Wallet size={20} className="text-blue-500" />
|
||||
Wallet
|
||||
</h3>
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Your communication credits are managed in the SMS & Calling settings.
|
||||
</p>
|
||||
<a
|
||||
href="/settings/sms-calling"
|
||||
className="inline-flex items-center gap-1 mt-2 text-sm font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400"
|
||||
>
|
||||
Manage Credits
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Payment Methods */}
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<CreditCard size={20} className="text-purple-500" />
|
||||
Payment Methods
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Manage your payment methods for subscriptions and credits
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddCard(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-brand-600 border border-brand-600 rounded-lg hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Card
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{paymentMethods.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<CreditCard size={40} className="mx-auto mb-2 opacity-30" />
|
||||
<p>No payment methods added yet.</p>
|
||||
<p className="text-sm mt-1">Add a card to enable auto-reload and subscriptions.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{paymentMethods.map((method) => (
|
||||
<div
|
||||
key={method.id}
|
||||
className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-8 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-600 flex items-center justify-center">
|
||||
{method.brand === 'visa' && (
|
||||
<span className="text-blue-600 font-bold text-sm">VISA</span>
|
||||
)}
|
||||
{method.brand === 'mastercard' && (
|
||||
<span className="text-orange-600 font-bold text-sm">MC</span>
|
||||
)}
|
||||
{method.brand === 'amex' && (
|
||||
<span className="text-blue-800 font-bold text-sm">AMEX</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
•••• {method.last4}
|
||||
</span>
|
||||
{method.isDefault && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-brand-100 text-brand-800 dark:bg-brand-900/30 dark:text-brand-300 rounded flex items-center gap-1">
|
||||
<Star size={10} className="fill-current" /> Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Expires {method.expMonth}/{method.expYear}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!method.isDefault && (
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
||||
>
|
||||
Set Default
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Billing History */}
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<FileText size={20} className="text-gray-500" />
|
||||
Billing History
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
View and download your invoices
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<FileText size={40} className="mx-auto mb-2 opacity-30" />
|
||||
<p>No invoices yet.</p>
|
||||
<p className="text-sm mt-1">Your invoices will appear here after your first payment.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Notice for Free Plan */}
|
||||
{business.plan === 'Free' && (
|
||||
<section className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 p-6 rounded-xl border border-amber-200 dark:border-amber-800">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-amber-100 dark:bg-amber-900/40 rounded-lg">
|
||||
<AlertCircle size={24} className="text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">You're on the Free Plan</h4>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
||||
Upgrade to unlock custom domains, advanced features, and priority support.
|
||||
</p>
|
||||
<button className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-indigo-500 to-purple-500 rounded-lg hover:from-indigo-600 hover:to-purple-600 transition-all">
|
||||
<Crown size={16} /> View Plans
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Add Card Modal Placeholder */}
|
||||
{showAddCard && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Add Payment Method
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
This will open a secure Stripe checkout to add your card.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowAddCard(false)}
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
Continue to Stripe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BillingSettings;
|
||||
321
frontend/src/pages/settings/BrandingSettings.tsx
Normal file
321
frontend/src/pages/settings/BrandingSettings.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Branding Settings Page
|
||||
*
|
||||
* Logo uploads, colors, and display preferences.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { Palette, Save, Check, Upload, X, Image as ImageIcon } from 'lucide-react';
|
||||
import { Business, User } from '../../types';
|
||||
|
||||
// Color palette options
|
||||
const colorPalettes = [
|
||||
{ name: 'Ocean Blue', primary: '#2563eb', secondary: '#0ea5e9' },
|
||||
{ name: 'Sky Blue', primary: '#0ea5e9', secondary: '#38bdf8' },
|
||||
{ name: 'Mint Green', primary: '#10b981', secondary: '#34d399' },
|
||||
{ name: 'Coral Reef', primary: '#f97316', secondary: '#fb923c' },
|
||||
{ name: 'Lavender', primary: '#a78bfa', secondary: '#c4b5fd' },
|
||||
{ name: 'Rose Pink', primary: '#ec4899', secondary: '#f472b6' },
|
||||
{ name: 'Forest Green', primary: '#059669', secondary: '#10b981' },
|
||||
{ name: 'Royal Purple', primary: '#7c3aed', secondary: '#a78bfa' },
|
||||
{ name: 'Slate Gray', primary: '#475569', secondary: '#64748b' },
|
||||
{ name: 'Crimson Red', primary: '#dc2626', secondary: '#ef4444' },
|
||||
];
|
||||
|
||||
const BrandingSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { business, updateBusiness, user } = useOutletContext<{
|
||||
business: Business;
|
||||
updateBusiness: (updates: Partial<Business>) => void;
|
||||
user: User;
|
||||
}>();
|
||||
|
||||
const [formState, setFormState] = useState({
|
||||
logoUrl: business.logoUrl,
|
||||
emailLogoUrl: business.emailLogoUrl,
|
||||
logoDisplayMode: business.logoDisplayMode || 'text-only',
|
||||
primaryColor: business.primaryColor,
|
||||
secondaryColor: business.secondaryColor || business.primaryColor,
|
||||
});
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
await updateBusiness(formState);
|
||||
setShowToast(true);
|
||||
setTimeout(() => setShowToast(false), 3000);
|
||||
};
|
||||
|
||||
const selectPalette = (primary: string, secondary: string) => {
|
||||
setFormState(prev => ({ ...prev, primaryColor: primary, secondaryColor: secondary }));
|
||||
};
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
|
||||
if (!isOwner) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Only the business owner can access these settings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<Palette className="text-purple-500" />
|
||||
{t('settings.branding.title', 'Branding')}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
Customize your business appearance with logos and colors.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Logo Section */}
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Brand Logos
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Upload your logos for different purposes. PNG with transparent background recommended.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Website Logo */}
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h5 className="font-medium text-gray-900 dark:text-white mb-2">Website Logo</h5>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||
Used in sidebar and customer-facing pages. Recommended: 500x500px
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
{formState.logoUrl ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={formState.logoUrl}
|
||||
alt="Logo"
|
||||
className="w-20 h-20 object-contain border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 p-2"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setFormState(prev => ({ ...prev, logoUrl: undefined }))}
|
||||
className="absolute -top-2 -right-2 bg-red-500 hover:bg-red-600 text-white rounded-full p-1"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-20 h-20 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg flex items-center justify-center text-gray-400">
|
||||
<ImageIcon size={24} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
id="logo-upload"
|
||||
className="hidden"
|
||||
accept="image/png,image/jpeg,image/svg+xml"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setFormState(prev => ({ ...prev, logoUrl: reader.result as string }));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="logo-upload"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 cursor-pointer text-sm font-medium"
|
||||
>
|
||||
<Upload size={16} />
|
||||
{formState.logoUrl ? 'Change' : 'Upload'}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display Mode */}
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Display Mode
|
||||
</label>
|
||||
<select
|
||||
value={formState.logoDisplayMode}
|
||||
onChange={(e) => setFormState(prev => ({ ...prev, logoDisplayMode: e.target.value as any }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg text-sm"
|
||||
>
|
||||
<option value="text-only">Text Only</option>
|
||||
<option value="logo-only">Logo Only</option>
|
||||
<option value="logo-and-text">Logo and Text</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Logo */}
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h5 className="font-medium text-gray-900 dark:text-white mb-2">Email Logo</h5>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||
Used in email notifications. Recommended: 600x200px wide
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
{formState.emailLogoUrl ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={formState.emailLogoUrl}
|
||||
alt="Email Logo"
|
||||
className="w-32 h-12 object-contain border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 p-2"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setFormState(prev => ({ ...prev, emailLogoUrl: undefined }))}
|
||||
className="absolute -top-2 -right-2 bg-red-500 hover:bg-red-600 text-white rounded-full p-1"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-32 h-12 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg flex items-center justify-center text-gray-400">
|
||||
<ImageIcon size={20} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
id="email-logo-upload"
|
||||
className="hidden"
|
||||
accept="image/png,image/jpeg,image/svg+xml"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setFormState(prev => ({ ...prev, emailLogoUrl: reader.result as string }));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="email-logo-upload"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 cursor-pointer text-sm font-medium"
|
||||
>
|
||||
<Upload size={16} />
|
||||
{formState.emailLogoUrl ? 'Change' : 'Upload'}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Colors Section */}
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Brand Colors
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Choose a color palette or customize your own colors.
|
||||
</p>
|
||||
|
||||
{/* Palette Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3 mb-6">
|
||||
{colorPalettes.map((palette) => (
|
||||
<button
|
||||
key={palette.name}
|
||||
onClick={() => selectPalette(palette.primary, palette.secondary)}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
formState.primaryColor === palette.primary && formState.secondaryColor === palette.secondary
|
||||
? 'border-gray-900 dark:border-white ring-2 ring-offset-2 ring-gray-900 dark:ring-white'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="h-8 rounded-md mb-2"
|
||||
style={{ background: `linear-gradient(to right, ${palette.primary}, ${palette.secondary})` }}
|
||||
/>
|
||||
<p className="text-xs font-medium text-gray-700 dark:text-gray-300 text-center truncate">
|
||||
{palette.name}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom Colors */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Primary Color
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={formState.primaryColor}
|
||||
onChange={(e) => setFormState(prev => ({ ...prev, primaryColor: e.target.value }))}
|
||||
className="w-10 h-10 rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formState.primaryColor}
|
||||
onChange={(e) => setFormState(prev => ({ ...prev, primaryColor: e.target.value }))}
|
||||
className="w-24 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Secondary Color
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={formState.secondaryColor}
|
||||
onChange={(e) => setFormState(prev => ({ ...prev, secondaryColor: e.target.value }))}
|
||||
className="w-10 h-10 rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formState.secondaryColor}
|
||||
onChange={(e) => setFormState(prev => ({ ...prev, secondaryColor: e.target.value }))}
|
||||
className="w-24 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Preview
|
||||
</label>
|
||||
<div
|
||||
className="h-10 rounded-lg"
|
||||
style={{ background: `linear-gradient(to right, ${formState.primaryColor}, ${formState.secondaryColor})` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-brand-600 hover:bg-brand-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Save size={18} />
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
{showToast && (
|
||||
<div className="fixed bottom-4 right-4 flex items-center gap-2 px-4 py-3 bg-green-500 text-white rounded-lg shadow-lg">
|
||||
<Check size={18} />
|
||||
Changes saved successfully
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrandingSettings;
|
||||
727
frontend/src/pages/settings/CommunicationSettings.tsx
Normal file
727
frontend/src/pages/settings/CommunicationSettings.tsx
Normal file
@@ -0,0 +1,727 @@
|
||||
/**
|
||||
* Communication Settings Page
|
||||
*
|
||||
* Manage SMS and calling credits, auto-reload settings, and view transaction history.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import {
|
||||
Phone, Wallet, RefreshCw, Check, CreditCard, Loader2,
|
||||
ArrowUpRight, ArrowDownRight, Clock, Save, MessageSquare
|
||||
} from 'lucide-react';
|
||||
import { Business, User } from '../../types';
|
||||
import {
|
||||
useCommunicationCredits,
|
||||
useCreditTransactions,
|
||||
useUpdateCreditsSettings,
|
||||
} from '../../hooks/useCommunicationCredits';
|
||||
import { CreditPaymentModal } from '../../components/CreditPaymentForm';
|
||||
|
||||
const CommunicationSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { business, user } = useOutletContext<{
|
||||
business: Business;
|
||||
user: User;
|
||||
}>();
|
||||
|
||||
const { data: credits, isLoading: creditsLoading } = useCommunicationCredits();
|
||||
const { data: transactions } = useCreditTransactions(1, 10);
|
||||
const updateSettings = useUpdateCreditsSettings();
|
||||
|
||||
// Wizard state
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
const [wizardStep, setWizardStep] = useState(1);
|
||||
const [wizardData, setWizardData] = useState({
|
||||
appointmentsPerMonth: 100,
|
||||
smsRemindersEnabled: true,
|
||||
smsPerAppointment: 2,
|
||||
maskedCallingEnabled: false,
|
||||
avgCallMinutes: 3,
|
||||
callsPerMonth: 20,
|
||||
dedicatedNumberNeeded: false,
|
||||
callingPattern: 'sequential' as 'concurrent' | 'sequential',
|
||||
staffCount: 1,
|
||||
maxDailyAppointmentsPerStaff: 8,
|
||||
});
|
||||
|
||||
// Settings form state
|
||||
const [settingsForm, setSettingsForm] = useState({
|
||||
auto_reload_enabled: credits?.auto_reload_enabled ?? false,
|
||||
auto_reload_threshold_cents: credits?.auto_reload_threshold_cents ?? 1000,
|
||||
auto_reload_amount_cents: credits?.auto_reload_amount_cents ?? 2500,
|
||||
low_balance_warning_cents: credits?.low_balance_warning_cents ?? 500,
|
||||
});
|
||||
|
||||
// Top-up modal state
|
||||
const [showTopUp, setShowTopUp] = useState(false);
|
||||
const [topUpAmount, setTopUpAmount] = useState(2500);
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
|
||||
// Update settings form when credits data loads
|
||||
useEffect(() => {
|
||||
if (credits) {
|
||||
setSettingsForm({
|
||||
auto_reload_enabled: credits.auto_reload_enabled,
|
||||
auto_reload_threshold_cents: credits.auto_reload_threshold_cents,
|
||||
auto_reload_amount_cents: credits.auto_reload_amount_cents,
|
||||
low_balance_warning_cents: credits.low_balance_warning_cents,
|
||||
});
|
||||
}
|
||||
}, [credits]);
|
||||
|
||||
// Check if needs setup
|
||||
const needsSetup = !credits || (credits.balance_cents === 0 && credits.total_loaded_cents === 0);
|
||||
|
||||
// Calculate recommended phone numbers based on calling pattern
|
||||
const getRecommendedPhoneNumbers = () => {
|
||||
if (!wizardData.maskedCallingEnabled || !wizardData.dedicatedNumberNeeded) {
|
||||
return 0;
|
||||
}
|
||||
if (wizardData.callingPattern === 'sequential') {
|
||||
return Math.max(1, Math.ceil(wizardData.staffCount / 3));
|
||||
} else {
|
||||
return wizardData.maxDailyAppointmentsPerStaff;
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate estimated monthly cost
|
||||
const calculateEstimate = () => {
|
||||
let totalCents = 0;
|
||||
if (wizardData.smsRemindersEnabled) {
|
||||
const smsCount = wizardData.appointmentsPerMonth * wizardData.smsPerAppointment;
|
||||
totalCents += smsCount * 3;
|
||||
}
|
||||
if (wizardData.maskedCallingEnabled) {
|
||||
const callMinutes = wizardData.callsPerMonth * wizardData.avgCallMinutes;
|
||||
totalCents += callMinutes * 5;
|
||||
}
|
||||
if (wizardData.dedicatedNumberNeeded) {
|
||||
const recommendedNumbers = getRecommendedPhoneNumbers();
|
||||
totalCents += recommendedNumbers * 200;
|
||||
}
|
||||
return totalCents;
|
||||
};
|
||||
|
||||
// Calculate recommended starting balance
|
||||
const getRecommendedBalance = () => {
|
||||
const monthlyEstimate = calculateEstimate();
|
||||
return Math.max(2500, Math.ceil((monthlyEstimate * 2.5) / 500) * 500);
|
||||
};
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
await updateSettings.mutateAsync(settingsForm);
|
||||
};
|
||||
|
||||
const handlePaymentSuccess = async () => {
|
||||
if (showWizard || needsSetup) {
|
||||
await updateSettings.mutateAsync(settingsForm);
|
||||
}
|
||||
setShowTopUp(false);
|
||||
setShowWizard(false);
|
||||
};
|
||||
|
||||
const formatCurrency = (cents: number) => {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOwner) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Only the business owner can access these settings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (creditsLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-brand-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<Phone className="text-green-500" />
|
||||
SMS & Calling Credits
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
Manage your prepaid credits for SMS reminders and masked calling.
|
||||
</p>
|
||||
</div>
|
||||
{!needsSetup && (
|
||||
<button
|
||||
onClick={() => setShowWizard(true)}
|
||||
className="text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400"
|
||||
>
|
||||
Recalculate usage
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Setup Wizard or Main Content */}
|
||||
{needsSetup || showWizard ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="mb-6">
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{needsSetup ? 'Set Up Communication Credits' : 'Estimate Your Usage'}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Answer a few questions to estimate your monthly communication costs
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="flex items-center gap-2 mb-8">
|
||||
{[1, 2, 3, 4].map((step) => (
|
||||
<React.Fragment key={step}>
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
wizardStep >= step
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{wizardStep > step ? <Check className="w-4 h-4" /> : step}
|
||||
</div>
|
||||
{step < 4 && (
|
||||
<div
|
||||
className={`flex-1 h-1 ${
|
||||
wizardStep > step ? 'bg-brand-600' : 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Appointment Volume */}
|
||||
{wizardStep === 1 && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
How many appointments do you handle per month?
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={wizardData.appointmentsPerMonth}
|
||||
onChange={(e) =>
|
||||
setWizardData({ ...wizardData, appointmentsPerMonth: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-lg"
|
||||
placeholder="100"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
Include all appointments: new bookings, rescheduled, and recurring
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => setWizardStep(2)}
|
||||
className="px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: SMS Settings */}
|
||||
{wizardStep === 2 && (
|
||||
<div className="space-y-6">
|
||||
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<label className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Enable SMS Reminders
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Send text reminders to customers and staff
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={wizardData.smsRemindersEnabled}
|
||||
onChange={(e) =>
|
||||
setWizardData({ ...wizardData, smsRemindersEnabled: e.target.checked })
|
||||
}
|
||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500 w-5 h-5"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{wizardData.smsRemindersEnabled && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
SMS messages per appointment
|
||||
</label>
|
||||
<select
|
||||
value={wizardData.smsPerAppointment}
|
||||
onChange={(e) =>
|
||||
setWizardData({ ...wizardData, smsPerAppointment: parseInt(e.target.value) })
|
||||
}
|
||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="1">1 - Reminder only</option>
|
||||
<option value="2">2 - Confirmation + Reminder</option>
|
||||
<option value="3">3 - Confirmation + Reminder + Follow-up</option>
|
||||
<option value="4">4 - All of above + Staff notifications</option>
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
Cost: $0.03 per SMS message
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={() => setWizardStep(1)}
|
||||
className="px-6 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setWizardStep(3)}
|
||||
className="px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Masked Calling */}
|
||||
{wizardStep === 3 && (
|
||||
<div className="space-y-6">
|
||||
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<label className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Enable Masked Calling
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Allow customers and staff to call each other without revealing real numbers
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={wizardData.maskedCallingEnabled}
|
||||
onChange={(e) =>
|
||||
setWizardData({ ...wizardData, maskedCallingEnabled: e.target.checked })
|
||||
}
|
||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500 w-5 h-5"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{wizardData.maskedCallingEnabled && (
|
||||
<>
|
||||
<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">
|
||||
Estimated calls per month
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={wizardData.callsPerMonth}
|
||||
onChange={(e) =>
|
||||
setWizardData({ ...wizardData, callsPerMonth: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Average call duration (minutes)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={wizardData.avgCallMinutes}
|
||||
onChange={(e) =>
|
||||
setWizardData({ ...wizardData, avgCallMinutes: parseInt(e.target.value) || 1 })
|
||||
}
|
||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Cost: $0.05 per minute of voice calling
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={() => setWizardStep(2)}
|
||||
className="px-6 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setWizardStep(4)}
|
||||
className="px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700"
|
||||
>
|
||||
See Estimate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Summary and Load Credits */}
|
||||
{wizardStep === 4 && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-6">
|
||||
<h5 className="font-medium text-gray-900 dark:text-white mb-4">
|
||||
Estimated Monthly Costs
|
||||
</h5>
|
||||
|
||||
<div className="space-y-3">
|
||||
{wizardData.smsRemindersEnabled && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
SMS Messages ({wizardData.appointmentsPerMonth * wizardData.smsPerAppointment}/mo)
|
||||
</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{formatCurrency(wizardData.appointmentsPerMonth * wizardData.smsPerAppointment * 3)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wizardData.maskedCallingEnabled && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
Voice Calling ({wizardData.callsPerMonth * wizardData.avgCallMinutes} min/mo)
|
||||
</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{formatCurrency(wizardData.callsPerMonth * wizardData.avgCallMinutes * 5)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-600 pt-3 mt-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-medium text-gray-900 dark:text-white">Total Estimated</span>
|
||||
<span className="text-xl font-bold text-brand-600">
|
||||
{formatCurrency(calculateEstimate())}/month
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-brand-50 dark:bg-brand-900/20 rounded-lg p-6">
|
||||
<h5 className="font-medium text-brand-900 dark:text-brand-100 mb-2">
|
||||
Recommended Starting Balance
|
||||
</h5>
|
||||
<p className="text-3xl font-bold text-brand-600 mb-2">
|
||||
{formatCurrency(getRecommendedBalance())}
|
||||
</p>
|
||||
<p className="text-sm text-brand-700 dark:text-brand-300">
|
||||
This covers approximately 2-3 months of estimated usage with a safety buffer
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Choose your starting amount
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{[1000, 2500, 5000, 10000].map((amount) => (
|
||||
<button
|
||||
key={amount}
|
||||
onClick={() => setTopUpAmount(amount)}
|
||||
className={`py-3 px-4 rounded-lg border-2 transition-colors ${
|
||||
topUpAmount === amount
|
||||
? 'border-brand-600 bg-brand-50 dark:bg-brand-900/30 text-brand-600'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className="font-semibold">{formatCurrency(amount)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={() => setWizardStep(3)}
|
||||
className="px-6 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<div className="flex gap-3">
|
||||
{!needsSetup && (
|
||||
<button
|
||||
onClick={() => setShowWizard(false)}
|
||||
className="px-6 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowTopUp(true)}
|
||||
className="px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 flex items-center gap-2"
|
||||
>
|
||||
<CreditCard className="w-4 h-4" />
|
||||
Load {formatCurrency(topUpAmount)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Current Balance Card */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Current Balance</span>
|
||||
<Wallet className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{formatCurrency(credits?.balance_cents || 0)}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowTopUp(true)}
|
||||
className="mt-3 w-full py-2 text-sm font-medium text-brand-600 border border-brand-600 rounded-lg hover:bg-brand-50 dark:hover:bg-brand-900/20"
|
||||
>
|
||||
Add Credits
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Total Loaded</span>
|
||||
<ArrowUpRight className="w-5 h-5 text-green-500" />
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-green-600">
|
||||
{formatCurrency(credits?.total_loaded_cents || 0)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">All time</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Total Spent</span>
|
||||
<ArrowDownRight className="w-5 h-5 text-red-500" />
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{formatCurrency(credits?.total_spent_cents || 0)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">All time</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-Reload Settings */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Auto-Reload Settings
|
||||
</h4>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center justify-between p-3 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Enable Auto-Reload
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Automatically add credits when balance falls below threshold
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settingsForm.auto_reload_enabled}
|
||||
onChange={(e) =>
|
||||
setSettingsForm({ ...settingsForm, auto_reload_enabled: e.target.checked })
|
||||
}
|
||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500 w-5 h-5"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{settingsForm.auto_reload_enabled && (
|
||||
<div className="grid grid-cols-2 gap-4 pl-4 border-l-2 border-brand-200 dark:border-brand-800">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Reload when balance below
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={(settingsForm.auto_reload_threshold_cents / 100).toFixed(0)}
|
||||
onChange={(e) =>
|
||||
setSettingsForm({
|
||||
...settingsForm,
|
||||
auto_reload_threshold_cents: (parseFloat(e.target.value) || 0) * 100,
|
||||
})
|
||||
}
|
||||
className="w-full pl-8 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Reload amount
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
|
||||
<input
|
||||
type="number"
|
||||
min="5"
|
||||
step="5"
|
||||
value={(settingsForm.auto_reload_amount_cents / 100).toFixed(0)}
|
||||
onChange={(e) =>
|
||||
setSettingsForm({
|
||||
...settingsForm,
|
||||
auto_reload_amount_cents: (parseFloat(e.target.value) || 0) * 100,
|
||||
})
|
||||
}
|
||||
className="w-full pl-8 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Low balance warning at
|
||||
</label>
|
||||
<div className="relative w-1/2">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={(settingsForm.low_balance_warning_cents / 100).toFixed(0)}
|
||||
onChange={(e) =>
|
||||
setSettingsForm({
|
||||
...settingsForm,
|
||||
low_balance_warning_cents: (parseFloat(e.target.value) || 0) * 100,
|
||||
})
|
||||
}
|
||||
className="w-full pl-8 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
You'll receive an email when your balance drops below this amount
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={handleSaveSettings}
|
||||
disabled={updateSettings.isPending}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{updateSettings.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transaction History */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
Recent Transactions
|
||||
</h4>
|
||||
|
||||
{transactions?.results && transactions.results.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{transactions.results.map((tx: any) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="flex items-center justify-between py-3 border-b border-gray-100 dark:border-gray-700 last:border-0"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
tx.amount_cents > 0
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-600'
|
||||
: 'bg-red-100 dark:bg-red-900/30 text-red-600'
|
||||
}`}
|
||||
>
|
||||
{tx.amount_cents > 0 ? (
|
||||
<ArrowUpRight className="w-4 h-4" />
|
||||
) : (
|
||||
<ArrowDownRight className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{tx.description || tx.transaction_type.replace('_', ' ')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatDate(tx.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p
|
||||
className={`font-medium ${
|
||||
tx.amount_cents > 0 ? 'text-green-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{tx.amount_cents > 0 ? '+' : ''}
|
||||
{formatCurrency(tx.amount_cents)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Balance: {formatCurrency(tx.balance_after_cents)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
|
||||
No transactions yet
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Credit Payment Modal */}
|
||||
<CreditPaymentModal
|
||||
isOpen={showTopUp}
|
||||
onClose={() => setShowTopUp(false)}
|
||||
defaultAmount={topUpAmount}
|
||||
onSuccess={handlePaymentSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommunicationSettings;
|
||||
333
frontend/src/pages/settings/DomainsSettings.tsx
Normal file
333
frontend/src/pages/settings/DomainsSettings.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Domains Settings Page
|
||||
*
|
||||
* Manage custom domains and booking URLs for the business.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import {
|
||||
Globe, Link2, Copy, Star, Trash2, RefreshCw, CheckCircle, AlertCircle,
|
||||
ShoppingCart, Crown
|
||||
} from 'lucide-react';
|
||||
import { Business, User, CustomDomain } from '../../types';
|
||||
import {
|
||||
useCustomDomains,
|
||||
useAddCustomDomain,
|
||||
useDeleteCustomDomain,
|
||||
useVerifyCustomDomain,
|
||||
useSetPrimaryDomain
|
||||
} from '../../hooks/useCustomDomains';
|
||||
import DomainPurchase from '../../components/DomainPurchase';
|
||||
|
||||
const DomainsSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { business, user } = useOutletContext<{
|
||||
business: Business;
|
||||
user: User;
|
||||
}>();
|
||||
|
||||
// Hooks
|
||||
const { data: customDomains = [], isLoading: domainsLoading } = useCustomDomains();
|
||||
const addDomainMutation = useAddCustomDomain();
|
||||
const deleteDomainMutation = useDeleteCustomDomain();
|
||||
const verifyDomainMutation = useVerifyCustomDomain();
|
||||
const setPrimaryMutation = useSetPrimaryDomain();
|
||||
|
||||
// Local state
|
||||
const [newDomain, setNewDomain] = useState('');
|
||||
const [verifyingDomainId, setVerifyingDomainId] = useState<number | null>(null);
|
||||
const [verifyError, setVerifyError] = useState<string | null>(null);
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
|
||||
const handleAddDomain = () => {
|
||||
if (!newDomain.trim()) return;
|
||||
|
||||
addDomainMutation.mutate(newDomain, {
|
||||
onSuccess: () => {
|
||||
setNewDomain('');
|
||||
setShowToast(true);
|
||||
setTimeout(() => setShowToast(false), 3000);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(error.response?.data?.error || 'Failed to add domain');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteDomain = (domainId: number) => {
|
||||
if (!confirm('Are you sure you want to delete this custom domain?')) return;
|
||||
|
||||
deleteDomainMutation.mutate(domainId, {
|
||||
onSuccess: () => {
|
||||
setShowToast(true);
|
||||
setTimeout(() => setShowToast(false), 3000);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleVerifyDomain = (domainId: number) => {
|
||||
setVerifyingDomainId(domainId);
|
||||
setVerifyError(null);
|
||||
|
||||
verifyDomainMutation.mutate(domainId, {
|
||||
onSuccess: (data: any) => {
|
||||
setVerifyingDomainId(null);
|
||||
if (data.verified) {
|
||||
setShowToast(true);
|
||||
setTimeout(() => setShowToast(false), 3000);
|
||||
} else {
|
||||
setVerifyError(data.message);
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setVerifyingDomainId(null);
|
||||
setVerifyError(error.response?.data?.message || 'Verification failed');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSetPrimary = (domainId: number) => {
|
||||
setPrimaryMutation.mutate(domainId, {
|
||||
onSuccess: () => {
|
||||
setShowToast(true);
|
||||
setTimeout(() => setShowToast(false), 3000);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(error.response?.data?.error || 'Failed to set primary domain');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOwner) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Only the business owner can access these settings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<Globe className="text-indigo-500" />
|
||||
{t('settings.domains.title', 'Custom Domains')}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
Configure custom domains for your booking pages.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Domain Setup - Booking URL */}
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Link2 size={20} className="text-brand-500" /> Your Booking URL
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
|
||||
<code className="flex-1 text-sm font-mono text-gray-900 dark:text-white">
|
||||
{business.subdomain}.smoothschedule.com
|
||||
</code>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(`${business.subdomain}.smoothschedule.com`)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Custom Domains Management */}
|
||||
{business.plan !== 'Free' ? (
|
||||
<>
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Globe size={20} className="text-indigo-500" />
|
||||
Custom Domains
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Use your own domains for your booking pages
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add New Domain Form */}
|
||||
<div className="mb-4 p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newDomain}
|
||||
onChange={(e) => setNewDomain(e.target.value)}
|
||||
placeholder="booking.yourdomain.com"
|
||||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-brand-500 font-mono text-sm"
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleAddDomain();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddDomain}
|
||||
disabled={addDomainMutation.isPending || !newDomain.trim()}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium text-sm"
|
||||
>
|
||||
{addDomainMutation.isPending ? 'Adding...' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Domains List */}
|
||||
{domainsLoading ? (
|
||||
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
|
||||
Loading domains...
|
||||
</div>
|
||||
) : customDomains.length === 0 ? (
|
||||
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
|
||||
<Globe size={40} className="mx-auto mb-2 opacity-30" />
|
||||
<p className="text-sm">No custom domains yet. Add one above.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{customDomains.map((domain: CustomDomain) => (
|
||||
<div
|
||||
key={domain.id}
|
||||
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-mono text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{domain.domain}
|
||||
</h4>
|
||||
{domain.is_primary && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300 rounded">
|
||||
<Star size={10} className="fill-current" /> Primary
|
||||
</span>
|
||||
)}
|
||||
{domain.is_verified ? (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded">
|
||||
<CheckCircle size={10} /> Verified
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded">
|
||||
<AlertCircle size={10} /> Pending
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!domain.is_verified && (
|
||||
<div className="mt-2 p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded text-xs">
|
||||
<p className="font-medium text-amber-800 dark:text-amber-300 mb-1">Add DNS TXT record:</p>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-amber-700 dark:text-amber-400">Name:</span>
|
||||
<code className="px-1 bg-white dark:bg-gray-800 rounded text-gray-900 dark:text-white">{domain.dns_txt_record_name}</code>
|
||||
<button onClick={() => navigator.clipboard.writeText(domain.dns_txt_record_name || '')} className="text-amber-600 hover:text-amber-700"><Copy size={12} /></button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-amber-700 dark:text-amber-400">Value:</span>
|
||||
<code className="px-1 bg-white dark:bg-gray-800 rounded text-gray-900 dark:text-white truncate max-w-[200px]">{domain.dns_txt_record}</code>
|
||||
<button onClick={() => navigator.clipboard.writeText(domain.dns_txt_record || '')} className="text-amber-600 hover:text-amber-700"><Copy size={12} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{verifyError && verifyingDomainId === domain.id && (
|
||||
<p className="mt-1 text-red-600 dark:text-red-400">{verifyError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-3">
|
||||
{!domain.is_verified && (
|
||||
<button
|
||||
onClick={() => handleVerifyDomain(domain.id)}
|
||||
disabled={verifyingDomainId === domain.id}
|
||||
className="p-1.5 text-brand-600 dark:text-brand-400 hover:bg-brand-50 dark:hover:bg-brand-900/30 rounded transition-colors disabled:opacity-50"
|
||||
title="Verify"
|
||||
>
|
||||
<RefreshCw size={16} className={verifyingDomainId === domain.id ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
)}
|
||||
{domain.is_verified && !domain.is_primary && (
|
||||
<button
|
||||
onClick={() => handleSetPrimary(domain.id)}
|
||||
disabled={setPrimaryMutation.isPending}
|
||||
className="p-1.5 text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded transition-colors"
|
||||
title="Set as Primary"
|
||||
>
|
||||
<Star size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteDomain(domain.id)}
|
||||
disabled={deleteDomainMutation.isPending}
|
||||
className="p-1.5 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Domain Purchase */}
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<ShoppingCart size={20} className="text-green-500" />
|
||||
Purchase a Domain
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Search and register a new domain name
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DomainPurchase />
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
/* Upgrade prompt for free plans */
|
||||
<section className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 p-6 rounded-xl border border-amber-200 dark:border-amber-800">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-amber-100 dark:bg-amber-900/40 rounded-lg">
|
||||
<Crown size={24} className="text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">Unlock Custom Domains</h4>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
||||
Upgrade to use your own domain (e.g., <span className="font-mono">book.yourbusiness.com</span>) or purchase a new one.
|
||||
</p>
|
||||
<button className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-indigo-500 to-purple-500 rounded-lg hover:from-indigo-600 hover:to-purple-600 transition-all">
|
||||
<Crown size={16} /> View Plans
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Toast */}
|
||||
{showToast && (
|
||||
<div className="fixed bottom-4 right-4 flex items-center gap-2 px-4 py-3 bg-green-500 text-white rounded-lg shadow-lg">
|
||||
<CheckCircle size={18} />
|
||||
Changes saved successfully
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DomainsSettings;
|
||||
52
frontend/src/pages/settings/EmailSettings.tsx
Normal file
52
frontend/src/pages/settings/EmailSettings.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Email Settings Page
|
||||
*
|
||||
* Manage email addresses for ticket system and customer communication.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { Mail } from 'lucide-react';
|
||||
import { Business, User } from '../../types';
|
||||
import TicketEmailAddressManager from '../../components/TicketEmailAddressManager';
|
||||
|
||||
const EmailSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { business, user } = useOutletContext<{
|
||||
business: Business;
|
||||
user: User;
|
||||
}>();
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
|
||||
if (!isOwner) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Only the business owner can access these settings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<Mail className="text-blue-500" />
|
||||
{t('settings.email.title', 'Email Setup')}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
Configure email addresses for your ticketing system and customer communication.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Email Address Manager */}
|
||||
<TicketEmailAddressManager />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailSettings;
|
||||
163
frontend/src/pages/settings/GeneralSettings.tsx
Normal file
163
frontend/src/pages/settings/GeneralSettings.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* General Settings Page
|
||||
*
|
||||
* Business identity settings: name, subdomain, timezone, contact info.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { Building2, Save, Check } from 'lucide-react';
|
||||
import { Business, User } from '../../types';
|
||||
|
||||
const GeneralSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { business, updateBusiness, user } = useOutletContext<{
|
||||
business: Business;
|
||||
updateBusiness: (updates: Partial<Business>) => void;
|
||||
user: User;
|
||||
}>();
|
||||
|
||||
const [formState, setFormState] = useState({
|
||||
name: business.name,
|
||||
subdomain: business.subdomain,
|
||||
contactEmail: business.contactEmail || '',
|
||||
phone: business.phone || '',
|
||||
});
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormState(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
await updateBusiness(formState);
|
||||
setShowToast(true);
|
||||
setTimeout(() => setShowToast(false), 3000);
|
||||
};
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
|
||||
if (!isOwner) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('settings.ownerOnly', 'Only the business owner can access these settings.')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<Building2 className="text-brand-500" />
|
||||
{t('settings.general.title', 'General Settings')}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('settings.general.subtitle', 'Manage your business identity and contact information.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Business Identity */}
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('settings.businessIdentity', 'Business Identity')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('settings.businessName', 'Business Name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formState.name}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('settings.subdomain', 'Subdomain')}
|
||||
</label>
|
||||
<div className="flex">
|
||||
<input
|
||||
type="text"
|
||||
name="subdomain"
|
||||
value={formState.subdomain}
|
||||
className="flex-1 min-w-0 px-4 py-2 border border-r-0 border-gray-300 dark:border-gray-600 rounded-l-lg bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400 cursor-not-allowed"
|
||||
readOnly
|
||||
/>
|
||||
<span className="inline-flex items-center px-4 py-2 border border-l-0 border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-500 dark:text-gray-400 text-sm rounded-r-lg">
|
||||
.smoothschedule.com
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('settings.subdomainHint', 'Contact support to change your subdomain.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contact Information */}
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('settings.contactInfo', 'Contact Information')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('settings.contactEmail', 'Contact Email')}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="contactEmail"
|
||||
value={formState.contactEmail}
|
||||
onChange={handleChange}
|
||||
placeholder="contact@yourbusiness.com"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('settings.phone', 'Phone Number')}
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={formState.phone}
|
||||
onChange={handleChange}
|
||||
placeholder="+1 (555) 123-4567"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-brand-600 hover:bg-brand-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Save size={18} />
|
||||
{t('common.saveChanges', 'Save Changes')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
{showToast && (
|
||||
<div className="fixed bottom-4 right-4 flex items-center gap-2 px-4 py-3 bg-green-500 text-white rounded-lg shadow-lg animate-fade-in">
|
||||
<Check size={18} />
|
||||
{t('common.saved', 'Changes saved successfully')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GeneralSettings;
|
||||
292
frontend/src/pages/settings/ResourceTypesSettings.tsx
Normal file
292
frontend/src/pages/settings/ResourceTypesSettings.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Resource Types Settings Page
|
||||
*
|
||||
* Define and manage custom resource types (e.g., Stylist, Treatment Room, Equipment).
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { Layers, Plus, X, Pencil, Trash2, Users } from 'lucide-react';
|
||||
import { Business, User } from '../../types';
|
||||
import { useResourceTypes, useCreateResourceType, useUpdateResourceType, useDeleteResourceType } from '../../hooks/useResourceTypes';
|
||||
|
||||
const ResourceTypesSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { business, user } = useOutletContext<{
|
||||
business: Business;
|
||||
user: User;
|
||||
}>();
|
||||
|
||||
const { data: resourceTypes = [], isLoading } = useResourceTypes();
|
||||
const createResourceType = useCreateResourceType();
|
||||
const updateResourceType = useUpdateResourceType();
|
||||
const deleteResourceType = useDeleteResourceType();
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingType, setEditingType] = useState<any>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
category: 'OTHER' as 'STAFF' | 'OTHER',
|
||||
iconName: '',
|
||||
});
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingType(null);
|
||||
setFormData({ name: '', description: '', category: 'OTHER', iconName: '' });
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (type: any) => {
|
||||
setEditingType(type);
|
||||
setFormData({
|
||||
name: type.name,
|
||||
description: type.description || '',
|
||||
category: type.category,
|
||||
iconName: type.icon_name || type.iconName || '',
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingType(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingType) {
|
||||
await updateResourceType.mutateAsync({
|
||||
id: editingType.id,
|
||||
updates: formData,
|
||||
});
|
||||
} else {
|
||||
await createResourceType.mutateAsync(formData);
|
||||
}
|
||||
closeModal();
|
||||
} catch (error) {
|
||||
console.error('Failed to save resource type:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (window.confirm(`Are you sure you want to delete the "${name}" resource type?`)) {
|
||||
try {
|
||||
await deleteResourceType.mutateAsync(id);
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.error || 'Failed to delete resource type');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOwner) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Only the business owner can access these settings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<Layers className="text-indigo-500" />
|
||||
{t('settings.resourceTypes.title', 'Resource Types')}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
Define custom types for your resources (e.g., Stylist, Treatment Room, Equipment).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Resource Types List */}
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('settings.resourceTypes.list', 'Your Resource Types')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('settings.resourceTypes.listDescription', 'Create categories to organize your resources.')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium text-sm"
|
||||
>
|
||||
<Plus size={16} />
|
||||
{t('settings.addResourceType', 'Add Type')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
|
||||
</div>
|
||||
) : resourceTypes.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<Layers size={40} className="mx-auto mb-2 opacity-30" />
|
||||
<p>{t('settings.noResourceTypes', 'No custom resource types yet.')}</p>
|
||||
<p className="text-sm mt-1">{t('settings.addFirstResourceType', 'Add your first resource type to categorize your resources.')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{resourceTypes.map((type: any) => {
|
||||
const isDefault = type.is_default || type.isDefault;
|
||||
return (
|
||||
<div
|
||||
key={type.id}
|
||||
className="p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ${
|
||||
type.category === 'STAFF' ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400' : 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{type.category === 'STAFF' ? <Users size={20} /> : <Layers size={20} />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white flex items-center gap-2">
|
||||
{type.name}
|
||||
{isDefault && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{type.category === 'STAFF' ? 'Requires staff assignment' : 'General resource'}
|
||||
</p>
|
||||
{type.description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1 line-clamp-2">
|
||||
{type.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||
<button
|
||||
onClick={() => openEditModal(type)}
|
||||
className="p-2 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
|
||||
title={t('common.edit', 'Edit')}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
{!isDefault && (
|
||||
<button
|
||||
onClick={() => handleDelete(type.id, type.name)}
|
||||
disabled={deleteResourceType.isPending}
|
||||
className="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50"
|
||||
title={t('common.delete', 'Delete')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Modal for Create/Edit */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-100 dark:border-gray-700 shrink-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{editingType
|
||||
? t('settings.editResourceType', 'Edit Resource Type')
|
||||
: t('settings.addResourceType', 'Add Resource Type')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('settings.resourceTypeName', 'Name')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
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-brand-500"
|
||||
placeholder={t('settings.resourceTypeNamePlaceholder', 'e.g., Stylist, Treatment Room, Camera')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('settings.resourceTypeDescription', 'Description')}
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
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-brand-500 resize-none"
|
||||
placeholder={t('settings.resourceTypeDescriptionPlaceholder', 'Describe this type of resource...')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('settings.resourceTypeCategory', 'Category')} *
|
||||
</label>
|
||||
<select
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value as 'STAFF' | 'OTHER' })}
|
||||
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-brand-500"
|
||||
>
|
||||
<option value="STAFF">{t('settings.categoryStaff', 'Staff (requires staff assignment)')}</option>
|
||||
<option value="OTHER">{t('settings.categoryOther', 'Other (general resource)')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{formData.category === 'STAFF'
|
||||
? t('settings.staffCategoryHint', 'Staff resources must be assigned to a team member')
|
||||
: t('settings.otherCategoryHint', 'General resources like rooms, equipment, or vehicles')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 p-6 border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createResourceType.isPending || updateResourceType.isPending}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{editingType ? t('common.save', 'Save') : t('common.create', 'Create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResourceTypesSettings;
|
||||
24
frontend/src/pages/settings/index.tsx
Normal file
24
frontend/src/pages/settings/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Settings Pages Index
|
||||
*
|
||||
* Exports all settings sub-pages for routing.
|
||||
*/
|
||||
|
||||
// Business Settings
|
||||
export { default as GeneralSettings } from './GeneralSettings';
|
||||
export { default as BrandingSettings } from './BrandingSettings';
|
||||
export { default as ResourceTypesSettings } from './ResourceTypesSettings';
|
||||
|
||||
// Integrations
|
||||
export { default as DomainsSettings } from './DomainsSettings';
|
||||
export { default as ApiSettings } from './ApiSettings';
|
||||
|
||||
// Access
|
||||
export { default as AuthenticationSettings } from './AuthenticationSettings';
|
||||
|
||||
// Communication
|
||||
export { default as EmailSettings } from './EmailSettings';
|
||||
export { default as CommunicationSettings } from './CommunicationSettings';
|
||||
|
||||
// Billing
|
||||
export { default as BillingSettings } from './BillingSettings';
|
||||
@@ -104,6 +104,7 @@ LOCAL_APPS = [
|
||||
"platform_admin.apps.PlatformAdminConfig",
|
||||
"notifications", # New: Generic notification app
|
||||
"tickets", # New: Support tickets app
|
||||
"smoothschedule.comms_credits", # Communication credits and SMS/calling
|
||||
# Your stuff: custom apps go here
|
||||
]
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
||||
|
||||
@@ -48,6 +48,7 @@ SHARED_APPS = [
|
||||
'tickets', # Ticket system - shared for platform support access
|
||||
'notifications', # Notification system - shared for platform to notify tenants
|
||||
'smoothschedule.public_api', # Public API v1 for third-party integrations
|
||||
'smoothschedule.comms_credits', # Communication credits (SMS/calling) - shared for billing
|
||||
]
|
||||
|
||||
# Tenant-specific apps - Each tenant gets isolated data in their own schema
|
||||
@@ -55,7 +56,6 @@ TENANT_APPS = [
|
||||
'django.contrib.contenttypes', # Needed for tenant schemas
|
||||
'schedule', # Resource scheduling with configurable concurrency
|
||||
'payments', # Stripe Connect payments bridge
|
||||
'communication', # Twilio masked communications
|
||||
# Add your tenant-scoped business logic apps here:
|
||||
# 'appointments',
|
||||
# 'customers',
|
||||
|
||||
@@ -69,6 +69,8 @@ urlpatterns += [
|
||||
path("", include("schedule.urls")),
|
||||
# Payments API
|
||||
path("payments/", include("payments.urls")),
|
||||
# Communication Credits API
|
||||
path("communication-credits/", include("smoothschedule.comms_credits.urls", namespace="comms_credits")),
|
||||
# Tickets API
|
||||
path("tickets/", include("tickets.urls")),
|
||||
# Notifications API
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-02 02:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0010_add_oauth_credential_model'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='twilio_phone_number',
|
||||
field=models.CharField(blank=True, default='', help_text='Assigned Twilio phone number for this tenant', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='twilio_subaccount_auth_token',
|
||||
field=models.CharField(blank=True, default='', help_text='Twilio Subaccount Auth Token', max_length=50),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='twilio_subaccount_sid',
|
||||
field=models.CharField(blank=True, default='', help_text='Twilio Subaccount SID for this tenant', max_length=50),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-02 02:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0011_tenant_twilio_phone_number_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='can_use_sms_reminders',
|
||||
field=models.BooleanField(default=False, help_text='Whether this business can send SMS reminders to customers/staff'),
|
||||
),
|
||||
]
|
||||
83
smoothschedule/core/migrations/0013_stripe_payment_fields.py
Normal file
83
smoothschedule/core/migrations/0013_stripe_payment_fields.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-02 06:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0012_tenant_can_use_sms_reminders'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='payment_mode',
|
||||
field=models.CharField(choices=[('none', 'Not Configured'), ('direct_api', 'Direct API Keys (Free Tier)'), ('connect', 'Stripe Connect (Paid Tiers)')], default='none', help_text='How this business accepts payments', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='stripe_api_key_account_id',
|
||||
field=models.CharField(blank=True, default='', help_text='Stripe Account ID from validated keys', max_length=50),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='stripe_api_key_account_name',
|
||||
field=models.CharField(blank=True, default='', help_text='Stripe Account name from validated keys', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='stripe_api_key_error',
|
||||
field=models.TextField(blank=True, default='', help_text='Validation error message if keys are invalid'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='stripe_api_key_status',
|
||||
field=models.CharField(blank=True, choices=[('active', 'Active'), ('invalid', 'Invalid'), ('deprecated', 'Deprecated')], default='active', help_text='Status of stored API keys', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='stripe_api_key_validated_at',
|
||||
field=models.DateTimeField(blank=True, help_text='When the API keys were last validated', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='stripe_charges_enabled',
|
||||
field=models.BooleanField(default=False, help_text='Whether Stripe account can accept charges'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='stripe_connect_id',
|
||||
field=models.CharField(blank=True, default='', help_text='Stripe Connected Account ID (acct_xxx)', max_length=50),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='stripe_connect_status',
|
||||
field=models.CharField(choices=[('pending', 'Pending'), ('onboarding', 'Onboarding'), ('active', 'Active'), ('restricted', 'Restricted'), ('rejected', 'Rejected')], default='pending', help_text='Status of Stripe Connect account', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='stripe_details_submitted',
|
||||
field=models.BooleanField(default=False, help_text='Whether onboarding details have been submitted'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='stripe_onboarding_complete',
|
||||
field=models.BooleanField(default=False, help_text='Whether Stripe Connect onboarding is complete'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='stripe_payouts_enabled',
|
||||
field=models.BooleanField(default=False, help_text='Whether Stripe account can receive payouts'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='stripe_publishable_key',
|
||||
field=models.CharField(blank=True, default='', help_text='Stripe Publishable Key (pk_xxx)', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='stripe_secret_key',
|
||||
field=models.CharField(blank=True, default='', help_text='Stripe Secret Key (sk_xxx) - Encrypted', max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -155,6 +155,10 @@ class Tenant(TenantMixin):
|
||||
default=False,
|
||||
help_text="Whether this business can permanently delete data"
|
||||
)
|
||||
can_use_sms_reminders = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this business can send SMS reminders to customers/staff"
|
||||
)
|
||||
can_use_masked_phone_numbers = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this business can use masked phone numbers for privacy"
|
||||
@@ -168,12 +172,127 @@ class Tenant(TenantMixin):
|
||||
help_text="Whether this business can use the mobile app"
|
||||
)
|
||||
|
||||
# Stripe Payment Configuration
|
||||
payment_mode = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
('none', 'Not Configured'),
|
||||
('direct_api', 'Direct API Keys (Free Tier)'),
|
||||
('connect', 'Stripe Connect (Paid Tiers)'),
|
||||
],
|
||||
default='none',
|
||||
help_text="How this business accepts payments"
|
||||
)
|
||||
|
||||
# Stripe Connect fields (for paid tier businesses)
|
||||
stripe_connect_id = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="Stripe Connected Account ID (acct_xxx)"
|
||||
)
|
||||
stripe_connect_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
('pending', 'Pending'),
|
||||
('onboarding', 'Onboarding'),
|
||||
('active', 'Active'),
|
||||
('restricted', 'Restricted'),
|
||||
('rejected', 'Rejected'),
|
||||
],
|
||||
default='pending',
|
||||
help_text="Status of Stripe Connect account"
|
||||
)
|
||||
stripe_charges_enabled = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether Stripe account can accept charges"
|
||||
)
|
||||
stripe_payouts_enabled = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether Stripe account can receive payouts"
|
||||
)
|
||||
stripe_details_submitted = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether onboarding details have been submitted"
|
||||
)
|
||||
stripe_onboarding_complete = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether Stripe Connect onboarding is complete"
|
||||
)
|
||||
|
||||
# Direct API Keys fields (for free tier businesses)
|
||||
stripe_secret_key = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="Stripe Secret Key (sk_xxx) - Encrypted"
|
||||
)
|
||||
stripe_publishable_key = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="Stripe Publishable Key (pk_xxx)"
|
||||
)
|
||||
stripe_api_key_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
('active', 'Active'),
|
||||
('invalid', 'Invalid'),
|
||||
('deprecated', 'Deprecated'),
|
||||
],
|
||||
default='active',
|
||||
blank=True,
|
||||
help_text="Status of stored API keys"
|
||||
)
|
||||
stripe_api_key_validated_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="When the API keys were last validated"
|
||||
)
|
||||
stripe_api_key_account_id = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="Stripe Account ID from validated keys"
|
||||
)
|
||||
stripe_api_key_account_name = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="Stripe Account name from validated keys"
|
||||
)
|
||||
stripe_api_key_error = models.TextField(
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="Validation error message if keys are invalid"
|
||||
)
|
||||
|
||||
# Onboarding tracking
|
||||
initial_setup_complete = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether the business has completed initial onboarding"
|
||||
)
|
||||
|
||||
# Twilio Integration (for SMS reminders and masked calling)
|
||||
twilio_subaccount_sid = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="Twilio Subaccount SID for this tenant"
|
||||
)
|
||||
twilio_subaccount_auth_token = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="Twilio Subaccount Auth Token"
|
||||
)
|
||||
twilio_phone_number = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="Assigned Twilio phone number for this tenant"
|
||||
)
|
||||
|
||||
# Sandbox/Test Mode
|
||||
sandbox_schema_name = models.CharField(
|
||||
max_length=63,
|
||||
|
||||
@@ -222,7 +222,10 @@ def HasQuota(feature_code):
|
||||
'MAX_RESOURCES': 'schedule.Resource',
|
||||
'MAX_USERS': 'users.User',
|
||||
'MAX_EVENTS_PER_MONTH': 'schedule.Event',
|
||||
# Add more mappings as needed
|
||||
'MAX_SERVICES': 'schedule.Service',
|
||||
'MAX_APPOINTMENTS': 'schedule.Event',
|
||||
'MAX_EMAIL_TEMPLATES': 'schedule.EmailTemplate',
|
||||
'MAX_AUTOMATED_TASKS': 'schedule.ScheduledTask',
|
||||
}
|
||||
|
||||
def has_permission(self, request, view):
|
||||
@@ -269,8 +272,23 @@ def HasQuota(feature_code):
|
||||
|
||||
# Count current usage
|
||||
# NOTE: django-tenants automatically scopes this query to tenant schema
|
||||
current_count = Model.objects.count()
|
||||
|
||||
|
||||
# Special handling for monthly appointment limit
|
||||
if feature_code == 'MAX_APPOINTMENTS':
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
# Count appointments in current month
|
||||
now = timezone.now()
|
||||
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
next_month = start_of_month + timedelta(days=32)
|
||||
start_of_next_month = next_month.replace(day=1)
|
||||
current_count = Model.objects.filter(
|
||||
start_time__gte=start_of_month,
|
||||
start_time__lt=start_of_next_month
|
||||
).count()
|
||||
else:
|
||||
current_count = Model.objects.count()
|
||||
|
||||
# The "Hard Block": Enforce the limit
|
||||
if current_count >= limit:
|
||||
# Quota exceeded - deny the operation
|
||||
@@ -280,7 +298,7 @@ def HasQuota(feature_code):
|
||||
f"{feature_code.replace('MAX_', '').lower().replace('_', ' ')}. "
|
||||
f"Please upgrade your subscription to add more."
|
||||
)
|
||||
|
||||
|
||||
# Quota available - allow the operation
|
||||
return True
|
||||
|
||||
|
||||
@@ -3,12 +3,58 @@ Payments App URLs
|
||||
"""
|
||||
from django.urls import path
|
||||
from .views import (
|
||||
# Config status
|
||||
PaymentConfigStatusView,
|
||||
# API Keys (Free Tier)
|
||||
ApiKeysView,
|
||||
ApiKeysValidateView,
|
||||
ApiKeysRevalidateView,
|
||||
ApiKeysDeleteView,
|
||||
# Connect (Paid Tiers)
|
||||
ConnectStatusView,
|
||||
ConnectOnboardView,
|
||||
ConnectRefreshLinkView,
|
||||
ConnectAccountSessionView,
|
||||
ConnectRefreshStatusView,
|
||||
# Transactions
|
||||
TransactionListView,
|
||||
TransactionSummaryView,
|
||||
StripeChargesView,
|
||||
StripePayoutsView,
|
||||
StripeBalanceView,
|
||||
TransactionExportView,
|
||||
# Payment operations
|
||||
CreatePaymentIntentView,
|
||||
TerminalConnectionTokenView,
|
||||
RefundPaymentView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
# Payment configuration status
|
||||
path('config/status/', PaymentConfigStatusView.as_view(), name='payment-config-status'),
|
||||
|
||||
# API Keys endpoints (free tier)
|
||||
path('api-keys/', ApiKeysView.as_view(), name='api-keys'),
|
||||
path('api-keys/validate/', ApiKeysValidateView.as_view(), name='api-keys-validate'),
|
||||
path('api-keys/revalidate/', ApiKeysRevalidateView.as_view(), name='api-keys-revalidate'),
|
||||
path('api-keys/delete/', ApiKeysDeleteView.as_view(), name='api-keys-delete'),
|
||||
|
||||
# Connect endpoints (paid tiers)
|
||||
path('connect/status/', ConnectStatusView.as_view(), name='connect-status'),
|
||||
path('connect/onboard/', ConnectOnboardView.as_view(), name='connect-onboard'),
|
||||
path('connect/refresh-link/', ConnectRefreshLinkView.as_view(), name='connect-refresh-link'),
|
||||
path('connect/account-session/', ConnectAccountSessionView.as_view(), name='connect-account-session'),
|
||||
path('connect/refresh-status/', ConnectRefreshStatusView.as_view(), name='connect-refresh-status'),
|
||||
|
||||
# Transaction endpoints
|
||||
path('transactions/', TransactionListView.as_view(), name='transaction-list'),
|
||||
path('transactions/summary/', TransactionSummaryView.as_view(), name='transaction-summary'),
|
||||
path('transactions/charges/', StripeChargesView.as_view(), name='stripe-charges'),
|
||||
path('transactions/payouts/', StripePayoutsView.as_view(), name='stripe-payouts'),
|
||||
path('transactions/balance/', StripeBalanceView.as_view(), name='stripe-balance'),
|
||||
path('transactions/export/', TransactionExportView.as_view(), name='transaction-export'),
|
||||
|
||||
# Payment operations (existing)
|
||||
path('payment-intents/', CreatePaymentIntentView.as_view(), name='create-payment-intent'),
|
||||
path('terminal/connection-token/', TerminalConnectionTokenView.as_view(), name='terminal-connection-token'),
|
||||
path('refunds/', RefundPaymentView.as_view(), name='create-refund'),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,429 @@
|
||||
"""
|
||||
Management command to seed default subscription plans.
|
||||
|
||||
Usage:
|
||||
python manage.py seed_subscription_plans
|
||||
python manage.py seed_subscription_plans --force # Override existing plans
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from platform_admin.models import SubscriptionPlan
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Seeds default subscription plans for the platform'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--force',
|
||||
action='store_true',
|
||||
help='Override existing plans with the same name',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
force = options['force']
|
||||
|
||||
plans = [
|
||||
# Free Tier
|
||||
{
|
||||
'name': 'Free',
|
||||
'description': 'Perfect for getting started. Try out the core features with no commitment.',
|
||||
'plan_type': 'base',
|
||||
'business_tier': 'Free',
|
||||
'price_monthly': None,
|
||||
'price_yearly': None,
|
||||
'features': [
|
||||
'Up to 2 team members',
|
||||
'Up to 5 resources',
|
||||
'50 appointments per month',
|
||||
'Basic scheduling',
|
||||
'Email notifications',
|
||||
'Mobile-friendly booking page',
|
||||
'Community support',
|
||||
],
|
||||
'limits': {
|
||||
'max_users': 2,
|
||||
'max_resources': 5,
|
||||
'max_appointments': 50,
|
||||
'max_services': 10,
|
||||
'max_automated_tasks': 0,
|
||||
'max_email_templates': 3,
|
||||
},
|
||||
'permissions': {
|
||||
'can_accept_payments': False,
|
||||
'sms_reminders': False,
|
||||
'advanced_reporting': False,
|
||||
'priority_support': False,
|
||||
'can_use_custom_domain': False,
|
||||
'can_create_plugins': False,
|
||||
'can_white_label': False,
|
||||
'can_api_access': False,
|
||||
'can_use_masked_phone_numbers': False,
|
||||
'can_use_email_templates': True,
|
||||
'can_customize_booking_page': False,
|
||||
'can_export_data': False,
|
||||
},
|
||||
'transaction_fee_percent': 0,
|
||||
'transaction_fee_fixed': 0,
|
||||
'sms_enabled': False,
|
||||
'masked_calling_enabled': False,
|
||||
'proxy_number_enabled': False,
|
||||
'is_active': True,
|
||||
'is_public': True,
|
||||
'is_most_popular': False,
|
||||
'show_price': True,
|
||||
},
|
||||
# Starter Tier
|
||||
{
|
||||
'name': 'Starter',
|
||||
'description': 'Great for small businesses ready to grow. Essential tools to manage your appointments.',
|
||||
'plan_type': 'base',
|
||||
'business_tier': 'Starter',
|
||||
'price_monthly': 19.00,
|
||||
'price_yearly': 190.00,
|
||||
'features': [
|
||||
'Up to 5 team members',
|
||||
'Up to 15 resources',
|
||||
'Unlimited appointments',
|
||||
'Online payments (2.9% + $0.30)',
|
||||
'SMS reminders',
|
||||
'Custom booking page colors',
|
||||
'Google Calendar sync',
|
||||
'Basic analytics',
|
||||
'Email support',
|
||||
],
|
||||
'limits': {
|
||||
'max_users': 5,
|
||||
'max_resources': 15,
|
||||
'max_appointments': -1, # Unlimited
|
||||
'max_services': 25,
|
||||
'max_automated_tasks': 3,
|
||||
'max_email_templates': 10,
|
||||
},
|
||||
'permissions': {
|
||||
'can_accept_payments': True,
|
||||
'sms_reminders': True,
|
||||
'advanced_reporting': False,
|
||||
'priority_support': False,
|
||||
'can_use_custom_domain': False,
|
||||
'can_create_plugins': False,
|
||||
'can_white_label': False,
|
||||
'can_api_access': False,
|
||||
'can_use_masked_phone_numbers': False,
|
||||
'can_use_email_templates': True,
|
||||
'can_customize_booking_page': True,
|
||||
'can_export_data': True,
|
||||
},
|
||||
'transaction_fee_percent': 2.9,
|
||||
'transaction_fee_fixed': 0.30,
|
||||
'sms_enabled': True,
|
||||
'sms_price_per_message_cents': 3,
|
||||
'masked_calling_enabled': False,
|
||||
'proxy_number_enabled': False,
|
||||
'is_active': True,
|
||||
'is_public': True,
|
||||
'is_most_popular': False,
|
||||
'show_price': True,
|
||||
},
|
||||
# Professional Tier
|
||||
{
|
||||
'name': 'Professional',
|
||||
'description': 'For growing teams that need powerful automation and customization.',
|
||||
'plan_type': 'base',
|
||||
'business_tier': 'Professional',
|
||||
'price_monthly': 49.00,
|
||||
'price_yearly': 490.00,
|
||||
'features': [
|
||||
'Up to 15 team members',
|
||||
'Unlimited resources',
|
||||
'Unlimited appointments',
|
||||
'Lower payment fees (2.5% + $0.25)',
|
||||
'SMS & masked calling',
|
||||
'Custom domain',
|
||||
'Advanced analytics',
|
||||
'Automated workflows',
|
||||
'Custom email templates',
|
||||
'API access',
|
||||
'Priority email support',
|
||||
],
|
||||
'limits': {
|
||||
'max_users': 15,
|
||||
'max_resources': -1, # Unlimited
|
||||
'max_appointments': -1,
|
||||
'max_services': -1,
|
||||
'max_automated_tasks': 10,
|
||||
'max_email_templates': -1,
|
||||
},
|
||||
'permissions': {
|
||||
'can_accept_payments': True,
|
||||
'sms_reminders': True,
|
||||
'advanced_reporting': True,
|
||||
'priority_support': True,
|
||||
'can_use_custom_domain': True,
|
||||
'can_create_plugins': False,
|
||||
'can_white_label': False,
|
||||
'can_api_access': True,
|
||||
'can_use_masked_phone_numbers': True,
|
||||
'can_use_email_templates': True,
|
||||
'can_customize_booking_page': True,
|
||||
'can_export_data': True,
|
||||
},
|
||||
'transaction_fee_percent': 2.5,
|
||||
'transaction_fee_fixed': 0.25,
|
||||
'sms_enabled': True,
|
||||
'sms_price_per_message_cents': 3,
|
||||
'masked_calling_enabled': True,
|
||||
'masked_calling_price_per_minute_cents': 5,
|
||||
'proxy_number_enabled': True,
|
||||
'proxy_number_monthly_fee_cents': 200,
|
||||
'default_auto_reload_enabled': True,
|
||||
'default_auto_reload_threshold_cents': 1000,
|
||||
'default_auto_reload_amount_cents': 2500,
|
||||
'is_active': True,
|
||||
'is_public': True,
|
||||
'is_most_popular': True,
|
||||
'show_price': True,
|
||||
},
|
||||
# Business Tier
|
||||
{
|
||||
'name': 'Business',
|
||||
'description': 'For established businesses with multiple locations or large teams.',
|
||||
'plan_type': 'base',
|
||||
'business_tier': 'Business',
|
||||
'price_monthly': 99.00,
|
||||
'price_yearly': 990.00,
|
||||
'features': [
|
||||
'Up to 50 team members',
|
||||
'Unlimited resources',
|
||||
'Unlimited appointments',
|
||||
'Lowest payment fees (2.2% + $0.20)',
|
||||
'All communication features',
|
||||
'Multiple custom domains',
|
||||
'White-label option',
|
||||
'Custom plugins',
|
||||
'Advanced automation',
|
||||
'Dedicated onboarding',
|
||||
'Priority phone support',
|
||||
],
|
||||
'limits': {
|
||||
'max_users': 50,
|
||||
'max_resources': -1,
|
||||
'max_appointments': -1,
|
||||
'max_services': -1,
|
||||
'max_automated_tasks': 25,
|
||||
'max_email_templates': -1,
|
||||
},
|
||||
'permissions': {
|
||||
'can_accept_payments': True,
|
||||
'sms_reminders': True,
|
||||
'advanced_reporting': True,
|
||||
'priority_support': True,
|
||||
'can_use_custom_domain': True,
|
||||
'can_create_plugins': True,
|
||||
'can_white_label': True,
|
||||
'can_api_access': True,
|
||||
'can_use_masked_phone_numbers': True,
|
||||
'can_use_email_templates': True,
|
||||
'can_customize_booking_page': True,
|
||||
'can_export_data': True,
|
||||
},
|
||||
'transaction_fee_percent': 2.2,
|
||||
'transaction_fee_fixed': 0.20,
|
||||
'sms_enabled': True,
|
||||
'sms_price_per_message_cents': 2,
|
||||
'masked_calling_enabled': True,
|
||||
'masked_calling_price_per_minute_cents': 4,
|
||||
'proxy_number_enabled': True,
|
||||
'proxy_number_monthly_fee_cents': 150,
|
||||
'default_auto_reload_enabled': True,
|
||||
'default_auto_reload_threshold_cents': 2000,
|
||||
'default_auto_reload_amount_cents': 5000,
|
||||
'is_active': True,
|
||||
'is_public': True,
|
||||
'is_most_popular': False,
|
||||
'show_price': True,
|
||||
},
|
||||
# Enterprise Tier
|
||||
{
|
||||
'name': 'Enterprise',
|
||||
'description': 'Custom solutions for large organizations with complex needs.',
|
||||
'plan_type': 'base',
|
||||
'business_tier': 'Enterprise',
|
||||
'price_monthly': None, # Contact us
|
||||
'price_yearly': None,
|
||||
'features': [
|
||||
'Unlimited team members',
|
||||
'Unlimited everything',
|
||||
'Custom payment terms',
|
||||
'Dedicated infrastructure',
|
||||
'Custom integrations',
|
||||
'SSO/SAML support',
|
||||
'SLA guarantee',
|
||||
'Dedicated account manager',
|
||||
'24/7 priority support',
|
||||
'Custom contracts',
|
||||
],
|
||||
'limits': {
|
||||
'max_users': -1,
|
||||
'max_resources': -1,
|
||||
'max_appointments': -1,
|
||||
'max_services': -1,
|
||||
'max_automated_tasks': -1,
|
||||
'max_email_templates': -1,
|
||||
},
|
||||
'permissions': {
|
||||
'can_accept_payments': True,
|
||||
'sms_reminders': True,
|
||||
'advanced_reporting': True,
|
||||
'priority_support': True,
|
||||
'can_use_custom_domain': True,
|
||||
'can_create_plugins': True,
|
||||
'can_white_label': True,
|
||||
'can_api_access': True,
|
||||
'can_use_masked_phone_numbers': True,
|
||||
'can_use_email_templates': True,
|
||||
'can_customize_booking_page': True,
|
||||
'can_export_data': True,
|
||||
'sso_enabled': True,
|
||||
'dedicated_support': True,
|
||||
},
|
||||
'transaction_fee_percent': 2.0,
|
||||
'transaction_fee_fixed': 0.15,
|
||||
'sms_enabled': True,
|
||||
'sms_price_per_message_cents': 2,
|
||||
'masked_calling_enabled': True,
|
||||
'masked_calling_price_per_minute_cents': 3,
|
||||
'proxy_number_enabled': True,
|
||||
'proxy_number_monthly_fee_cents': 100,
|
||||
'default_auto_reload_enabled': True,
|
||||
'default_auto_reload_threshold_cents': 5000,
|
||||
'default_auto_reload_amount_cents': 10000,
|
||||
'is_active': True,
|
||||
'is_public': True,
|
||||
'is_most_popular': False,
|
||||
'show_price': False, # Contact us
|
||||
},
|
||||
# Add-ons
|
||||
{
|
||||
'name': 'Extra Team Members',
|
||||
'description': 'Add more team members to your plan.',
|
||||
'plan_type': 'addon',
|
||||
'business_tier': '',
|
||||
'price_monthly': 5.00,
|
||||
'price_yearly': 50.00,
|
||||
'features': [
|
||||
'Add 1 additional team member',
|
||||
'Full feature access for new member',
|
||||
],
|
||||
'limits': {
|
||||
'additional_users': 1,
|
||||
},
|
||||
'permissions': {},
|
||||
'transaction_fee_percent': 0,
|
||||
'transaction_fee_fixed': 0,
|
||||
'is_active': True,
|
||||
'is_public': True,
|
||||
'show_price': True,
|
||||
},
|
||||
{
|
||||
'name': 'SMS Bundle',
|
||||
'description': 'Bulk SMS credits at a discounted rate.',
|
||||
'plan_type': 'addon',
|
||||
'business_tier': '',
|
||||
'price_monthly': 20.00,
|
||||
'price_yearly': None,
|
||||
'features': [
|
||||
'1,000 SMS credits',
|
||||
'Never expires',
|
||||
'33% savings vs pay-as-you-go',
|
||||
],
|
||||
'limits': {
|
||||
'sms_credits': 1000,
|
||||
},
|
||||
'permissions': {},
|
||||
'transaction_fee_percent': 0,
|
||||
'transaction_fee_fixed': 0,
|
||||
'is_active': True,
|
||||
'is_public': True,
|
||||
'show_price': True,
|
||||
},
|
||||
{
|
||||
'name': 'Additional Proxy Number',
|
||||
'description': 'Add a dedicated phone number for masked calling.',
|
||||
'plan_type': 'addon',
|
||||
'business_tier': '',
|
||||
'price_monthly': 2.00,
|
||||
'price_yearly': 20.00,
|
||||
'features': [
|
||||
'1 dedicated phone number',
|
||||
'US or Canadian number',
|
||||
'Masked calling support',
|
||||
],
|
||||
'limits': {
|
||||
'additional_proxy_numbers': 1,
|
||||
},
|
||||
'permissions': {},
|
||||
'transaction_fee_percent': 0,
|
||||
'transaction_fee_fixed': 0,
|
||||
'is_active': True,
|
||||
'is_public': True,
|
||||
'show_price': True,
|
||||
},
|
||||
{
|
||||
'name': 'White Label',
|
||||
'description': 'Remove all SmoothSchedule branding from your booking pages.',
|
||||
'plan_type': 'addon',
|
||||
'business_tier': '',
|
||||
'price_monthly': 29.00,
|
||||
'price_yearly': 290.00,
|
||||
'features': [
|
||||
'Remove SmoothSchedule branding',
|
||||
'Custom footer text',
|
||||
'Custom email sender name',
|
||||
],
|
||||
'limits': {},
|
||||
'permissions': {
|
||||
'can_white_label': True,
|
||||
},
|
||||
'transaction_fee_percent': 0,
|
||||
'transaction_fee_fixed': 0,
|
||||
'is_active': True,
|
||||
'is_public': True,
|
||||
'show_price': True,
|
||||
},
|
||||
]
|
||||
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for plan_data in plans:
|
||||
existing = SubscriptionPlan.objects.filter(name=plan_data['name']).first()
|
||||
|
||||
if existing:
|
||||
if force:
|
||||
for key, value in plan_data.items():
|
||||
setattr(existing, key, value)
|
||||
existing.save()
|
||||
updated_count += 1
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f"Updated: {plan_data['name']}")
|
||||
)
|
||||
else:
|
||||
skipped_count += 1
|
||||
self.stdout.write(
|
||||
self.style.NOTICE(f"Skipped (exists): {plan_data['name']}")
|
||||
)
|
||||
else:
|
||||
SubscriptionPlan.objects.create(**plan_data)
|
||||
created_count += 1
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Created: {plan_data['name']}")
|
||||
)
|
||||
|
||||
self.stdout.write('')
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Done! Created: {created_count}, Updated: {updated_count}, Skipped: {skipped_count}"
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-02 02:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('platform_admin', '0009_add_email_check_interval'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='subscriptionplan',
|
||||
name='default_auto_reload_amount_cents',
|
||||
field=models.IntegerField(default=2500, help_text='Default auto-reload amount in cents'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subscriptionplan',
|
||||
name='default_auto_reload_enabled',
|
||||
field=models.BooleanField(default=False, help_text='Whether auto-reload is enabled by default for new tenants'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subscriptionplan',
|
||||
name='default_auto_reload_threshold_cents',
|
||||
field=models.IntegerField(default=1000, help_text='Default auto-reload threshold in cents'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subscriptionplan',
|
||||
name='masked_calling_enabled',
|
||||
field=models.BooleanField(default=False, help_text='Whether masked calling is available for this tier'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subscriptionplan',
|
||||
name='masked_calling_price_per_minute_cents',
|
||||
field=models.IntegerField(default=5, help_text='Price per voice minute in cents'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subscriptionplan',
|
||||
name='proxy_number_enabled',
|
||||
field=models.BooleanField(default=False, help_text='Whether tenants can have dedicated proxy numbers'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subscriptionplan',
|
||||
name='proxy_number_monthly_fee_cents',
|
||||
field=models.IntegerField(default=200, help_text='Monthly fee per proxy number in cents'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subscriptionplan',
|
||||
name='sms_enabled',
|
||||
field=models.BooleanField(default=False, help_text='Whether SMS reminders are available for this tier'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subscriptionplan',
|
||||
name='sms_price_per_message_cents',
|
||||
field=models.IntegerField(default=3, help_text='Price per SMS message in cents'),
|
||||
),
|
||||
]
|
||||
@@ -252,6 +252,50 @@ class SubscriptionPlan(models.Model):
|
||||
help_text="Fixed transaction fee in dollars"
|
||||
)
|
||||
|
||||
# SMS & Communication Settings
|
||||
sms_enabled = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether SMS reminders are available for this tier"
|
||||
)
|
||||
sms_price_per_message_cents = models.IntegerField(
|
||||
default=3, # $0.03
|
||||
help_text="Price per SMS message in cents"
|
||||
)
|
||||
|
||||
# Masked Calling Settings
|
||||
masked_calling_enabled = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether masked calling is available for this tier"
|
||||
)
|
||||
masked_calling_price_per_minute_cents = models.IntegerField(
|
||||
default=5, # $0.05
|
||||
help_text="Price per voice minute in cents"
|
||||
)
|
||||
|
||||
# Proxy Phone Number Settings
|
||||
proxy_number_enabled = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether tenants can have dedicated proxy numbers"
|
||||
)
|
||||
proxy_number_monthly_fee_cents = models.IntegerField(
|
||||
default=200, # $2.00
|
||||
help_text="Monthly fee per proxy number in cents"
|
||||
)
|
||||
|
||||
# Default Credit Settings (for new tenants on this tier)
|
||||
default_auto_reload_enabled = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether auto-reload is enabled by default for new tenants"
|
||||
)
|
||||
default_auto_reload_threshold_cents = models.IntegerField(
|
||||
default=1000, # $10
|
||||
help_text="Default auto-reload threshold in cents"
|
||||
)
|
||||
default_auto_reload_amount_cents = models.IntegerField(
|
||||
default=2500, # $25
|
||||
help_text="Default auto-reload amount in cents"
|
||||
)
|
||||
|
||||
# Visibility
|
||||
is_active = models.BooleanField(default=True)
|
||||
is_public = models.BooleanField(
|
||||
|
||||
1106
smoothschedule/schedule/email_template_presets.py
Normal file
1106
smoothschedule/schedule/email_template_presets.py
Normal file
File diff suppressed because it is too large
Load Diff
1829
smoothschedule/schedule/management/commands/seed_email_templates.py
Normal file
1829
smoothschedule/schedule/management/commands/seed_email_templates.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -219,10 +219,34 @@ class EventSerializer(serializers.ModelSerializer):
|
||||
Serializer for Event model with availability validation.
|
||||
|
||||
CRITICAL: Validates resource availability before saving via AvailabilityService.
|
||||
|
||||
Status mapping (frontend -> backend):
|
||||
- PENDING -> SCHEDULED
|
||||
- CONFIRMED -> SCHEDULED
|
||||
- CANCELLED -> CANCELED
|
||||
- NO_SHOW -> NOSHOW
|
||||
"""
|
||||
# Status mapping: frontend value -> backend value
|
||||
STATUS_MAPPING = {
|
||||
'PENDING': 'SCHEDULED',
|
||||
'CONFIRMED': 'SCHEDULED',
|
||||
'CANCELLED': 'CANCELED',
|
||||
'NO_SHOW': 'NOSHOW',
|
||||
}
|
||||
|
||||
# Reverse mapping for serialization: backend value -> frontend value
|
||||
STATUS_REVERSE_MAPPING = {
|
||||
'SCHEDULED': 'CONFIRMED',
|
||||
'CANCELED': 'CANCELLED',
|
||||
'NOSHOW': 'NO_SHOW',
|
||||
}
|
||||
|
||||
participants = ParticipantSerializer(many=True, read_only=True)
|
||||
duration_minutes = serializers.SerializerMethodField()
|
||||
|
||||
# Override status field to allow frontend values
|
||||
status = serializers.CharField(required=False)
|
||||
|
||||
# Simplified fields for frontend compatibility
|
||||
resource_id = serializers.SerializerMethodField()
|
||||
customer_id = serializers.SerializerMethodField()
|
||||
@@ -292,7 +316,27 @@ class EventSerializer(serializers.ModelSerializer):
|
||||
def get_is_paid(self, obj):
|
||||
"""Check if event is paid"""
|
||||
return obj.status == 'PAID'
|
||||
|
||||
|
||||
def validate_status(self, value):
|
||||
"""Map frontend status values to backend values"""
|
||||
if value in self.STATUS_MAPPING:
|
||||
return self.STATUS_MAPPING[value]
|
||||
# Accept backend values directly
|
||||
valid_backend_statuses = [s.value for s in Event.Status]
|
||||
if value in valid_backend_statuses:
|
||||
return value
|
||||
raise serializers.ValidationError(
|
||||
f'Invalid status "{value}". Valid values are: '
|
||||
f'{", ".join(list(self.STATUS_MAPPING.keys()) + valid_backend_statuses)}'
|
||||
)
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""Map backend status values to frontend values when serializing"""
|
||||
data = super().to_representation(instance)
|
||||
if 'status' in data and data['status'] in self.STATUS_REVERSE_MAPPING:
|
||||
data['status'] = self.STATUS_REVERSE_MAPPING[data['status']]
|
||||
return data
|
||||
|
||||
def validate(self, attrs):
|
||||
"""
|
||||
Validate event timing and resource availability.
|
||||
|
||||
@@ -258,11 +258,14 @@ class ServiceViewSet(viewsets.ModelViewSet):
|
||||
API endpoint for managing Services.
|
||||
|
||||
Services are the offerings a business provides (e.g., Haircut, Massage).
|
||||
|
||||
Permissions:
|
||||
- Subject to MAX_SERVICES quota (hard block on creation)
|
||||
"""
|
||||
queryset = Service.objects.filter(is_active=True)
|
||||
serializer_class = ServiceSerializer
|
||||
# TODO: Re-enable authentication for production
|
||||
permission_classes = [AllowAny] # Temporarily allow unauthenticated access for development
|
||||
permission_classes = [AllowAny, HasQuota('MAX_SERVICES')] # Temporarily allow unauthenticated access for development
|
||||
|
||||
filterset_fields = ['is_active']
|
||||
search_fields = ['name', 'description']
|
||||
@@ -1416,9 +1419,14 @@ class EmailTemplateViewSet(viewsets.ModelViewSet):
|
||||
{
|
||||
'category': 'Appointment',
|
||||
'items': [
|
||||
{'code': '{{APPOINTMENT_TIME}}', 'description': 'Full date and time'},
|
||||
{'code': '{{APPOINTMENT_DATE}}', 'description': 'Date only'},
|
||||
{'code': '{{APPOINTMENT_SERVICE}}', 'description': 'Service name'},
|
||||
{'code': '{{EVENT_START_DATETIME}}', 'description': 'Full date and time'},
|
||||
{'code': '{{EVENT_START_DATE}}', 'description': 'Date only'},
|
||||
{'code': '{{EVENT_START_TIME}}', 'description': 'Time only'},
|
||||
{'code': '{{EVENT_ID}}', 'description': 'Event/Appointment ID'},
|
||||
{'code': '{{SERVICE_NAME}}', 'description': 'Service name'},
|
||||
{'code': '{{SERVICE_DURATION}}', 'description': 'Service duration'},
|
||||
{'code': '{{SERVICE_PRICE}}', 'description': 'Service price'},
|
||||
{'code': '{{STAFF_NAME}}', 'description': 'Staff member name'},
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1430,4 +1438,51 @@ class EmailTemplateViewSet(viewsets.ModelViewSet):
|
||||
},
|
||||
],
|
||||
'categories': [choice[0] for choice in EmailTemplate.Category.choices],
|
||||
})
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def presets(self, request):
|
||||
"""
|
||||
Get pre-built email template presets organized by category.
|
||||
|
||||
Users can select a preset and customize it to create their own template.
|
||||
Each category has multiple style variations (professional, friendly, minimalist).
|
||||
|
||||
Query params:
|
||||
- category: Filter presets by category (APPOINTMENT, REMINDER, etc.)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"presets": {
|
||||
"APPOINTMENT": [
|
||||
{
|
||||
"name": "Appointment Confirmation - Professional",
|
||||
"description": "Clean, professional...",
|
||||
"style": "professional",
|
||||
"subject": "...",
|
||||
"html_content": "...",
|
||||
"text_content": "..."
|
||||
},
|
||||
...
|
||||
],
|
||||
...
|
||||
}
|
||||
}
|
||||
"""
|
||||
from .email_template_presets import get_presets_by_category, get_all_presets
|
||||
|
||||
category = request.query_params.get('category')
|
||||
|
||||
if category:
|
||||
# Return presets for specific category
|
||||
category_upper = category.upper()
|
||||
presets = get_presets_by_category(category_upper)
|
||||
return Response({
|
||||
'category': category_upper,
|
||||
'presets': presets
|
||||
})
|
||||
else:
|
||||
# Return all presets organized by category
|
||||
return Response({
|
||||
'presets': get_all_presets()
|
||||
})
|
||||
83
smoothschedule/smoothschedule/comms_credits/admin.py
Normal file
83
smoothschedule/smoothschedule/comms_credits/admin.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from django.contrib import admin
|
||||
from .models import CommunicationCredits, CreditTransaction, ProxyPhoneNumber, MaskedSession
|
||||
|
||||
|
||||
@admin.register(CommunicationCredits)
|
||||
class CommunicationCreditsAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'tenant',
|
||||
'balance_display',
|
||||
'auto_reload_enabled',
|
||||
'auto_reload_threshold_display',
|
||||
'last_twilio_sync_at',
|
||||
]
|
||||
list_filter = ['auto_reload_enabled']
|
||||
search_fields = ['tenant__name']
|
||||
readonly_fields = [
|
||||
'total_loaded_cents', 'total_spent_cents',
|
||||
'last_twilio_sync_at', 'created_at', 'updated_at'
|
||||
]
|
||||
|
||||
def balance_display(self, obj):
|
||||
return f"${obj.balance_cents/100:.2f}"
|
||||
balance_display.short_description = 'Balance'
|
||||
|
||||
def auto_reload_threshold_display(self, obj):
|
||||
return f"${obj.auto_reload_threshold_cents/100:.2f}"
|
||||
auto_reload_threshold_display.short_description = 'Reload Threshold'
|
||||
|
||||
|
||||
@admin.register(CreditTransaction)
|
||||
class CreditTransactionAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'created_at',
|
||||
'credits',
|
||||
'amount_display',
|
||||
'transaction_type',
|
||||
'description',
|
||||
]
|
||||
list_filter = ['transaction_type', 'created_at']
|
||||
search_fields = ['credits__tenant__name', 'description']
|
||||
readonly_fields = ['created_at']
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
def amount_display(self, obj):
|
||||
sign = '+' if obj.amount_cents > 0 else ''
|
||||
return f"{sign}${obj.amount_cents/100:.2f}"
|
||||
amount_display.short_description = 'Amount'
|
||||
|
||||
|
||||
@admin.register(ProxyPhoneNumber)
|
||||
class ProxyPhoneNumberAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'phone_number',
|
||||
'status',
|
||||
'assigned_tenant',
|
||||
'monthly_fee_display',
|
||||
'is_active',
|
||||
]
|
||||
list_filter = ['status', 'is_active']
|
||||
search_fields = ['phone_number', 'assigned_tenant__name']
|
||||
readonly_fields = ['twilio_sid', 'created_at', 'updated_at']
|
||||
|
||||
def monthly_fee_display(self, obj):
|
||||
return f"${obj.monthly_fee_cents/100:.2f}"
|
||||
monthly_fee_display.short_description = 'Monthly Fee'
|
||||
|
||||
|
||||
@admin.register(MaskedSession)
|
||||
class MaskedSessionAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'id',
|
||||
'tenant',
|
||||
'status',
|
||||
'customer_phone',
|
||||
'staff_phone',
|
||||
'sms_count',
|
||||
'created_at',
|
||||
'expires_at',
|
||||
]
|
||||
list_filter = ['status', 'created_at']
|
||||
search_fields = ['tenant__name', 'customer_phone', 'staff_phone']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
date_hierarchy = 'created_at'
|
||||
7
smoothschedule/smoothschedule/comms_credits/apps.py
Normal file
7
smoothschedule/smoothschedule/comms_credits/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CommsCreditsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'smoothschedule.comms_credits'
|
||||
verbose_name = 'Communication Credits'
|
||||
@@ -0,0 +1,107 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-02 02:56
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('core', '0011_tenant_twilio_phone_number_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CommunicationCredits',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('balance_cents', models.IntegerField(default=0, help_text='Current credit balance in cents')),
|
||||
('auto_reload_enabled', models.BooleanField(default=False, help_text='Automatically reload credits when balance falls below threshold')),
|
||||
('auto_reload_threshold_cents', models.IntegerField(default=1000, help_text='Reload when balance falls below this amount (cents)')),
|
||||
('auto_reload_amount_cents', models.IntegerField(default=2500, help_text='Amount to reload (cents)')),
|
||||
('low_balance_warning_cents', models.IntegerField(default=500, help_text='Send warning email when balance falls below this amount (cents)')),
|
||||
('low_balance_warning_sent', models.BooleanField(default=False, help_text='Whether low balance warning has been sent (reset on reload)')),
|
||||
('low_balance_warning_sent_at', models.DateTimeField(blank=True, help_text='When the last low balance warning was sent', null=True)),
|
||||
('stripe_payment_method_id', models.CharField(blank=True, default='', help_text='Stripe Payment Method ID for auto-reload', max_length=255)),
|
||||
('last_twilio_sync_at', models.DateTimeField(blank=True, help_text='When usage was last synced from Twilio', null=True)),
|
||||
('twilio_sync_period_start', models.DateField(blank=True, help_text='Start of current Twilio billing period', null=True)),
|
||||
('twilio_raw_usage_cents', models.IntegerField(default=0, help_text='Raw Twilio cost for current period (cents)')),
|
||||
('billed_usage_cents', models.IntegerField(default=0, help_text='Amount already deducted from credits for current period (cents)')),
|
||||
('total_loaded_cents', models.IntegerField(default=0, help_text='Total credits ever loaded (cents)')),
|
||||
('total_spent_cents', models.IntegerField(default=0, help_text='Total credits ever spent (cents)')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('tenant', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='communication_credits', to='core.tenant')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Communication Credits',
|
||||
'verbose_name_plural': 'Communication Credits',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProxyPhoneNumber',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('phone_number', models.CharField(help_text='E.164 format phone number', max_length=20, unique=True)),
|
||||
('twilio_sid', models.CharField(help_text='Twilio Phone Number SID', max_length=50)),
|
||||
('status', models.CharField(choices=[('available', 'Available'), ('assigned', 'Assigned to Tenant'), ('reserved', 'Reserved for Session'), ('inactive', 'Inactive')], default='available', max_length=20)),
|
||||
('assigned_at', models.DateTimeField(blank=True, null=True)),
|
||||
('monthly_fee_cents', models.IntegerField(default=200, help_text='Monthly fee charged to tenant (cents)')),
|
||||
('last_billed_at', models.DateTimeField(blank=True, help_text='When this number was last billed', null=True)),
|
||||
('friendly_name', models.CharField(blank=True, default='', help_text='Friendly name for the number', max_length=100)),
|
||||
('capabilities', models.JSONField(blank=True, default=dict, help_text='Twilio capabilities (voice, sms, mms)')),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('assigned_tenant', models.ForeignKey(blank=True, help_text='Tenant this number is assigned to', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='proxy_phone_numbers', to='core.tenant')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Proxy Phone Number',
|
||||
'verbose_name_plural': 'Proxy Phone Numbers',
|
||||
'ordering': ['phone_number'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CreditTransaction',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('amount_cents', models.IntegerField(help_text='Amount in cents (positive=credit, negative=debit)')),
|
||||
('balance_after_cents', models.IntegerField(help_text='Balance after this transaction (cents)')),
|
||||
('transaction_type', models.CharField(choices=[('manual', 'Manual Top-up'), ('auto_reload', 'Auto Reload'), ('usage', 'Usage'), ('refund', 'Refund'), ('adjustment', 'Adjustment'), ('promo', 'Promotional Credit')], default='usage', max_length=20)),
|
||||
('description', models.CharField(blank=True, default='', max_length=255)),
|
||||
('reference_type', models.CharField(blank=True, default='', help_text='Type: sms, voice, proxy_number, etc.', max_length=50)),
|
||||
('reference_id', models.CharField(blank=True, default='', help_text='External reference (Twilio SID, etc.)', max_length=100)),
|
||||
('stripe_charge_id', models.CharField(blank=True, default='', help_text='Stripe Charge/PaymentIntent ID', max_length=255)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('credits', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='comms_credits.communicationcredits')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['credits', '-created_at'], name='comms_credi_credits_ae1f83_idx'), models.Index(fields=['transaction_type', '-created_at'], name='comms_credi_transac_c0fc69_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MaskedSession',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('event_id', models.IntegerField(blank=True, help_text='ID of the Event this session is for (in tenant schema)', null=True)),
|
||||
('customer_phone', models.CharField(help_text="Customer's real phone number", max_length=20)),
|
||||
('staff_phone', models.CharField(help_text="Staff member's real phone number", max_length=20)),
|
||||
('status', models.CharField(choices=[('active', 'Active'), ('closed', 'Closed'), ('expired', 'Expired')], default='active', max_length=20)),
|
||||
('expires_at', models.DateTimeField(help_text='When this session automatically expires')),
|
||||
('closed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('sms_count', models.IntegerField(default=0, help_text='Number of SMS messages sent through this session')),
|
||||
('voice_seconds', models.IntegerField(default=0, help_text='Total voice call duration in seconds')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='masked_sessions', to='core.tenant')),
|
||||
('proxy_number', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sessions', to='comms_credits.proxyphonenumber')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['tenant', 'status'], name='comms_credi_tenant__9ddf1c_idx'), models.Index(fields=['proxy_number', 'status'], name='comms_credi_proxy_n_e0fe91_idx'), models.Index(fields=['expires_at'], name='comms_credi_expires_6dd7c7_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-02 04:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('comms_credits', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='communicationcredits',
|
||||
name='stripe_customer_id',
|
||||
field=models.CharField(blank=True, default='', help_text='Stripe Customer ID for this tenant', max_length=255),
|
||||
),
|
||||
]
|
||||
532
smoothschedule/smoothschedule/comms_credits/models.py
Normal file
532
smoothschedule/smoothschedule/comms_credits/models.py
Normal file
@@ -0,0 +1,532 @@
|
||||
"""
|
||||
Communication Credits Models
|
||||
|
||||
Prepaid credit system for SMS and voice services.
|
||||
Integrates with Twilio subaccounts for usage tracking.
|
||||
"""
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class CommunicationCredits(models.Model):
|
||||
"""
|
||||
Prepaid communication credits for a tenant.
|
||||
|
||||
Credits are used for:
|
||||
- SMS reminders to customers and staff
|
||||
- Masked calling/SMS through proxy numbers
|
||||
- Voice minutes
|
||||
|
||||
Usage is tracked via Twilio subaccounts and synced periodically.
|
||||
"""
|
||||
tenant = models.OneToOneField(
|
||||
'core.Tenant',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='communication_credits'
|
||||
)
|
||||
|
||||
# Current balance (stored in cents for precision)
|
||||
balance_cents = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Current credit balance in cents"
|
||||
)
|
||||
|
||||
# Auto-reload settings
|
||||
auto_reload_enabled = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Automatically reload credits when balance falls below threshold"
|
||||
)
|
||||
auto_reload_threshold_cents = models.IntegerField(
|
||||
default=1000, # $10
|
||||
help_text="Reload when balance falls below this amount (cents)"
|
||||
)
|
||||
auto_reload_amount_cents = models.IntegerField(
|
||||
default=2500, # $25
|
||||
help_text="Amount to reload (cents)"
|
||||
)
|
||||
|
||||
# Notification settings
|
||||
low_balance_warning_cents = models.IntegerField(
|
||||
default=500, # $5
|
||||
help_text="Send warning email when balance falls below this amount (cents)"
|
||||
)
|
||||
low_balance_warning_sent = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether low balance warning has been sent (reset on reload)"
|
||||
)
|
||||
low_balance_warning_sent_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="When the last low balance warning was sent"
|
||||
)
|
||||
|
||||
# Stripe integration
|
||||
stripe_customer_id = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="Stripe Customer ID for this tenant"
|
||||
)
|
||||
stripe_payment_method_id = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="Stripe Payment Method ID for auto-reload"
|
||||
)
|
||||
|
||||
# Twilio usage sync tracking
|
||||
last_twilio_sync_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="When usage was last synced from Twilio"
|
||||
)
|
||||
twilio_sync_period_start = models.DateField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Start of current Twilio billing period"
|
||||
)
|
||||
twilio_raw_usage_cents = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Raw Twilio cost for current period (cents)"
|
||||
)
|
||||
billed_usage_cents = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Amount already deducted from credits for current period (cents)"
|
||||
)
|
||||
|
||||
# Lifetime stats
|
||||
total_loaded_cents = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Total credits ever loaded (cents)"
|
||||
)
|
||||
total_spent_cents = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Total credits ever spent (cents)"
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Communication Credits'
|
||||
verbose_name_plural = 'Communication Credits'
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.tenant.name} - ${self.balance_cents/100:.2f}"
|
||||
|
||||
@property
|
||||
def balance(self):
|
||||
"""Balance in dollars"""
|
||||
return self.balance_cents / 100
|
||||
|
||||
@property
|
||||
def auto_reload_threshold(self):
|
||||
"""Threshold in dollars"""
|
||||
return self.auto_reload_threshold_cents / 100
|
||||
|
||||
@property
|
||||
def auto_reload_amount(self):
|
||||
"""Reload amount in dollars"""
|
||||
return self.auto_reload_amount_cents / 100
|
||||
|
||||
def deduct(self, amount_cents, description, reference_type=None, reference_id=None):
|
||||
"""
|
||||
Deduct credits from balance.
|
||||
|
||||
Args:
|
||||
amount_cents: Amount to deduct in cents
|
||||
description: Human-readable description
|
||||
reference_type: Type of reference (e.g., 'sms', 'voice', 'proxy_number')
|
||||
reference_id: External ID (e.g., Twilio SID)
|
||||
|
||||
Returns:
|
||||
CreditTransaction if successful, None if insufficient balance
|
||||
"""
|
||||
if self.balance_cents < amount_cents:
|
||||
return None
|
||||
|
||||
self.balance_cents -= amount_cents
|
||||
self.total_spent_cents += amount_cents
|
||||
self.save(update_fields=['balance_cents', 'total_spent_cents', 'updated_at'])
|
||||
|
||||
# Create transaction record
|
||||
transaction = CreditTransaction.objects.create(
|
||||
credits=self,
|
||||
amount_cents=-amount_cents,
|
||||
balance_after_cents=self.balance_cents,
|
||||
transaction_type='usage',
|
||||
description=description,
|
||||
reference_type=reference_type or '',
|
||||
reference_id=reference_id or '',
|
||||
)
|
||||
|
||||
# Check thresholds
|
||||
self._check_thresholds()
|
||||
|
||||
return transaction
|
||||
|
||||
def add_credits(self, amount_cents, transaction_type='manual',
|
||||
stripe_charge_id=None, description=None):
|
||||
"""
|
||||
Add credits to balance.
|
||||
|
||||
Args:
|
||||
amount_cents: Amount to add in cents
|
||||
transaction_type: 'manual', 'auto_reload', 'refund', 'adjustment'
|
||||
stripe_charge_id: Stripe charge ID if applicable
|
||||
description: Optional description
|
||||
"""
|
||||
self.balance_cents += amount_cents
|
||||
self.total_loaded_cents += amount_cents
|
||||
self.low_balance_warning_sent = False # Reset warning flag
|
||||
self.save(update_fields=[
|
||||
'balance_cents', 'total_loaded_cents',
|
||||
'low_balance_warning_sent', 'updated_at'
|
||||
])
|
||||
|
||||
# Create transaction record
|
||||
CreditTransaction.objects.create(
|
||||
credits=self,
|
||||
amount_cents=amount_cents,
|
||||
balance_after_cents=self.balance_cents,
|
||||
transaction_type=transaction_type,
|
||||
description=description or f"Credits added ({transaction_type})",
|
||||
stripe_charge_id=stripe_charge_id or '',
|
||||
)
|
||||
|
||||
def _check_thresholds(self):
|
||||
"""Check and trigger warnings/auto-reload."""
|
||||
# Low balance warning
|
||||
if (self.balance_cents <= self.low_balance_warning_cents
|
||||
and not self.low_balance_warning_sent):
|
||||
self._send_low_balance_warning()
|
||||
|
||||
# Auto-reload
|
||||
if (self.auto_reload_enabled
|
||||
and self.balance_cents <= self.auto_reload_threshold_cents
|
||||
and self.stripe_payment_method_id):
|
||||
self._trigger_auto_reload()
|
||||
|
||||
def _send_low_balance_warning(self):
|
||||
"""Send low balance warning email."""
|
||||
from smoothschedule.comms_credits.tasks import send_low_balance_warning
|
||||
send_low_balance_warning.delay(self.id)
|
||||
|
||||
self.low_balance_warning_sent = True
|
||||
self.low_balance_warning_sent_at = timezone.now()
|
||||
self.save(update_fields=['low_balance_warning_sent', 'low_balance_warning_sent_at'])
|
||||
|
||||
def _trigger_auto_reload(self):
|
||||
"""Trigger auto-reload of credits."""
|
||||
from smoothschedule.comms_credits.tasks import process_auto_reload
|
||||
process_auto_reload.delay(self.id)
|
||||
|
||||
|
||||
class CreditTransaction(models.Model):
|
||||
"""
|
||||
Transaction history for communication credits.
|
||||
|
||||
Tracks all credit additions and deductions for auditing
|
||||
and billing reconciliation.
|
||||
"""
|
||||
|
||||
class TransactionType(models.TextChoices):
|
||||
MANUAL = 'manual', 'Manual Top-up'
|
||||
AUTO_RELOAD = 'auto_reload', 'Auto Reload'
|
||||
USAGE = 'usage', 'Usage'
|
||||
REFUND = 'refund', 'Refund'
|
||||
ADJUSTMENT = 'adjustment', 'Adjustment'
|
||||
PROMO = 'promo', 'Promotional Credit'
|
||||
|
||||
credits = models.ForeignKey(
|
||||
CommunicationCredits,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='transactions'
|
||||
)
|
||||
|
||||
# Amount (positive = add, negative = deduct)
|
||||
amount_cents = models.IntegerField(
|
||||
help_text="Amount in cents (positive=credit, negative=debit)"
|
||||
)
|
||||
balance_after_cents = models.IntegerField(
|
||||
help_text="Balance after this transaction (cents)"
|
||||
)
|
||||
|
||||
# Transaction type
|
||||
transaction_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=TransactionType.choices,
|
||||
default=TransactionType.USAGE
|
||||
)
|
||||
|
||||
# Description and reference
|
||||
description = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default=''
|
||||
)
|
||||
reference_type = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="Type: sms, voice, proxy_number, etc."
|
||||
)
|
||||
reference_id = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="External reference (Twilio SID, etc.)"
|
||||
)
|
||||
|
||||
# Stripe integration
|
||||
stripe_charge_id = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="Stripe Charge/PaymentIntent ID"
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['credits', '-created_at']),
|
||||
models.Index(fields=['transaction_type', '-created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
sign = '+' if self.amount_cents > 0 else ''
|
||||
return f"{sign}${self.amount_cents/100:.2f} - {self.description}"
|
||||
|
||||
@property
|
||||
def amount(self):
|
||||
"""Amount in dollars"""
|
||||
return self.amount_cents / 100
|
||||
|
||||
|
||||
class ProxyPhoneNumber(models.Model):
|
||||
"""
|
||||
Pool of Twilio phone numbers for masked calling.
|
||||
|
||||
Numbers can be:
|
||||
- Unassigned (in pool, available)
|
||||
- Assigned to a tenant (dedicated line)
|
||||
- Reserved for a specific session (temporary masking)
|
||||
"""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
AVAILABLE = 'available', 'Available'
|
||||
ASSIGNED = 'assigned', 'Assigned to Tenant'
|
||||
RESERVED = 'reserved', 'Reserved for Session'
|
||||
INACTIVE = 'inactive', 'Inactive'
|
||||
|
||||
phone_number = models.CharField(
|
||||
max_length=20,
|
||||
unique=True,
|
||||
help_text="E.164 format phone number"
|
||||
)
|
||||
twilio_sid = models.CharField(
|
||||
max_length=50,
|
||||
help_text="Twilio Phone Number SID"
|
||||
)
|
||||
|
||||
# Status
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.AVAILABLE
|
||||
)
|
||||
|
||||
# Assignment
|
||||
assigned_tenant = models.ForeignKey(
|
||||
'core.Tenant',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='proxy_phone_numbers',
|
||||
help_text="Tenant this number is assigned to"
|
||||
)
|
||||
assigned_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
|
||||
# Billing
|
||||
monthly_fee_cents = models.IntegerField(
|
||||
default=200, # $2.00
|
||||
help_text="Monthly fee charged to tenant (cents)"
|
||||
)
|
||||
last_billed_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="When this number was last billed"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
friendly_name = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="Friendly name for the number"
|
||||
)
|
||||
capabilities = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Twilio capabilities (voice, sms, mms)"
|
||||
)
|
||||
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['phone_number']
|
||||
verbose_name = 'Proxy Phone Number'
|
||||
verbose_name_plural = 'Proxy Phone Numbers'
|
||||
|
||||
def __str__(self):
|
||||
tenant_info = f" ({self.assigned_tenant.name})" if self.assigned_tenant else ""
|
||||
return f"{self.phone_number}{tenant_info}"
|
||||
|
||||
def assign_to_tenant(self, tenant):
|
||||
"""Assign this number to a tenant."""
|
||||
self.assigned_tenant = tenant
|
||||
self.assigned_at = timezone.now()
|
||||
self.status = self.Status.ASSIGNED
|
||||
self.save(update_fields=['assigned_tenant', 'assigned_at', 'status', 'updated_at'])
|
||||
|
||||
def release(self):
|
||||
"""Release this number back to the pool."""
|
||||
self.assigned_tenant = None
|
||||
self.assigned_at = None
|
||||
self.status = self.Status.AVAILABLE
|
||||
self.save(update_fields=['assigned_tenant', 'assigned_at', 'status', 'updated_at'])
|
||||
|
||||
|
||||
class MaskedSession(models.Model):
|
||||
"""
|
||||
Temporary masked communication session between parties.
|
||||
|
||||
Links a customer and staff member through a proxy number
|
||||
for the duration of an appointment/event.
|
||||
"""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
ACTIVE = 'active', 'Active'
|
||||
CLOSED = 'closed', 'Closed'
|
||||
EXPIRED = 'expired', 'Expired'
|
||||
|
||||
tenant = models.ForeignKey(
|
||||
'core.Tenant',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='masked_sessions'
|
||||
)
|
||||
|
||||
# The event this session is for (stored as ID since Event is in tenant schema)
|
||||
event_id = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="ID of the Event this session is for (in tenant schema)"
|
||||
)
|
||||
|
||||
# Proxy number used for this session
|
||||
proxy_number = models.ForeignKey(
|
||||
ProxyPhoneNumber,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name='sessions'
|
||||
)
|
||||
|
||||
# Participant phone numbers
|
||||
customer_phone = models.CharField(
|
||||
max_length=20,
|
||||
help_text="Customer's real phone number"
|
||||
)
|
||||
staff_phone = models.CharField(
|
||||
max_length=20,
|
||||
help_text="Staff member's real phone number"
|
||||
)
|
||||
|
||||
# Status
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.ACTIVE
|
||||
)
|
||||
|
||||
# Timing
|
||||
expires_at = models.DateTimeField(
|
||||
help_text="When this session automatically expires"
|
||||
)
|
||||
closed_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
|
||||
# Usage tracking
|
||||
sms_count = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Number of SMS messages sent through this session"
|
||||
)
|
||||
voice_seconds = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Total voice call duration in seconds"
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['tenant', 'status']),
|
||||
models.Index(fields=['proxy_number', 'status']),
|
||||
models.Index(fields=['expires_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Session {self.id}: {self.customer_phone} <-> {self.staff_phone}"
|
||||
|
||||
def is_active(self):
|
||||
"""Check if session is still active."""
|
||||
if self.status != self.Status.ACTIVE:
|
||||
return False
|
||||
if timezone.now() >= self.expires_at:
|
||||
return False
|
||||
return True
|
||||
|
||||
def close(self):
|
||||
"""Close this session."""
|
||||
self.status = self.Status.CLOSED
|
||||
self.closed_at = timezone.now()
|
||||
self.save(update_fields=['status', 'closed_at', 'updated_at'])
|
||||
|
||||
# Release proxy number back to pool if it was reserved for this session
|
||||
if self.proxy_number and self.proxy_number.status == ProxyPhoneNumber.Status.RESERVED:
|
||||
self.proxy_number.status = ProxyPhoneNumber.Status.AVAILABLE
|
||||
self.proxy_number.save(update_fields=['status', 'updated_at'])
|
||||
|
||||
def get_destination_for_caller(self, caller_phone):
|
||||
"""
|
||||
Determine where to route a call/SMS based on who's calling.
|
||||
|
||||
Args:
|
||||
caller_phone: The phone number of the caller
|
||||
|
||||
Returns:
|
||||
The phone number to forward to, or None if caller not in session
|
||||
"""
|
||||
# Normalize phone numbers for comparison
|
||||
caller_normalized = caller_phone.replace('+', '').replace('-', '').replace(' ', '')
|
||||
customer_normalized = self.customer_phone.replace('+', '').replace('-', '').replace(' ', '')
|
||||
staff_normalized = self.staff_phone.replace('+', '').replace('-', '').replace(' ', '')
|
||||
|
||||
if caller_normalized.endswith(customer_normalized) or customer_normalized.endswith(caller_normalized):
|
||||
return self.staff_phone
|
||||
elif caller_normalized.endswith(staff_normalized) or staff_normalized.endswith(caller_normalized):
|
||||
return self.customer_phone
|
||||
return None
|
||||
418
smoothschedule/smoothschedule/comms_credits/tasks.py
Normal file
418
smoothschedule/smoothschedule/comms_credits/tasks.py
Normal file
@@ -0,0 +1,418 @@
|
||||
"""
|
||||
Celery tasks for communication credits and Twilio integration.
|
||||
"""
|
||||
import logging
|
||||
from celery import shared_task
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task
|
||||
def sync_twilio_usage_all_tenants():
|
||||
"""
|
||||
Sync Twilio usage for all tenants with subaccounts.
|
||||
|
||||
Runs periodically (e.g., hourly) to:
|
||||
1. Fetch usage from each Twilio subaccount
|
||||
2. Calculate charges with markup
|
||||
3. Deduct from tenant credits
|
||||
"""
|
||||
from core.models import Tenant
|
||||
|
||||
tenants = Tenant.objects.exclude(twilio_subaccount_sid='')
|
||||
|
||||
synced = 0
|
||||
errors = 0
|
||||
|
||||
for tenant in tenants:
|
||||
try:
|
||||
sync_twilio_usage_for_tenant.delay(tenant.id)
|
||||
synced += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Error queuing sync for tenant {tenant.name}: {e}")
|
||||
errors += 1
|
||||
|
||||
logger.info(f"Queued Twilio sync for {synced} tenants, {errors} errors")
|
||||
return {'synced': synced, 'errors': errors}
|
||||
|
||||
|
||||
@shared_task
|
||||
def sync_twilio_usage_for_tenant(tenant_id):
|
||||
"""
|
||||
Sync Twilio usage for a specific tenant.
|
||||
|
||||
Fetches usage from Twilio API and deducts from credits.
|
||||
"""
|
||||
from core.models import Tenant
|
||||
from .models import CommunicationCredits
|
||||
|
||||
try:
|
||||
tenant = Tenant.objects.get(id=tenant_id)
|
||||
except Tenant.DoesNotExist:
|
||||
logger.error(f"Tenant {tenant_id} not found")
|
||||
return {'error': 'Tenant not found'}
|
||||
|
||||
if not tenant.twilio_subaccount_sid:
|
||||
return {'error': 'No Twilio subaccount configured'}
|
||||
|
||||
# Get or create credits for tenant
|
||||
credits, created = CommunicationCredits.objects.get_or_create(tenant=tenant)
|
||||
|
||||
try:
|
||||
from twilio.rest import Client
|
||||
|
||||
# Initialize Twilio client with subaccount credentials
|
||||
client = Client(
|
||||
tenant.twilio_subaccount_sid,
|
||||
tenant.twilio_subaccount_auth_token
|
||||
)
|
||||
|
||||
# Get this month's usage
|
||||
today = timezone.now().date()
|
||||
first_of_month = today.replace(day=1)
|
||||
|
||||
records = client.usage.records.this_month.list()
|
||||
|
||||
# Calculate total Twilio cost
|
||||
total_twilio_cost_cents = 0
|
||||
usage_breakdown = {}
|
||||
|
||||
for record in records:
|
||||
cost_cents = int(float(record.price) * 100)
|
||||
total_twilio_cost_cents += cost_cents
|
||||
usage_breakdown[record.category] = {
|
||||
'usage': str(record.usage),
|
||||
'cost_cents': cost_cents,
|
||||
}
|
||||
|
||||
# Apply markup (e.g., 50% margin)
|
||||
# This should come from the tenant's subscription plan
|
||||
markup_multiplier = getattr(settings, 'COMMS_MARKUP_MULTIPLIER', 1.5)
|
||||
total_with_markup_cents = int(total_twilio_cost_cents * markup_multiplier)
|
||||
|
||||
# Calculate new charges since last sync
|
||||
new_charges = total_with_markup_cents - credits.billed_usage_cents
|
||||
|
||||
if new_charges > 0:
|
||||
# Deduct from credits
|
||||
credits.deduct(
|
||||
new_charges,
|
||||
f"Twilio usage for {first_of_month.strftime('%B %Y')}",
|
||||
reference_type='twilio_sync',
|
||||
reference_id=f"{first_of_month.isoformat()}"
|
||||
)
|
||||
|
||||
# Update sync tracking
|
||||
credits.billed_usage_cents = total_with_markup_cents
|
||||
|
||||
# Update sync info
|
||||
credits.last_twilio_sync_at = timezone.now()
|
||||
credits.twilio_sync_period_start = first_of_month
|
||||
credits.twilio_raw_usage_cents = total_twilio_cost_cents
|
||||
credits.save(update_fields=[
|
||||
'last_twilio_sync_at', 'twilio_sync_period_start',
|
||||
'twilio_raw_usage_cents', 'billed_usage_cents'
|
||||
])
|
||||
|
||||
logger.info(
|
||||
f"Synced Twilio usage for {tenant.name}: "
|
||||
f"${total_twilio_cost_cents/100:.2f} raw, "
|
||||
f"${total_with_markup_cents/100:.2f} with markup, "
|
||||
f"${new_charges/100:.2f} new charges"
|
||||
)
|
||||
|
||||
return {
|
||||
'tenant': tenant.name,
|
||||
'raw_cost_cents': total_twilio_cost_cents,
|
||||
'billed_cents': total_with_markup_cents,
|
||||
'new_charges_cents': new_charges,
|
||||
'usage_breakdown': usage_breakdown,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing Twilio usage for {tenant.name}: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_low_balance_warning(credits_id):
|
||||
"""
|
||||
Send low balance warning email to tenant.
|
||||
"""
|
||||
from .models import CommunicationCredits
|
||||
from django.core.mail import send_mail
|
||||
|
||||
try:
|
||||
credits = CommunicationCredits.objects.select_related('tenant').get(id=credits_id)
|
||||
except CommunicationCredits.DoesNotExist:
|
||||
return {'error': 'Credits not found'}
|
||||
|
||||
tenant = credits.tenant
|
||||
|
||||
# Get tenant owner's email
|
||||
owner = tenant.users.filter(role='owner').first()
|
||||
if not owner:
|
||||
logger.warning(f"No owner found for tenant {tenant.name}")
|
||||
return {'error': 'No owner email'}
|
||||
|
||||
subject = f"Low Communication Credits Balance - {tenant.name}"
|
||||
message = f"""
|
||||
Hi {owner.first_name or owner.username},
|
||||
|
||||
Your communication credits balance for {tenant.name} is running low.
|
||||
|
||||
Current Balance: ${credits.balance_cents/100:.2f}
|
||||
Warning Threshold: ${credits.low_balance_warning_cents/100:.2f}
|
||||
|
||||
{"Auto-reload is ENABLED and will trigger at $" + f"{credits.auto_reload_threshold_cents/100:.2f}" if credits.auto_reload_enabled else "Auto-reload is NOT enabled. Please add credits to avoid service interruption."}
|
||||
|
||||
To add credits or manage your settings, visit your Communication Settings page.
|
||||
|
||||
Best regards,
|
||||
SmoothSchedule Team
|
||||
"""
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject,
|
||||
message,
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[owner.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
logger.info(f"Sent low balance warning to {owner.email} for {tenant.name}")
|
||||
return {'sent_to': owner.email}
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending low balance warning: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
@shared_task
|
||||
def process_auto_reload(credits_id):
|
||||
"""
|
||||
Process auto-reload of credits.
|
||||
|
||||
Charges the stored payment method and adds credits.
|
||||
"""
|
||||
from .models import CommunicationCredits
|
||||
import stripe
|
||||
|
||||
try:
|
||||
credits = CommunicationCredits.objects.select_related('tenant').get(id=credits_id)
|
||||
except CommunicationCredits.DoesNotExist:
|
||||
return {'error': 'Credits not found'}
|
||||
|
||||
if not credits.auto_reload_enabled:
|
||||
return {'error': 'Auto-reload not enabled'}
|
||||
|
||||
if not credits.stripe_payment_method_id:
|
||||
return {'error': 'No payment method'}
|
||||
|
||||
if credits.balance_cents > credits.auto_reload_threshold_cents:
|
||||
return {'skipped': 'Balance above threshold'}
|
||||
|
||||
tenant = credits.tenant
|
||||
amount_cents = credits.auto_reload_amount_cents
|
||||
|
||||
try:
|
||||
# Get Stripe API key from platform settings
|
||||
from platform_admin.models import PlatformSettings
|
||||
platform_settings = PlatformSettings.get_instance()
|
||||
stripe.api_key = platform_settings.get_stripe_secret_key()
|
||||
|
||||
# Create payment intent and confirm
|
||||
payment_intent = stripe.PaymentIntent.create(
|
||||
amount=amount_cents,
|
||||
currency='usd',
|
||||
payment_method=credits.stripe_payment_method_id,
|
||||
confirm=True,
|
||||
description=f"Communication credits reload for {tenant.name}",
|
||||
metadata={
|
||||
'tenant_id': str(tenant.id),
|
||||
'tenant_name': tenant.name,
|
||||
'type': 'comms_credits_reload',
|
||||
},
|
||||
# Auto-confirm with the stored payment method
|
||||
automatic_payment_methods={
|
||||
'enabled': True,
|
||||
'allow_redirects': 'never',
|
||||
},
|
||||
)
|
||||
|
||||
if payment_intent.status == 'succeeded':
|
||||
# Add credits
|
||||
credits.add_credits(
|
||||
amount_cents,
|
||||
transaction_type='auto_reload',
|
||||
stripe_charge_id=payment_intent.id,
|
||||
description=f"Auto-reload: ${amount_cents/100:.2f}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Auto-reloaded ${amount_cents/100:.2f} for {tenant.name}, "
|
||||
f"new balance: ${credits.balance_cents/100:.2f}"
|
||||
)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'amount_cents': amount_cents,
|
||||
'new_balance_cents': credits.balance_cents,
|
||||
'payment_intent_id': payment_intent.id,
|
||||
}
|
||||
else:
|
||||
logger.error(f"Payment failed for {tenant.name}: {payment_intent.status}")
|
||||
return {'error': f"Payment status: {payment_intent.status}"}
|
||||
|
||||
except stripe.error.CardError as e:
|
||||
logger.error(f"Card error for {tenant.name}: {e.error.message}")
|
||||
return {'error': f"Card error: {e.error.message}"}
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing auto-reload for {tenant.name}: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
@shared_task
|
||||
def bill_proxy_phone_numbers():
|
||||
"""
|
||||
Monthly task to bill tenants for assigned proxy phone numbers.
|
||||
"""
|
||||
from .models import ProxyPhoneNumber, CommunicationCredits
|
||||
|
||||
today = timezone.now()
|
||||
first_of_month = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Get all assigned numbers that haven't been billed this month
|
||||
numbers = ProxyPhoneNumber.objects.filter(
|
||||
status=ProxyPhoneNumber.Status.ASSIGNED,
|
||||
is_active=True,
|
||||
).exclude(
|
||||
last_billed_at__gte=first_of_month
|
||||
).select_related('assigned_tenant')
|
||||
|
||||
billed = 0
|
||||
errors = 0
|
||||
|
||||
for number in numbers:
|
||||
tenant = number.assigned_tenant
|
||||
if not tenant:
|
||||
continue
|
||||
|
||||
try:
|
||||
credits, _ = CommunicationCredits.objects.get_or_create(tenant=tenant)
|
||||
|
||||
# Deduct monthly fee
|
||||
result = credits.deduct(
|
||||
number.monthly_fee_cents,
|
||||
f"Proxy number {number.phone_number} - {today.strftime('%B %Y')}",
|
||||
reference_type='proxy_number',
|
||||
reference_id=number.phone_number,
|
||||
)
|
||||
|
||||
if result:
|
||||
number.last_billed_at = today
|
||||
number.save(update_fields=['last_billed_at', 'updated_at'])
|
||||
billed += 1
|
||||
logger.info(
|
||||
f"Billed ${number.monthly_fee_cents/100:.2f} for {number.phone_number} "
|
||||
f"to {tenant.name}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Insufficient credits for {tenant.name} to bill {number.phone_number}"
|
||||
)
|
||||
errors += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error billing {number.phone_number}: {e}")
|
||||
errors += 1
|
||||
|
||||
logger.info(f"Billed {billed} proxy numbers, {errors} errors")
|
||||
return {'billed': billed, 'errors': errors}
|
||||
|
||||
|
||||
@shared_task
|
||||
def expire_masked_sessions():
|
||||
"""
|
||||
Close expired masked sessions and release proxy numbers.
|
||||
"""
|
||||
from .models import MaskedSession
|
||||
|
||||
expired = MaskedSession.objects.filter(
|
||||
status=MaskedSession.Status.ACTIVE,
|
||||
expires_at__lt=timezone.now()
|
||||
)
|
||||
|
||||
count = 0
|
||||
for session in expired:
|
||||
session.status = MaskedSession.Status.EXPIRED
|
||||
session.closed_at = timezone.now()
|
||||
session.save(update_fields=['status', 'closed_at', 'updated_at'])
|
||||
|
||||
# Release proxy number if reserved
|
||||
if session.proxy_number:
|
||||
from .models import ProxyPhoneNumber
|
||||
if session.proxy_number.status == ProxyPhoneNumber.Status.RESERVED:
|
||||
session.proxy_number.status = ProxyPhoneNumber.Status.AVAILABLE
|
||||
session.proxy_number.save(update_fields=['status', 'updated_at'])
|
||||
|
||||
count += 1
|
||||
|
||||
if count:
|
||||
logger.info(f"Expired {count} masked sessions")
|
||||
|
||||
return {'expired': count}
|
||||
|
||||
|
||||
@shared_task
|
||||
def create_twilio_subaccount(tenant_id):
|
||||
"""
|
||||
Create a Twilio subaccount for a tenant.
|
||||
|
||||
Called when SMS/calling is first enabled for a tenant.
|
||||
"""
|
||||
from core.models import Tenant
|
||||
from twilio.rest import Client
|
||||
|
||||
try:
|
||||
tenant = Tenant.objects.get(id=tenant_id)
|
||||
except Tenant.DoesNotExist:
|
||||
return {'error': 'Tenant not found'}
|
||||
|
||||
if tenant.twilio_subaccount_sid:
|
||||
return {'skipped': 'Subaccount already exists'}
|
||||
|
||||
try:
|
||||
# Get master Twilio credentials from settings
|
||||
master_sid = getattr(settings, 'TWILIO_ACCOUNT_SID', '')
|
||||
master_token = getattr(settings, 'TWILIO_AUTH_TOKEN', '')
|
||||
|
||||
if not master_sid or not master_token:
|
||||
return {'error': 'Twilio master credentials not configured'}
|
||||
|
||||
client = Client(master_sid, master_token)
|
||||
|
||||
# Create subaccount
|
||||
subaccount = client.api.accounts.create(
|
||||
friendly_name=f"SmoothSchedule - {tenant.name}"
|
||||
)
|
||||
|
||||
# Save to tenant
|
||||
tenant.twilio_subaccount_sid = subaccount.sid
|
||||
tenant.twilio_subaccount_auth_token = subaccount.auth_token
|
||||
tenant.save(update_fields=[
|
||||
'twilio_subaccount_sid', 'twilio_subaccount_auth_token'
|
||||
])
|
||||
|
||||
logger.info(f"Created Twilio subaccount for {tenant.name}: {subaccount.sid}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'subaccount_sid': subaccount.sid,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating Twilio subaccount for {tenant.name}: {e}")
|
||||
return {'error': str(e)}
|
||||
43
smoothschedule/smoothschedule/comms_credits/urls.py
Normal file
43
smoothschedule/smoothschedule/comms_credits/urls.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Communication Credits URL Configuration
|
||||
"""
|
||||
from django.urls import path
|
||||
|
||||
from .views import (
|
||||
get_credits_view,
|
||||
update_settings_view,
|
||||
add_credits_view,
|
||||
create_payment_intent_view,
|
||||
confirm_payment_view,
|
||||
setup_payment_method_view,
|
||||
save_payment_method_view,
|
||||
get_transactions_view,
|
||||
get_usage_stats_view,
|
||||
)
|
||||
|
||||
app_name = 'comms_credits'
|
||||
|
||||
urlpatterns = [
|
||||
# Get current credits and settings
|
||||
path('', get_credits_view, name='get_credits'),
|
||||
|
||||
# Update settings
|
||||
path('settings/', update_settings_view, name='update_settings'),
|
||||
|
||||
# Add credits (direct payment)
|
||||
path('add/', add_credits_view, name='add_credits'),
|
||||
|
||||
# Stripe Elements payment flow
|
||||
path('create-payment-intent/', create_payment_intent_view, name='create_payment_intent'),
|
||||
path('confirm-payment/', confirm_payment_view, name='confirm_payment'),
|
||||
|
||||
# Setup payment method for auto-reload
|
||||
path('setup-payment-method/', setup_payment_method_view, name='setup_payment_method'),
|
||||
path('save-payment-method/', save_payment_method_view, name='save_payment_method'),
|
||||
|
||||
# Transaction history
|
||||
path('transactions/', get_transactions_view, name='transactions'),
|
||||
|
||||
# Usage stats
|
||||
path('usage-stats/', get_usage_stats_view, name='usage_stats'),
|
||||
]
|
||||
575
smoothschedule/smoothschedule/comms_credits/views.py
Normal file
575
smoothschedule/smoothschedule/comms_credits/views.py
Normal file
@@ -0,0 +1,575 @@
|
||||
"""
|
||||
Communication Credits API Views
|
||||
|
||||
API endpoints for managing prepaid communication credits.
|
||||
Integrates with Stripe for payments.
|
||||
"""
|
||||
import stripe
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
|
||||
from .models import CommunicationCredits, CreditTransaction
|
||||
|
||||
# Initialize Stripe
|
||||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||
|
||||
|
||||
class TransactionPagination(PageNumberPagination):
|
||||
page_size = 20
|
||||
page_size_query_param = 'limit'
|
||||
max_page_size = 100
|
||||
|
||||
|
||||
def get_or_create_credits(tenant):
|
||||
"""Get or create CommunicationCredits for a tenant."""
|
||||
credits, created = CommunicationCredits.objects.get_or_create(
|
||||
tenant=tenant
|
||||
)
|
||||
return credits
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_credits_view(request):
|
||||
"""
|
||||
Get current communication credits for the business.
|
||||
|
||||
Returns the credit balance and settings.
|
||||
"""
|
||||
tenant = request.tenant
|
||||
if not tenant:
|
||||
return Response(
|
||||
{'error': 'No business context'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
credits = get_or_create_credits(tenant)
|
||||
|
||||
return Response({
|
||||
'id': credits.id,
|
||||
'balance_cents': credits.balance_cents,
|
||||
'auto_reload_enabled': credits.auto_reload_enabled,
|
||||
'auto_reload_threshold_cents': credits.auto_reload_threshold_cents,
|
||||
'auto_reload_amount_cents': credits.auto_reload_amount_cents,
|
||||
'low_balance_warning_cents': credits.low_balance_warning_cents,
|
||||
'low_balance_warning_sent': credits.low_balance_warning_sent,
|
||||
'stripe_payment_method_id': credits.stripe_payment_method_id,
|
||||
'last_twilio_sync_at': credits.last_twilio_sync_at,
|
||||
'total_loaded_cents': credits.total_loaded_cents,
|
||||
'total_spent_cents': credits.total_spent_cents,
|
||||
'created_at': credits.created_at,
|
||||
'updated_at': credits.updated_at,
|
||||
})
|
||||
|
||||
|
||||
@api_view(['PATCH'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def update_settings_view(request):
|
||||
"""
|
||||
Update credit settings (auto-reload, thresholds, etc.)
|
||||
"""
|
||||
tenant = request.tenant
|
||||
if not tenant:
|
||||
return Response(
|
||||
{'error': 'No business context'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
credits = get_or_create_credits(tenant)
|
||||
|
||||
# Allowed fields to update
|
||||
allowed_fields = [
|
||||
'auto_reload_enabled',
|
||||
'auto_reload_threshold_cents',
|
||||
'auto_reload_amount_cents',
|
||||
'low_balance_warning_cents',
|
||||
'stripe_payment_method_id',
|
||||
]
|
||||
|
||||
update_fields = ['updated_at']
|
||||
for field in allowed_fields:
|
||||
if field in request.data:
|
||||
setattr(credits, field, request.data[field])
|
||||
update_fields.append(field)
|
||||
|
||||
credits.save(update_fields=update_fields)
|
||||
|
||||
return Response({
|
||||
'id': credits.id,
|
||||
'balance_cents': credits.balance_cents,
|
||||
'auto_reload_enabled': credits.auto_reload_enabled,
|
||||
'auto_reload_threshold_cents': credits.auto_reload_threshold_cents,
|
||||
'auto_reload_amount_cents': credits.auto_reload_amount_cents,
|
||||
'low_balance_warning_cents': credits.low_balance_warning_cents,
|
||||
'low_balance_warning_sent': credits.low_balance_warning_sent,
|
||||
'stripe_payment_method_id': credits.stripe_payment_method_id,
|
||||
'last_twilio_sync_at': credits.last_twilio_sync_at,
|
||||
'total_loaded_cents': credits.total_loaded_cents,
|
||||
'total_spent_cents': credits.total_spent_cents,
|
||||
'created_at': credits.created_at,
|
||||
'updated_at': credits.updated_at,
|
||||
})
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def add_credits_view(request):
|
||||
"""
|
||||
Add credits via Stripe payment.
|
||||
|
||||
Expects:
|
||||
- amount_cents: Amount to add (in cents, minimum $5 = 500 cents)
|
||||
- payment_method_id: Stripe Payment Method ID
|
||||
- save_payment_method: Optional, whether to save for auto-reload
|
||||
"""
|
||||
tenant = request.tenant
|
||||
if not tenant:
|
||||
return Response(
|
||||
{'error': 'No business context'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
amount_cents = request.data.get('amount_cents')
|
||||
payment_method_id = request.data.get('payment_method_id')
|
||||
save_payment_method = request.data.get('save_payment_method', False)
|
||||
|
||||
if not amount_cents or amount_cents < 500:
|
||||
return Response(
|
||||
{'error': 'Minimum amount is $5.00 (500 cents)'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
if not payment_method_id:
|
||||
return Response(
|
||||
{'error': 'Payment method is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
credits = get_or_create_credits(tenant)
|
||||
|
||||
try:
|
||||
# Get or create a Stripe customer for this tenant
|
||||
stripe_customer_id = _get_or_create_stripe_customer(credits, tenant, request.user)
|
||||
|
||||
# Attach payment method to customer if not already attached
|
||||
try:
|
||||
stripe.PaymentMethod.attach(
|
||||
payment_method_id,
|
||||
customer=stripe_customer_id,
|
||||
)
|
||||
except stripe.error.InvalidRequestError as e:
|
||||
# Payment method might already be attached
|
||||
if 'already been attached' not in str(e):
|
||||
raise
|
||||
|
||||
# Create payment intent
|
||||
payment_intent = stripe.PaymentIntent.create(
|
||||
amount=amount_cents,
|
||||
currency='usd',
|
||||
customer=stripe_customer_id,
|
||||
payment_method=payment_method_id,
|
||||
confirm=True,
|
||||
automatic_payment_methods={
|
||||
'enabled': True,
|
||||
'allow_redirects': 'never',
|
||||
},
|
||||
description=f'Communication credits for {tenant.name}',
|
||||
metadata={
|
||||
'tenant_id': str(tenant.id),
|
||||
'tenant_name': tenant.name,
|
||||
'type': 'communication_credits',
|
||||
},
|
||||
)
|
||||
|
||||
if payment_intent.status == 'succeeded':
|
||||
# Add credits to balance
|
||||
with transaction.atomic():
|
||||
credits.add_credits(
|
||||
amount_cents=amount_cents,
|
||||
transaction_type='manual',
|
||||
stripe_charge_id=payment_intent.id,
|
||||
description=f'Added ${amount_cents/100:.2f} via Stripe'
|
||||
)
|
||||
|
||||
# Save payment method for auto-reload if requested
|
||||
if save_payment_method:
|
||||
credits.stripe_payment_method_id = payment_method_id
|
||||
credits.save(update_fields=['stripe_payment_method_id', 'updated_at'])
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'balance_cents': credits.balance_cents,
|
||||
'payment_intent_id': payment_intent.id,
|
||||
})
|
||||
|
||||
elif payment_intent.status == 'requires_action':
|
||||
# 3D Secure or additional authentication required
|
||||
return Response({
|
||||
'requires_action': True,
|
||||
'payment_intent_client_secret': payment_intent.client_secret,
|
||||
})
|
||||
|
||||
else:
|
||||
return Response(
|
||||
{'error': f'Payment failed: {payment_intent.status}'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
except stripe.error.CardError as e:
|
||||
return Response(
|
||||
{'error': e.user_message or 'Card was declined'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
except stripe.error.StripeError as e:
|
||||
return Response(
|
||||
{'error': 'Payment processing error. Please try again.'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def create_payment_intent_view(request):
|
||||
"""
|
||||
Create a Stripe PaymentIntent for credit purchase.
|
||||
|
||||
Use this for payment flows that need client-side confirmation.
|
||||
Returns the client_secret for Stripe Elements.
|
||||
|
||||
Expects:
|
||||
- amount_cents: Amount to add (in cents, minimum $5 = 500 cents)
|
||||
"""
|
||||
tenant = request.tenant
|
||||
if not tenant:
|
||||
return Response(
|
||||
{'error': 'No business context'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
amount_cents = request.data.get('amount_cents')
|
||||
|
||||
if not amount_cents or amount_cents < 500:
|
||||
return Response(
|
||||
{'error': 'Minimum amount is $5.00 (500 cents)'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
credits = get_or_create_credits(tenant)
|
||||
|
||||
try:
|
||||
# Get or create a Stripe customer for this tenant
|
||||
stripe_customer_id = _get_or_create_stripe_customer(credits, tenant, request.user)
|
||||
|
||||
# Create payment intent
|
||||
payment_intent = stripe.PaymentIntent.create(
|
||||
amount=amount_cents,
|
||||
currency='usd',
|
||||
customer=stripe_customer_id,
|
||||
automatic_payment_methods={
|
||||
'enabled': True,
|
||||
},
|
||||
description=f'Communication credits for {tenant.name}',
|
||||
metadata={
|
||||
'tenant_id': str(tenant.id),
|
||||
'tenant_name': tenant.name,
|
||||
'type': 'communication_credits',
|
||||
},
|
||||
)
|
||||
|
||||
return Response({
|
||||
'client_secret': payment_intent.client_secret,
|
||||
'payment_intent_id': payment_intent.id,
|
||||
})
|
||||
|
||||
except stripe.error.StripeError as e:
|
||||
return Response(
|
||||
{'error': 'Failed to create payment. Please try again.'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def confirm_payment_view(request):
|
||||
"""
|
||||
Confirm a payment after client-side processing.
|
||||
|
||||
Called after Stripe Elements confirms the payment on the client.
|
||||
|
||||
Expects:
|
||||
- payment_intent_id: The PaymentIntent ID
|
||||
- save_payment_method: Optional, whether to save for auto-reload
|
||||
"""
|
||||
tenant = request.tenant
|
||||
if not tenant:
|
||||
return Response(
|
||||
{'error': 'No business context'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
payment_intent_id = request.data.get('payment_intent_id')
|
||||
save_payment_method = request.data.get('save_payment_method', False)
|
||||
|
||||
if not payment_intent_id:
|
||||
return Response(
|
||||
{'error': 'Payment intent ID is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
# Retrieve the payment intent
|
||||
payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id)
|
||||
|
||||
# Verify the payment is for this tenant
|
||||
if payment_intent.metadata.get('tenant_id') != str(tenant.id):
|
||||
return Response(
|
||||
{'error': 'Invalid payment'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
if payment_intent.status != 'succeeded':
|
||||
return Response(
|
||||
{'error': f'Payment not completed: {payment_intent.status}'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
credits = get_or_create_credits(tenant)
|
||||
|
||||
# Check if we've already processed this payment
|
||||
existing = CreditTransaction.objects.filter(
|
||||
credits=credits,
|
||||
stripe_charge_id=payment_intent_id
|
||||
).exists()
|
||||
|
||||
if existing:
|
||||
return Response({
|
||||
'success': True,
|
||||
'balance_cents': credits.balance_cents,
|
||||
'already_processed': True,
|
||||
})
|
||||
|
||||
# Add credits
|
||||
with transaction.atomic():
|
||||
credits.add_credits(
|
||||
amount_cents=payment_intent.amount,
|
||||
transaction_type='manual',
|
||||
stripe_charge_id=payment_intent_id,
|
||||
description=f'Added ${payment_intent.amount/100:.2f} via Stripe'
|
||||
)
|
||||
|
||||
# Save payment method for auto-reload if requested
|
||||
if save_payment_method and payment_intent.payment_method:
|
||||
credits.stripe_payment_method_id = payment_intent.payment_method
|
||||
credits.save(update_fields=['stripe_payment_method_id', 'updated_at'])
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'balance_cents': credits.balance_cents,
|
||||
})
|
||||
|
||||
except stripe.error.StripeError as e:
|
||||
return Response(
|
||||
{'error': 'Failed to confirm payment'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def setup_payment_method_view(request):
|
||||
"""
|
||||
Create a SetupIntent for saving a payment method for auto-reload.
|
||||
|
||||
Returns the client_secret for Stripe Elements to collect card details
|
||||
without charging immediately.
|
||||
"""
|
||||
tenant = request.tenant
|
||||
if not tenant:
|
||||
return Response(
|
||||
{'error': 'No business context'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
credits = get_or_create_credits(tenant)
|
||||
|
||||
try:
|
||||
stripe_customer_id = _get_or_create_stripe_customer(credits, tenant, request.user)
|
||||
|
||||
setup_intent = stripe.SetupIntent.create(
|
||||
customer=stripe_customer_id,
|
||||
automatic_payment_methods={
|
||||
'enabled': True,
|
||||
},
|
||||
metadata={
|
||||
'tenant_id': str(tenant.id),
|
||||
'type': 'communication_credits_auto_reload',
|
||||
},
|
||||
)
|
||||
|
||||
return Response({
|
||||
'client_secret': setup_intent.client_secret,
|
||||
})
|
||||
|
||||
except stripe.error.StripeError as e:
|
||||
return Response(
|
||||
{'error': 'Failed to set up payment method'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def save_payment_method_view(request):
|
||||
"""
|
||||
Save a payment method for auto-reload after SetupIntent confirmation.
|
||||
|
||||
Expects:
|
||||
- payment_method_id: The confirmed payment method ID
|
||||
"""
|
||||
tenant = request.tenant
|
||||
if not tenant:
|
||||
return Response(
|
||||
{'error': 'No business context'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
payment_method_id = request.data.get('payment_method_id')
|
||||
|
||||
if not payment_method_id:
|
||||
return Response(
|
||||
{'error': 'Payment method ID is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
credits = get_or_create_credits(tenant)
|
||||
credits.stripe_payment_method_id = payment_method_id
|
||||
credits.save(update_fields=['stripe_payment_method_id', 'updated_at'])
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'payment_method_id': payment_method_id,
|
||||
})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_transactions_view(request):
|
||||
"""
|
||||
Get credit transaction history with pagination.
|
||||
"""
|
||||
tenant = request.tenant
|
||||
if not tenant:
|
||||
return Response(
|
||||
{'error': 'No business context'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
credits = get_or_create_credits(tenant)
|
||||
|
||||
transactions = CreditTransaction.objects.filter(credits=credits)
|
||||
|
||||
paginator = TransactionPagination()
|
||||
page = paginator.paginate_queryset(transactions, request)
|
||||
|
||||
results = [{
|
||||
'id': t.id,
|
||||
'amount_cents': t.amount_cents,
|
||||
'balance_after_cents': t.balance_after_cents,
|
||||
'transaction_type': t.transaction_type,
|
||||
'description': t.description,
|
||||
'reference_type': t.reference_type,
|
||||
'reference_id': t.reference_id,
|
||||
'stripe_charge_id': t.stripe_charge_id,
|
||||
'created_at': t.created_at,
|
||||
} for t in page]
|
||||
|
||||
return paginator.get_paginated_response(results)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_usage_stats_view(request):
|
||||
"""
|
||||
Get communication usage statistics for the current month.
|
||||
"""
|
||||
tenant = request.tenant
|
||||
if not tenant:
|
||||
return Response(
|
||||
{'error': 'No business context'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from .models import ProxyPhoneNumber
|
||||
|
||||
credits = get_or_create_credits(tenant)
|
||||
|
||||
# Get usage for current month from transactions
|
||||
now = timezone.now()
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
month_transactions = CreditTransaction.objects.filter(
|
||||
credits=credits,
|
||||
transaction_type='usage',
|
||||
created_at__gte=month_start
|
||||
)
|
||||
|
||||
# Calculate stats
|
||||
sms_transactions = month_transactions.filter(reference_type='sms')
|
||||
voice_transactions = month_transactions.filter(reference_type='voice')
|
||||
|
||||
sms_count = sms_transactions.count()
|
||||
|
||||
# Estimate voice minutes from cost (rough estimate)
|
||||
voice_cost_cents = abs(sum(t.amount_cents for t in voice_transactions))
|
||||
voice_minutes = int(voice_cost_cents / 1.3) # ~$0.013/min Twilio rate
|
||||
|
||||
# Count active proxy numbers
|
||||
active_proxy_numbers = ProxyPhoneNumber.objects.filter(
|
||||
assigned_tenant=tenant,
|
||||
status='assigned',
|
||||
is_active=True
|
||||
).count()
|
||||
|
||||
# Total estimated cost this month
|
||||
estimated_cost = abs(sum(t.amount_cents for t in month_transactions))
|
||||
|
||||
return Response({
|
||||
'sms_sent_this_month': sms_count,
|
||||
'voice_minutes_this_month': voice_minutes,
|
||||
'proxy_numbers_active': active_proxy_numbers,
|
||||
'estimated_cost_cents': estimated_cost,
|
||||
})
|
||||
|
||||
|
||||
def _get_or_create_stripe_customer(credits, tenant, user):
|
||||
"""
|
||||
Get or create a Stripe customer for the tenant.
|
||||
|
||||
Stores the Stripe customer ID on the CommunicationCredits model.
|
||||
"""
|
||||
# Check if we already have a Stripe customer
|
||||
if credits.stripe_customer_id:
|
||||
return credits.stripe_customer_id
|
||||
|
||||
# Create a new Stripe customer
|
||||
customer = stripe.Customer.create(
|
||||
email=user.email,
|
||||
name=tenant.name,
|
||||
metadata={
|
||||
'tenant_id': str(tenant.id),
|
||||
'tenant_name': tenant.name,
|
||||
},
|
||||
)
|
||||
|
||||
# Store the customer ID on the credits model
|
||||
credits.stripe_customer_id = customer.id
|
||||
credits.save(update_fields=['stripe_customer_id', 'updated_at'])
|
||||
|
||||
return customer.id
|
||||
@@ -1,940 +0,0 @@
|
||||
"""
|
||||
Management command to seed default email templates.
|
||||
|
||||
These templates are created for new businesses and can be customized.
|
||||
Platform templates are shared across all tenants.
|
||||
|
||||
Usage:
|
||||
# Seed templates for all schemas
|
||||
python manage.py seed_email_templates
|
||||
|
||||
# Seed templates for a specific schema
|
||||
python manage.py seed_email_templates --schema=demo
|
||||
|
||||
# Force reset to defaults (overwrites existing)
|
||||
python manage.py seed_email_templates --reset
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
from django_tenants.utils import schema_context, get_tenant_model
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Seed default email templates for tenants'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--schema',
|
||||
type=str,
|
||||
help='Specific tenant schema to seed (default: all tenants)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--reset',
|
||||
action='store_true',
|
||||
help='Reset templates to defaults (overwrites existing)',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
schema = options.get('schema')
|
||||
reset = options.get('reset', False)
|
||||
|
||||
if schema:
|
||||
# Seed specific schema
|
||||
self.seed_schema(schema, reset)
|
||||
else:
|
||||
# Seed all tenant schemas
|
||||
Tenant = get_tenant_model()
|
||||
tenants = Tenant.objects.exclude(schema_name='public')
|
||||
|
||||
for tenant in tenants:
|
||||
self.seed_schema(tenant.schema_name, reset)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Email templates seeded successfully!'))
|
||||
|
||||
def seed_schema(self, schema_name, reset=False):
|
||||
"""Seed templates for a specific schema"""
|
||||
self.stdout.write(f'Seeding templates for schema: {schema_name}')
|
||||
|
||||
with schema_context(schema_name):
|
||||
from schedule.models import EmailTemplate
|
||||
|
||||
templates = self.get_default_templates()
|
||||
|
||||
for template_data in templates:
|
||||
name = template_data['name']
|
||||
|
||||
if reset:
|
||||
# Delete existing and recreate
|
||||
EmailTemplate.objects.filter(name=name).delete()
|
||||
EmailTemplate.objects.create(**template_data)
|
||||
self.stdout.write(f' Reset: {name}')
|
||||
else:
|
||||
# Only create if doesn't exist
|
||||
_, created = EmailTemplate.objects.get_or_create(
|
||||
name=name,
|
||||
defaults=template_data
|
||||
)
|
||||
if created:
|
||||
self.stdout.write(f' Created: {name}')
|
||||
else:
|
||||
self.stdout.write(f' Skipped (exists): {name}')
|
||||
|
||||
def get_default_templates(self):
|
||||
"""Return list of default email templates"""
|
||||
return [
|
||||
# ========== CONFIRMATION TEMPLATES ==========
|
||||
{
|
||||
'name': 'Appointment Confirmation',
|
||||
'description': 'Sent when a customer books an appointment',
|
||||
'category': 'CONFIRMATION',
|
||||
'scope': 'BUSINESS',
|
||||
'subject': 'Your appointment at {{BUSINESS_NAME}} is confirmed!',
|
||||
'html_content': self.get_appointment_confirmation_html(),
|
||||
'text_content': self.get_appointment_confirmation_text(),
|
||||
},
|
||||
|
||||
# ========== REMINDER TEMPLATES ==========
|
||||
{
|
||||
'name': 'Appointment Reminder - 24 Hours',
|
||||
'description': 'Reminder sent 24 hours before appointment',
|
||||
'category': 'REMINDER',
|
||||
'scope': 'BUSINESS',
|
||||
'subject': 'Reminder: Your appointment tomorrow at {{BUSINESS_NAME}}',
|
||||
'html_content': self.get_appointment_reminder_html('24 hours'),
|
||||
'text_content': self.get_appointment_reminder_text('24 hours'),
|
||||
},
|
||||
{
|
||||
'name': 'Appointment Reminder - 1 Hour',
|
||||
'description': 'Reminder sent 1 hour before appointment',
|
||||
'category': 'REMINDER',
|
||||
'scope': 'BUSINESS',
|
||||
'subject': 'Reminder: Your appointment in 1 hour at {{BUSINESS_NAME}}',
|
||||
'html_content': self.get_appointment_reminder_html('1 hour'),
|
||||
'text_content': self.get_appointment_reminder_text('1 hour'),
|
||||
},
|
||||
|
||||
# ========== NOTIFICATION TEMPLATES ==========
|
||||
{
|
||||
'name': 'Appointment Rescheduled',
|
||||
'description': 'Sent when an appointment is rescheduled',
|
||||
'category': 'NOTIFICATION',
|
||||
'scope': 'BUSINESS',
|
||||
'subject': 'Your appointment at {{BUSINESS_NAME}} has been rescheduled',
|
||||
'html_content': self.get_appointment_rescheduled_html(),
|
||||
'text_content': self.get_appointment_rescheduled_text(),
|
||||
},
|
||||
{
|
||||
'name': 'Appointment Cancelled',
|
||||
'description': 'Sent when an appointment is cancelled',
|
||||
'category': 'NOTIFICATION',
|
||||
'scope': 'BUSINESS',
|
||||
'subject': 'Your appointment at {{BUSINESS_NAME}} has been cancelled',
|
||||
'html_content': self.get_appointment_cancelled_html(),
|
||||
'text_content': self.get_appointment_cancelled_text(),
|
||||
},
|
||||
{
|
||||
'name': 'Thank You - Appointment Complete',
|
||||
'description': 'Sent after an appointment is completed',
|
||||
'category': 'NOTIFICATION',
|
||||
'scope': 'BUSINESS',
|
||||
'subject': 'Thank you for visiting {{BUSINESS_NAME}}!',
|
||||
'html_content': self.get_thank_you_html(),
|
||||
'text_content': self.get_thank_you_text(),
|
||||
},
|
||||
|
||||
# ========== CUSTOMER ONBOARDING ==========
|
||||
{
|
||||
'name': 'Welcome New Customer',
|
||||
'description': 'Welcome email for new customer accounts',
|
||||
'category': 'NOTIFICATION',
|
||||
'scope': 'BUSINESS',
|
||||
'subject': 'Welcome to {{BUSINESS_NAME}}!',
|
||||
'html_content': self.get_welcome_customer_html(),
|
||||
'text_content': self.get_welcome_customer_text(),
|
||||
},
|
||||
|
||||
# ========== TICKET NOTIFICATIONS ==========
|
||||
{
|
||||
'name': 'Ticket Assigned',
|
||||
'description': 'Notification when a ticket is assigned to a staff member',
|
||||
'category': 'NOTIFICATION',
|
||||
'scope': 'BUSINESS',
|
||||
'subject': '[Ticket #{{TICKET_ID}}] You have been assigned: {{TICKET_SUBJECT}}',
|
||||
'html_content': self.get_ticket_assigned_html(),
|
||||
'text_content': self.get_ticket_assigned_text(),
|
||||
},
|
||||
{
|
||||
'name': 'Ticket Status Changed',
|
||||
'description': 'Notification when ticket status changes',
|
||||
'category': 'NOTIFICATION',
|
||||
'scope': 'BUSINESS',
|
||||
'subject': '[Ticket #{{TICKET_ID}}] Status updated: {{TICKET_STATUS}}',
|
||||
'html_content': self.get_ticket_status_changed_html(),
|
||||
'text_content': self.get_ticket_status_changed_text(),
|
||||
},
|
||||
{
|
||||
'name': 'Ticket Reply - Staff Notification',
|
||||
'description': 'Notification to staff when customer replies to ticket',
|
||||
'category': 'NOTIFICATION',
|
||||
'scope': 'BUSINESS',
|
||||
'subject': '[Ticket #{{TICKET_ID}}] New reply from customer: {{TICKET_SUBJECT}}',
|
||||
'html_content': self.get_ticket_reply_staff_html(),
|
||||
'text_content': self.get_ticket_reply_staff_text(),
|
||||
},
|
||||
{
|
||||
'name': 'Ticket Reply - Customer Notification',
|
||||
'description': 'Notification to customer when staff replies to ticket',
|
||||
'category': 'NOTIFICATION',
|
||||
'scope': 'BUSINESS',
|
||||
'subject': '[Ticket #{{TICKET_ID}}] {{BUSINESS_NAME}} has responded to your request',
|
||||
'html_content': self.get_ticket_reply_customer_html(),
|
||||
'text_content': self.get_ticket_reply_customer_text(),
|
||||
},
|
||||
{
|
||||
'name': 'Ticket Resolved',
|
||||
'description': 'Notification when a ticket is resolved/closed',
|
||||
'category': 'NOTIFICATION',
|
||||
'scope': 'BUSINESS',
|
||||
'subject': '[Ticket #{{TICKET_ID}}] Your request has been resolved',
|
||||
'html_content': self.get_ticket_resolved_html(),
|
||||
'text_content': self.get_ticket_resolved_text(),
|
||||
},
|
||||
]
|
||||
|
||||
# ========== HTML TEMPLATES ==========
|
||||
|
||||
def get_email_wrapper_start(self, title=''):
|
||||
return f'''<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{title}</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}}
|
||||
.container {{
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}}
|
||||
.header {{
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}}
|
||||
.business-name {{
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #6366f1;
|
||||
}}
|
||||
h1 {{
|
||||
color: #1f2937;
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
}}
|
||||
p {{
|
||||
color: #4b5563;
|
||||
margin-bottom: 15px;
|
||||
}}
|
||||
.highlight-box {{
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
border-radius: 12px;
|
||||
padding: 25px;
|
||||
margin: 25px 0;
|
||||
color: white;
|
||||
}}
|
||||
.info-row {{
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
}}
|
||||
.info-label {{
|
||||
font-weight: 600;
|
||||
margin-right: 10px;
|
||||
}}
|
||||
.cta-button {{
|
||||
display: inline-block;
|
||||
background-color: #6366f1;
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
padding: 14px 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
margin: 20px 0;
|
||||
}}
|
||||
.footer {{
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="business-name">{{{{BUSINESS_NAME}}}}</div>
|
||||
</div>
|
||||
'''
|
||||
|
||||
def get_email_wrapper_end(self):
|
||||
return '''
|
||||
<div class="footer">
|
||||
<p>
|
||||
This email was sent by {{BUSINESS_NAME}}.<br>
|
||||
If you have questions, please contact us at {{BUSINESS_EMAIL}}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
|
||||
def get_appointment_confirmation_html(self):
|
||||
return self.get_email_wrapper_start('Appointment Confirmation') + '''
|
||||
<h1>Your Appointment is Confirmed!</h1>
|
||||
|
||||
<p>Hi {{CUSTOMER_NAME}},</p>
|
||||
|
||||
<p>
|
||||
Great news! Your appointment at <strong>{{BUSINESS_NAME}}</strong> has been confirmed.
|
||||
We're looking forward to seeing you!
|
||||
</p>
|
||||
|
||||
<div class="highlight-box">
|
||||
<div class="info-row">
|
||||
<span class="info-label">📅 Date:</span>
|
||||
<span>{{APPOINTMENT_DATE}}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">🕐 Time:</span>
|
||||
<span>{{APPOINTMENT_TIME}}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">💼 Service:</span>
|
||||
<span>{{APPOINTMENT_SERVICE}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<strong>Need to make changes?</strong><br>
|
||||
If you need to reschedule or cancel, please contact us as soon as possible.
|
||||
</p>
|
||||
|
||||
<p>See you soon!</p>
|
||||
<p><strong>The {{BUSINESS_NAME}} Team</strong></p>
|
||||
''' + self.get_email_wrapper_end()
|
||||
|
||||
def get_appointment_confirmation_text(self):
|
||||
return '''Your Appointment is Confirmed!
|
||||
|
||||
Hi {{CUSTOMER_NAME}},
|
||||
|
||||
Great news! Your appointment at {{BUSINESS_NAME}} has been confirmed.
|
||||
|
||||
APPOINTMENT DETAILS
|
||||
-------------------
|
||||
Date: {{APPOINTMENT_DATE}}
|
||||
Time: {{APPOINTMENT_TIME}}
|
||||
Service: {{APPOINTMENT_SERVICE}}
|
||||
|
||||
Need to make changes?
|
||||
If you need to reschedule or cancel, please contact us as soon as possible.
|
||||
|
||||
See you soon!
|
||||
The {{BUSINESS_NAME}} Team
|
||||
|
||||
---
|
||||
{{BUSINESS_NAME}}
|
||||
{{BUSINESS_EMAIL}}
|
||||
{{BUSINESS_PHONE}}
|
||||
'''
|
||||
|
||||
def get_appointment_reminder_html(self, time_before):
|
||||
return self.get_email_wrapper_start('Appointment Reminder') + f'''
|
||||
<h1>Reminder: Your Appointment is Coming Up!</h1>
|
||||
|
||||
<p>Hi {{{{CUSTOMER_NAME}}}},</p>
|
||||
|
||||
<p>
|
||||
This is a friendly reminder that your appointment at <strong>{{{{BUSINESS_NAME}}}}</strong>
|
||||
is in <strong>{time_before}</strong>.
|
||||
</p>
|
||||
|
||||
<div class="highlight-box">
|
||||
<div class="info-row">
|
||||
<span class="info-label">📅 Date:</span>
|
||||
<span>{{{{APPOINTMENT_DATE}}}}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">🕐 Time:</span>
|
||||
<span>{{{{APPOINTMENT_TIME}}}}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">💼 Service:</span>
|
||||
<span>{{{{APPOINTMENT_SERVICE}}}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
We recommend arriving 5-10 minutes early to ensure a smooth check-in.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Need to reschedule?</strong><br>
|
||||
Please contact us as soon as possible if you need to make any changes.
|
||||
</p>
|
||||
|
||||
<p>See you soon!</p>
|
||||
<p><strong>The {{{{BUSINESS_NAME}}}} Team</strong></p>
|
||||
''' + self.get_email_wrapper_end()
|
||||
|
||||
def get_appointment_reminder_text(self, time_before):
|
||||
return f'''Reminder: Your Appointment is Coming Up!
|
||||
|
||||
Hi {{{{CUSTOMER_NAME}}}},
|
||||
|
||||
This is a friendly reminder that your appointment at {{{{BUSINESS_NAME}}}} is in {time_before}.
|
||||
|
||||
APPOINTMENT DETAILS
|
||||
-------------------
|
||||
Date: {{{{APPOINTMENT_DATE}}}}
|
||||
Time: {{{{APPOINTMENT_TIME}}}}
|
||||
Service: {{{{APPOINTMENT_SERVICE}}}}
|
||||
|
||||
We recommend arriving 5-10 minutes early to ensure a smooth check-in.
|
||||
|
||||
Need to reschedule?
|
||||
Please contact us as soon as possible if you need to make any changes.
|
||||
|
||||
See you soon!
|
||||
The {{{{BUSINESS_NAME}}}} Team
|
||||
|
||||
---
|
||||
{{{{BUSINESS_NAME}}}}
|
||||
{{{{BUSINESS_EMAIL}}}}
|
||||
{{{{BUSINESS_PHONE}}}}
|
||||
'''
|
||||
|
||||
def get_appointment_rescheduled_html(self):
|
||||
return self.get_email_wrapper_start('Appointment Rescheduled') + '''
|
||||
<h1>Your Appointment Has Been Rescheduled</h1>
|
||||
|
||||
<p>Hi {{CUSTOMER_NAME}},</p>
|
||||
|
||||
<p>
|
||||
Your appointment at <strong>{{BUSINESS_NAME}}</strong> has been rescheduled.
|
||||
Please note the new date and time below.
|
||||
</p>
|
||||
|
||||
<div class="highlight-box">
|
||||
<div class="info-row">
|
||||
<span class="info-label">📅 New Date:</span>
|
||||
<span>{{APPOINTMENT_DATE}}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">🕐 New Time:</span>
|
||||
<span>{{APPOINTMENT_TIME}}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">💼 Service:</span>
|
||||
<span>{{APPOINTMENT_SERVICE}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
If this new time doesn't work for you, please contact us to find an alternative.
|
||||
</p>
|
||||
|
||||
<p>Thank you for your understanding!</p>
|
||||
<p><strong>The {{BUSINESS_NAME}} Team</strong></p>
|
||||
''' + self.get_email_wrapper_end()
|
||||
|
||||
def get_appointment_rescheduled_text(self):
|
||||
return '''Your Appointment Has Been Rescheduled
|
||||
|
||||
Hi {{CUSTOMER_NAME}},
|
||||
|
||||
Your appointment at {{BUSINESS_NAME}} has been rescheduled.
|
||||
|
||||
NEW APPOINTMENT DETAILS
|
||||
-----------------------
|
||||
Date: {{APPOINTMENT_DATE}}
|
||||
Time: {{APPOINTMENT_TIME}}
|
||||
Service: {{APPOINTMENT_SERVICE}}
|
||||
|
||||
If this new time doesn't work for you, please contact us to find an alternative.
|
||||
|
||||
Thank you for your understanding!
|
||||
The {{BUSINESS_NAME}} Team
|
||||
|
||||
---
|
||||
{{BUSINESS_NAME}}
|
||||
{{BUSINESS_EMAIL}}
|
||||
{{BUSINESS_PHONE}}
|
||||
'''
|
||||
|
||||
def get_appointment_cancelled_html(self):
|
||||
return self.get_email_wrapper_start('Appointment Cancelled') + '''
|
||||
<h1>Your Appointment Has Been Cancelled</h1>
|
||||
|
||||
<p>Hi {{CUSTOMER_NAME}},</p>
|
||||
|
||||
<p>
|
||||
We're writing to confirm that your appointment at <strong>{{BUSINESS_NAME}}</strong>
|
||||
has been cancelled.
|
||||
</p>
|
||||
|
||||
<div style="background-color: #fef3c7; border: 1px solid #fcd34d; border-radius: 8px; padding: 15px; margin: 20px 0;">
|
||||
<p style="margin: 0; color: #92400e;">
|
||||
<strong>Cancelled Appointment:</strong><br>
|
||||
{{APPOINTMENT_DATE}} at {{APPOINTMENT_TIME}}<br>
|
||||
Service: {{APPOINTMENT_SERVICE}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
We'd love to see you! Would you like to book a new appointment?
|
||||
Visit our booking page or give us a call.
|
||||
</p>
|
||||
|
||||
<p>Thank you!</p>
|
||||
<p><strong>The {{BUSINESS_NAME}} Team</strong></p>
|
||||
''' + self.get_email_wrapper_end()
|
||||
|
||||
def get_appointment_cancelled_text(self):
|
||||
return '''Your Appointment Has Been Cancelled
|
||||
|
||||
Hi {{CUSTOMER_NAME}},
|
||||
|
||||
We're writing to confirm that your appointment at {{BUSINESS_NAME}} has been cancelled.
|
||||
|
||||
CANCELLED APPOINTMENT
|
||||
---------------------
|
||||
Date: {{APPOINTMENT_DATE}}
|
||||
Time: {{APPOINTMENT_TIME}}
|
||||
Service: {{APPOINTMENT_SERVICE}}
|
||||
|
||||
We'd love to see you! Would you like to book a new appointment?
|
||||
Visit our booking page or give us a call.
|
||||
|
||||
Thank you!
|
||||
The {{BUSINESS_NAME}} Team
|
||||
|
||||
---
|
||||
{{BUSINESS_NAME}}
|
||||
{{BUSINESS_EMAIL}}
|
||||
{{BUSINESS_PHONE}}
|
||||
'''
|
||||
|
||||
def get_thank_you_html(self):
|
||||
return self.get_email_wrapper_start('Thank You') + '''
|
||||
<h1>Thank You for Visiting!</h1>
|
||||
|
||||
<p>Hi {{CUSTOMER_NAME}},</p>
|
||||
|
||||
<p>
|
||||
Thank you for choosing <strong>{{BUSINESS_NAME}}</strong>!
|
||||
We hope you had a wonderful experience with us.
|
||||
</p>
|
||||
|
||||
<div style="background-color: #ecfdf5; border: 1px solid #6ee7b7; border-radius: 8px; padding: 20px; margin: 20px 0; text-align: center;">
|
||||
<p style="margin: 0; color: #065f46; font-size: 18px;">
|
||||
⭐ We'd Love Your Feedback! ⭐
|
||||
</p>
|
||||
<p style="margin: 10px 0 0 0; color: #047857;">
|
||||
Your opinion helps us improve and helps others find great services.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Ready to book your next appointment? We're here whenever you need us!
|
||||
</p>
|
||||
|
||||
<p>See you again soon!</p>
|
||||
<p><strong>The {{BUSINESS_NAME}} Team</strong></p>
|
||||
''' + self.get_email_wrapper_end()
|
||||
|
||||
def get_thank_you_text(self):
|
||||
return '''Thank You for Visiting!
|
||||
|
||||
Hi {{CUSTOMER_NAME}},
|
||||
|
||||
Thank you for choosing {{BUSINESS_NAME}}! We hope you had a wonderful experience with us.
|
||||
|
||||
We'd Love Your Feedback!
|
||||
Your opinion helps us improve and helps others find great services.
|
||||
|
||||
Ready to book your next appointment? We're here whenever you need us!
|
||||
|
||||
See you again soon!
|
||||
The {{BUSINESS_NAME}} Team
|
||||
|
||||
---
|
||||
{{BUSINESS_NAME}}
|
||||
{{BUSINESS_EMAIL}}
|
||||
{{BUSINESS_PHONE}}
|
||||
'''
|
||||
|
||||
def get_welcome_customer_html(self):
|
||||
# TODO: Implement full customer onboarding email flow
|
||||
# This should include: account setup, booking instructions, loyalty program info
|
||||
return self.get_email_wrapper_start('Welcome') + '''
|
||||
<h1>Welcome to {{BUSINESS_NAME}}!</h1>
|
||||
|
||||
<p>Hi {{CUSTOMER_NAME}},</p>
|
||||
|
||||
<p>
|
||||
Welcome! We're thrilled to have you join our community at <strong>{{BUSINESS_NAME}}</strong>.
|
||||
</p>
|
||||
|
||||
<div class="highlight-box">
|
||||
<p style="margin: 0; font-size: 18px; text-align: center;">
|
||||
🎉 Your account is all set up! 🎉
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>Here's what you can do:</p>
|
||||
<ul style="color: #4b5563;">
|
||||
<li>Book appointments online anytime</li>
|
||||
<li>View and manage your upcoming appointments</li>
|
||||
<li>Update your contact information and preferences</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
<strong>Ready to book your first appointment?</strong><br>
|
||||
We can't wait to see you!
|
||||
</p>
|
||||
|
||||
<p>Best regards,</p>
|
||||
<p><strong>The {{BUSINESS_NAME}} Team</strong></p>
|
||||
''' + self.get_email_wrapper_end()
|
||||
|
||||
def get_welcome_customer_text(self):
|
||||
# TODO: Implement full customer onboarding email flow
|
||||
return '''Welcome to {{BUSINESS_NAME}}!
|
||||
|
||||
Hi {{CUSTOMER_NAME}},
|
||||
|
||||
Welcome! We're thrilled to have you join our community at {{BUSINESS_NAME}}.
|
||||
|
||||
Your account is all set up!
|
||||
|
||||
Here's what you can do:
|
||||
- Book appointments online anytime
|
||||
- View and manage your upcoming appointments
|
||||
- Update your contact information and preferences
|
||||
|
||||
Ready to book your first appointment?
|
||||
We can't wait to see you!
|
||||
|
||||
Best regards,
|
||||
The {{BUSINESS_NAME}} Team
|
||||
|
||||
---
|
||||
{{BUSINESS_NAME}}
|
||||
{{BUSINESS_EMAIL}}
|
||||
{{BUSINESS_PHONE}}
|
||||
'''
|
||||
|
||||
# ========== TICKET NOTIFICATION TEMPLATES ==========
|
||||
|
||||
def get_ticket_assigned_html(self):
|
||||
return self.get_email_wrapper_start('Ticket Assigned') + '''
|
||||
<h1>New Ticket Assigned to You</h1>
|
||||
|
||||
<p>Hi {{ASSIGNEE_NAME}},</p>
|
||||
|
||||
<p>
|
||||
A ticket has been assigned to you and requires your attention.
|
||||
</p>
|
||||
|
||||
<div class="highlight-box">
|
||||
<div class="info-row">
|
||||
<span class="info-label">🎫 Ticket:</span>
|
||||
<span>#{{TICKET_ID}}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">📋 Subject:</span>
|
||||
<span>{{TICKET_SUBJECT}}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">⚡ Priority:</span>
|
||||
<span>{{TICKET_PRIORITY}}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">👤 From:</span>
|
||||
<span>{{TICKET_CUSTOMER_NAME}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #f3f4f6; border-radius: 8px; padding: 15px; margin: 20px 0;">
|
||||
<p style="margin: 0 0 10px 0; font-weight: 600; color: #374151;">Message:</p>
|
||||
<p style="margin: 0; color: #4b5563;">{{TICKET_MESSAGE}}</p>
|
||||
</div>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{TICKET_URL}}" class="cta-button">View Ticket</a>
|
||||
</p>
|
||||
|
||||
<p>Please respond as soon as possible.</p>
|
||||
<p><strong>{{BUSINESS_NAME}}</strong></p>
|
||||
''' + self.get_email_wrapper_end()
|
||||
|
||||
def get_ticket_assigned_text(self):
|
||||
return '''New Ticket Assigned to You
|
||||
|
||||
Hi {{ASSIGNEE_NAME}},
|
||||
|
||||
A ticket has been assigned to you and requires your attention.
|
||||
|
||||
TICKET DETAILS
|
||||
--------------
|
||||
Ticket: #{{TICKET_ID}}
|
||||
Subject: {{TICKET_SUBJECT}}
|
||||
Priority: {{TICKET_PRIORITY}}
|
||||
From: {{TICKET_CUSTOMER_NAME}}
|
||||
|
||||
Message:
|
||||
{{TICKET_MESSAGE}}
|
||||
|
||||
View ticket: {{TICKET_URL}}
|
||||
|
||||
Please respond as soon as possible.
|
||||
|
||||
---
|
||||
{{BUSINESS_NAME}}
|
||||
'''
|
||||
|
||||
def get_ticket_status_changed_html(self):
|
||||
return self.get_email_wrapper_start('Ticket Status Updated') + '''
|
||||
<h1>Ticket Status Updated</h1>
|
||||
|
||||
<p>Hi {{RECIPIENT_NAME}},</p>
|
||||
|
||||
<p>
|
||||
The status of ticket <strong>#{{TICKET_ID}}</strong> has been updated.
|
||||
</p>
|
||||
|
||||
<div class="highlight-box">
|
||||
<div class="info-row">
|
||||
<span class="info-label">🎫 Ticket:</span>
|
||||
<span>#{{TICKET_ID}}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">📋 Subject:</span>
|
||||
<span>{{TICKET_SUBJECT}}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">📊 New Status:</span>
|
||||
<span>{{TICKET_STATUS}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{TICKET_URL}}" class="cta-button">View Ticket</a>
|
||||
</p>
|
||||
|
||||
<p><strong>{{BUSINESS_NAME}}</strong></p>
|
||||
''' + self.get_email_wrapper_end()
|
||||
|
||||
def get_ticket_status_changed_text(self):
|
||||
return '''Ticket Status Updated
|
||||
|
||||
Hi {{RECIPIENT_NAME}},
|
||||
|
||||
The status of ticket #{{TICKET_ID}} has been updated.
|
||||
|
||||
TICKET DETAILS
|
||||
--------------
|
||||
Ticket: #{{TICKET_ID}}
|
||||
Subject: {{TICKET_SUBJECT}}
|
||||
New Status: {{TICKET_STATUS}}
|
||||
|
||||
View ticket: {{TICKET_URL}}
|
||||
|
||||
---
|
||||
{{BUSINESS_NAME}}
|
||||
'''
|
||||
|
||||
def get_ticket_reply_staff_html(self):
|
||||
return self.get_email_wrapper_start('New Customer Reply') + '''
|
||||
<h1>New Reply on Ticket #{{TICKET_ID}}</h1>
|
||||
|
||||
<p>Hi {{ASSIGNEE_NAME}},</p>
|
||||
|
||||
<p>
|
||||
<strong>{{TICKET_CUSTOMER_NAME}}</strong> has replied to ticket <strong>#{{TICKET_ID}}</strong>.
|
||||
</p>
|
||||
|
||||
<div style="background-color: #f3f4f6; border-radius: 8px; padding: 15px; margin: 20px 0;">
|
||||
<p style="margin: 0 0 10px 0; font-weight: 600; color: #374151;">
|
||||
Subject: {{TICKET_SUBJECT}}
|
||||
</p>
|
||||
<p style="margin: 0; color: #4b5563;">{{REPLY_MESSAGE}}</p>
|
||||
</div>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{TICKET_URL}}" class="cta-button">View & Reply</a>
|
||||
</p>
|
||||
|
||||
<p><strong>{{BUSINESS_NAME}}</strong></p>
|
||||
''' + self.get_email_wrapper_end()
|
||||
|
||||
def get_ticket_reply_staff_text(self):
|
||||
return '''New Reply on Ticket #{{TICKET_ID}}
|
||||
|
||||
Hi {{ASSIGNEE_NAME}},
|
||||
|
||||
{{TICKET_CUSTOMER_NAME}} has replied to ticket #{{TICKET_ID}}.
|
||||
|
||||
Subject: {{TICKET_SUBJECT}}
|
||||
|
||||
Reply:
|
||||
{{REPLY_MESSAGE}}
|
||||
|
||||
View & reply: {{TICKET_URL}}
|
||||
|
||||
---
|
||||
{{BUSINESS_NAME}}
|
||||
'''
|
||||
|
||||
def get_ticket_reply_customer_html(self):
|
||||
return self.get_email_wrapper_start('Response to Your Request') + '''
|
||||
<h1>We've Responded to Your Request</h1>
|
||||
|
||||
<p>Hi {{CUSTOMER_NAME}},</p>
|
||||
|
||||
<p>
|
||||
We've replied to your support request.
|
||||
</p>
|
||||
|
||||
<div class="highlight-box">
|
||||
<div class="info-row">
|
||||
<span class="info-label">🎫 Ticket:</span>
|
||||
<span>#{{TICKET_ID}}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">📋 Subject:</span>
|
||||
<span>{{TICKET_SUBJECT}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #f3f4f6; border-radius: 8px; padding: 15px; margin: 20px 0;">
|
||||
<p style="margin: 0 0 10px 0; font-weight: 600; color: #374151;">Our Response:</p>
|
||||
<p style="margin: 0; color: #4b5563;">{{REPLY_MESSAGE}}</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<strong>Need to reply?</strong><br>
|
||||
Simply reply to this email or click the button below.
|
||||
</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{TICKET_URL}}" class="cta-button">View Full Conversation</a>
|
||||
</p>
|
||||
|
||||
<p>Thank you for contacting us!</p>
|
||||
<p><strong>The {{BUSINESS_NAME}} Team</strong></p>
|
||||
''' + self.get_email_wrapper_end()
|
||||
|
||||
def get_ticket_reply_customer_text(self):
|
||||
return '''We've Responded to Your Request
|
||||
|
||||
Hi {{CUSTOMER_NAME}},
|
||||
|
||||
We've replied to your support request.
|
||||
|
||||
TICKET DETAILS
|
||||
--------------
|
||||
Ticket: #{{TICKET_ID}}
|
||||
Subject: {{TICKET_SUBJECT}}
|
||||
|
||||
Our Response:
|
||||
{{REPLY_MESSAGE}}
|
||||
|
||||
Need to reply?
|
||||
Simply reply to this email or visit: {{TICKET_URL}}
|
||||
|
||||
Thank you for contacting us!
|
||||
The {{BUSINESS_NAME}} Team
|
||||
|
||||
---
|
||||
{{BUSINESS_NAME}}
|
||||
{{BUSINESS_EMAIL}}
|
||||
{{BUSINESS_PHONE}}
|
||||
'''
|
||||
|
||||
def get_ticket_resolved_html(self):
|
||||
return self.get_email_wrapper_start('Ticket Resolved') + '''
|
||||
<h1>Your Request Has Been Resolved</h1>
|
||||
|
||||
<p>Hi {{CUSTOMER_NAME}},</p>
|
||||
|
||||
<p>
|
||||
Great news! Your support request has been resolved.
|
||||
</p>
|
||||
|
||||
<div style="background-color: #ecfdf5; border: 1px solid #6ee7b7; border-radius: 8px; padding: 20px; margin: 20px 0; text-align: center;">
|
||||
<p style="margin: 0; font-size: 18px; color: #065f46;">
|
||||
✅ Ticket #{{TICKET_ID}} - Resolved
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #f3f4f6; border-radius: 8px; padding: 15px; margin: 20px 0;">
|
||||
<p style="margin: 0 0 10px 0; font-weight: 600; color: #374151;">
|
||||
Subject: {{TICKET_SUBJECT}}
|
||||
</p>
|
||||
<p style="margin: 0; color: #4b5563; font-size: 14px;">
|
||||
Resolution: {{RESOLUTION_MESSAGE}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<strong>Not satisfied with the resolution?</strong><br>
|
||||
You can reopen this ticket by replying to this email within the next 7 days.
|
||||
</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{TICKET_URL}}" class="cta-button">View Ticket History</a>
|
||||
</p>
|
||||
|
||||
<p>Thank you for your patience!</p>
|
||||
<p><strong>The {{BUSINESS_NAME}} Team</strong></p>
|
||||
''' + self.get_email_wrapper_end()
|
||||
|
||||
def get_ticket_resolved_text(self):
|
||||
return '''Your Request Has Been Resolved
|
||||
|
||||
Hi {{CUSTOMER_NAME}},
|
||||
|
||||
Great news! Your support request has been resolved.
|
||||
|
||||
Ticket #{{TICKET_ID}} - RESOLVED
|
||||
|
||||
Subject: {{TICKET_SUBJECT}}
|
||||
Resolution: {{RESOLUTION_MESSAGE}}
|
||||
|
||||
Not satisfied with the resolution?
|
||||
You can reopen this ticket by replying to this email within the next 7 days.
|
||||
|
||||
View ticket history: {{TICKET_URL}}
|
||||
|
||||
Thank you for your patience!
|
||||
The {{BUSINESS_NAME}} Team
|
||||
|
||||
---
|
||||
{{BUSINESS_NAME}}
|
||||
{{BUSINESS_EMAIL}}
|
||||
{{BUSINESS_PHONE}}
|
||||
'''
|
||||
@@ -0,0 +1,16 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-02 02:54
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tickets', '0012_migrate_email_settings_to_addresses'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='TicketEmailSettings',
|
||||
),
|
||||
]
|
||||
Reference in New Issue
Block a user