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:
poduck
2025-12-02 01:42:38 -05:00
parent 8038f67183
commit 05ebd0f2bb
77 changed files with 14185 additions and 1394 deletions

View 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 &amp; 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}")

View 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>

View 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
View 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
View 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
View 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.

View 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>

View 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>

View 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>

View 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>

View 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 &rarr;</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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -1,2 +1,3 @@
VITE_DEV_MODE=true
VITE_API_URL=http://api.lvh.me:8000
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51SYttT4pb5kWPtNt8n52NRQLyBGbFQ52tnG1O5o11V06m3TPUyIzf6AHOpFNQErBj4m7pOwM6VzltePrdL16IFn0004YqWhRpA

View 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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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="/" />} />

View 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;

View File

@@ -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;

View File

@@ -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>
);

View 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;

View File

@@ -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>

View 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>
);
};

View File

@@ -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();

View 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
});
};

View File

@@ -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;

View 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;

View File

@@ -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

View File

@@ -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>

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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';

View File

@@ -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

View File

@@ -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',

View File

@@ -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

View File

@@ -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),
),
]

View File

@@ -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'),
),
]

View 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),
),
]

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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}"
)
)

View File

@@ -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'),
),
]

View File

@@ -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(

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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.

View File

@@ -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()
})

View 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'

View 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'

View File

@@ -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')],
},
),
]

View File

@@ -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),
),
]

View 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

View 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)}

View 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'),
]

View 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

View File

@@ -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}}
'''

View File

@@ -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',
),
]