diff --git a/TODO_EMAIL_AND_MESSAGING.md b/TODO_EMAIL_AND_MESSAGING.md new file mode 100644 index 0000000..2c399f2 --- /dev/null +++ b/TODO_EMAIL_AND_MESSAGING.md @@ -0,0 +1,133 @@ +# Email Templates & Messaging TODO + +## Completed Features + +### Email Template System (Implemented) +- [x] EmailTemplate model with BUSINESS and PLATFORM scopes +- [x] Categories: Appointment, Reminder, Confirmation, Marketing, Notification, Report, Other +- [x] Template variables: `{{BUSINESS_NAME}}`, `{{CUSTOMER_NAME}}`, `{{APPOINTMENT_DATE}}`, etc. +- [x] Backend API (CRUD, preview, duplicate) +- [x] Frontend Email Templates page with list/create/edit/delete +- [x] EmailTemplateForm component with code editor and preview +- [x] EmailTemplateSelector component for plugin configuration +- [x] Plugin config support for `email_template` type variables +- [x] Footer enforcement for FREE tier tenants +- [x] Default templates seed command (`python manage.py seed_email_templates`) + +### Default Email Templates Created +- [x] Appointment Confirmation +- [x] Appointment Reminder - 24 Hours +- [x] Appointment Reminder - 1 Hour +- [x] Appointment Rescheduled +- [x] Appointment Cancelled +- [x] Thank You - Appointment Complete +- [x] Welcome New Customer + +### Ticket Email Templates (Added) +- [x] Ticket Assigned - Notification to staff when assigned +- [x] Ticket Status Changed - Status update notification +- [x] Ticket Reply - Staff Notification - When customer replies +- [x] Ticket Reply - Customer Notification - When staff replies +- [x] Ticket Resolved - Resolution confirmation + +### Plugin Documentation Updated +- [x] Documented `email_template` variable type in SCHEDULER_PLUGIN_SYSTEM.md +- [x] Added template variable types table (text, textarea, email, number, url, email_template) +- [x] Added ticket-related insertion codes to template_parser.py + +--- + +## Pending Features + +### Customer Onboarding Email Process +- [ ] Welcome email when customer creates account +- [ ] Email verification workflow +- [ ] First booking celebration email +- [ ] Loyalty program introduction email (if enabled) + +### Internal Staff Messaging System +- [ ] Message model (sender, recipient, subject, body, read status) +- [ ] Conversation threading +- [ ] Messaging UI in sidebar/header +- [ ] Unread count badge +- [ ] Push notifications for new messages +- [ ] Email notification option for messages + +### Ticket Email Notifications +- [x] Email templates created (see "Ticket Email Templates" in Completed) +- [x] Ticket email notification service (`tickets/email_notifications.py`) + - TicketEmailService class with methods for each notification type + - Renders email templates with ticket context variables + - Falls back to default text templates if custom templates not found + - Includes Reply-To header with ticket ID for email threading +- [x] Django signals to trigger emails on ticket events (`tickets/signals.py`) + - Pre-save handler to track state changes (assignee, status) + - Post-save handlers for assignment, status change, and resolution + - Comment handler for reply notifications +- [x] Reply-To header with ticket ID for inbound processing + - Format: `support+ticket-{id}@{domain}` + - X-Ticket-ID header for easy parsing + +### Email Reply Forwarding (Inbound Email Processing) +- [ ] Setup inbound email handling (mail.talova.net integration) +- [ ] Parse incoming emails to support@smoothschedule.com (or per-tenant) +- [ ] Extract ticket ID from subject line or headers +- [ ] Create ticket reply from email content +- [ ] Notify assignee of new reply +- [ ] Handle attachments + +--- + +## Email Server Details + +Current email server: `mail.talova.net` +Test account: `poduck@mail.talova.net` + +### Inbound Email Options + +1. **Mailgun/SendGrid Inbound Routes** + - Forward to webhook endpoint + - Parse and process programmatically + - Best for scalability + +2. **IMAP Polling** + - Connect to mail.talova.net via IMAP + - Poll for new messages periodically + - Process and delete/archive + +3. **Email Piping** + - Configure MTA to pipe emails to a Django management command + - Real-time processing + - Requires server access + +--- + +## Platform vs Business Templates + +### Platform Templates (Scope: PLATFORM) +Used for system-wide emails: +- Tenant invitation +- Platform announcements +- System notifications + +### Business Templates (Scope: BUSINESS) +Per-tenant customizable templates: +- Appointment confirmations +- Reminders +- Customer communications +- Marketing emails + +--- + +## Running the Seed Command + +```bash +# Seed templates for all tenant schemas +docker compose -f docker-compose.local.yml exec django python manage.py seed_email_templates + +# Seed for specific schema +docker compose -f docker-compose.local.yml exec django python manage.py seed_email_templates --schema=demo + +# Reset to defaults (overwrites existing) +docker compose -f docker-compose.local.yml exec django python manage.py seed_email_templates --reset +``` diff --git a/frontend/public/plugin-logos/appointment-reminder-24hr.png b/frontend/public/plugin-logos/appointment-reminder-24hr.png index 5062e76..e99f7e6 100644 Binary files a/frontend/public/plugin-logos/appointment-reminder-24hr.png and b/frontend/public/plugin-logos/appointment-reminder-24hr.png differ diff --git a/frontend/public/plugin-logos/birthday-greetings.png b/frontend/public/plugin-logos/birthday-greetings.png index 648d415..13ca2a5 100644 Binary files a/frontend/public/plugin-logos/birthday-greetings.png and b/frontend/public/plugin-logos/birthday-greetings.png differ diff --git a/frontend/public/plugin-logos/daily-appointment-summary.png b/frontend/public/plugin-logos/daily-appointment-summary.png index 55f545a..5312ad9 100644 Binary files a/frontend/public/plugin-logos/daily-appointment-summary.png and b/frontend/public/plugin-logos/daily-appointment-summary.png differ diff --git a/frontend/public/plugin-logos/generate_all_logos_gemini.py b/frontend/public/plugin-logos/generate_all_logos_gemini.py deleted file mode 100644 index 38d41d5..0000000 --- a/frontend/public/plugin-logos/generate_all_logos_gemini.py +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env python3 -from google import genai -from google.genai import types -from PIL import Image -from io import BytesIO -import os -import time - -# Setup Client -client = genai.Client(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw") - -OUTPUT_DIR = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos" - -# Plugin configurations with detailed prompts -plugins = [ - { - 'filename': 'daily-appointment-summary.png', - 'prompt': '''Create a modern, minimalist app icon in square format with rounded corners. -Design: Indigo blue gradient background (#4f46e5 to lighter blue). -Icon: Simple white envelope overlaid with a small calendar icon in the corner. -Style: Flat design, clean geometric shapes, professional SaaS aesthetic. -The icon should be crisp and recognizable at 48x48 pixels.''' - }, - { - 'filename': 'no-show-tracker.png', - 'prompt': '''Create a modern, minimalist app icon in square format with rounded corners. -Design: Red gradient background (#dc2626 to darker red). -Icon: Simple white person silhouette with a bold white X symbol overlaid. -Style: Flat design, clean geometric shapes, professional warning aesthetic. -The icon should clearly convey "missed appointment" at small sizes.''' - }, - { - 'filename': 'birthday-greetings.png', - 'prompt': '''Create a modern, minimalist app icon in square format with rounded corners. -Design: Pink gradient background (#ec4899 to lighter pink). -Icon: Simple white birthday cake with 3 candles or a white gift box with a bow. -Style: Flat design, clean geometric shapes, cheerful yet professional aesthetic. -The icon should feel celebratory and friendly at small sizes.''' - }, - { - 'filename': 'monthly-revenue-report.png', - 'prompt': '''Create a modern, minimalist app icon in square format with rounded corners. -Design: Green gradient background (#10b981 to darker green). -Icon: Simple white upward trending line chart or ascending bar graph. -Style: Flat design, clean geometric shapes, professional business analytics aesthetic. -The icon should convey growth and success at small sizes.''' - }, - { - 'filename': 'appointment-reminder-24hr.png', - 'prompt': '''Create a modern, minimalist app icon in square format with rounded corners. -Design: Amber/orange gradient background (#f59e0b to darker orange). -Icon: Simple white notification bell with a small red circular alert badge. -Style: Flat design, clean geometric shapes, attention-grabbing yet professional aesthetic. -The icon should clearly indicate alerts and reminders at small sizes.''' - }, - { - 'filename': 'inactive-customer-reengagement.png', - 'prompt': '''Create a modern, minimalist app icon in square format with rounded corners. -Design: Purple gradient background (#8b5cf6 to darker purple). -Icon: Simple white heart symbol with a circular white refresh/return arrow around it. -Style: Flat design, clean geometric shapes, warm and welcoming professional aesthetic. -The icon should convey customer care and returning customers at small sizes.''' - } -] - -def generate_logo(prompt, filename): - """Generate a logo using Gemini 2.5 Flash Image""" - print(f"\nGenerating {filename}...") - - try: - response = client.models.generate_content( - model='gemini-2.5-flash-image', - contents=prompt, - config=types.GenerateContentConfig( - response_modalities=["IMAGE"] - ) - ) - - # Save the Image - if response.candidates[0].content.parts: - for part in response.candidates[0].content.parts: - if part.inline_data: - image_data = part.inline_data.data - image = Image.open(BytesIO(image_data)) - - # Save to output directory - output_path = os.path.join(OUTPUT_DIR, filename) - image.save(output_path) - print(f"✓ Saved: {output_path}") - return True - else: - print(f"✗ Model returned text instead of image: {part.text[:100]}") - return False - else: - print("✗ No content parts in response") - return False - - except Exception as e: - error_msg = str(e) - if "RESOURCE_EXHAUSTED" in error_msg or "quota" in error_msg.lower(): - print(f"✗ Quota exceeded. Please try again tomorrow when quota resets.") - print(f" Error: {error_msg[:200]}...") - else: - print(f"✗ Error: {type(e).__name__}: {error_msg[:200]}") - return False - -def main(): - os.makedirs(OUTPUT_DIR, exist_ok=True) - - print("=" * 70) - print("Generating Plugin Logos with Gemini 2.5 Flash Image") - print("=" * 70) - - success_count = 0 - for i, plugin in enumerate(plugins): - if i > 0: - # Wait 3 seconds between requests to avoid rate limiting - print("\nWaiting 3 seconds before next request...") - time.sleep(3) - - if generate_logo(plugin['prompt'], plugin['filename']): - success_count += 1 - - print("\n" + "=" * 70) - print(f"Generation complete: {success_count}/{len(plugins)} successful") - print("=" * 70) - - if success_count == 0: - print("\nNote: If you hit quota limits, try again tomorrow when the quota resets!") - -if __name__ == "__main__": - main() diff --git a/frontend/public/plugin-logos/generate_daily_summary.py b/frontend/public/plugin-logos/generate_daily_summary.py deleted file mode 100644 index d7872f3..0000000 --- a/frontend/public/plugin-logos/generate_daily_summary.py +++ /dev/null @@ -1,77 +0,0 @@ -import os -import sys - -try: - from PIL import Image, ImageDraw -except ImportError: - print("Error: Pillow library not found.") - print("Please install it using: pip install Pillow") - sys.exit(1) - -# Configuration -OUTPUT_PATH = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos/daily-appointment-summary.png" -SIZE = (144, 144) # Generated at 3x resolution (144px) for high quality on 48px displays -BG_COLOR = "#4f46e5" # Indigo/Blue -ICON_COLOR = "white" - -def create_logo(): - # Create a new image with a transparent background - img = Image.new('RGBA', SIZE, (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - - # 1. Draw Background (Rounded Square) - radius = 30 - rect_bounds = [0, 0, SIZE[0], SIZE[1]] - draw.rounded_rectangle(rect_bounds, radius=radius, fill=BG_COLOR) - - # 2. Draw Envelope Icon (Email concept) - # Centered, slightly offset up to make room for calendar - env_w = 90 - env_h = 60 - env_x = (SIZE[0] - env_w) // 2 - env_y = (SIZE[1] - env_h) // 2 - 10 - - # Envelope body - draw.rectangle([env_x, env_y, env_x + env_w, env_y + env_h], outline=ICON_COLOR, width=5) - - # Envelope flap (V shape) - draw.line([env_x, env_y, env_x + env_w // 2, env_y + env_h // 2 + 5], fill=ICON_COLOR, width=5) - draw.line([env_x + env_w, env_y, env_x + env_w // 2, env_y + env_h // 2 + 5], fill=ICON_COLOR, width=5) - - # 3. Draw Calendar Badge (Scheduling concept) - # Bottom right corner - cal_size = 50 - cal_x = env_x + env_w - (cal_size // 2) - cal_y = env_y + env_h - (cal_size // 2) - - # Clear background behind calendar for separation - border = 4 - draw.rounded_rectangle( - [cal_x - border, cal_y - border, cal_x + cal_size + border, cal_y + cal_size + border], - radius=10, fill=BG_COLOR - ) - - # Calendar body - draw.rounded_rectangle([cal_x, cal_y, cal_x + cal_size, cal_y + cal_size], radius=8, fill="white") - - # Calendar red header (using a lighter indigo/blue to match theme) - header_h = 14 - draw.rounded_rectangle( - [cal_x, cal_y, cal_x + cal_size, cal_y + header_h], - radius=8, corners=(True, True, False, False), fill="#818cf8" - ) - - # Calendar 'lines' - line_x = cal_x + 10 - line_w = cal_size - 20 - draw.line([line_x, cal_y + 22, line_x + line_w, cal_y + 22], fill=BG_COLOR, width=3) - draw.line([line_x, cal_y + 32, line_x + line_w, cal_y + 32], fill=BG_COLOR, width=3) - draw.line([line_x, cal_y + 42, line_x + line_w * 0.6, cal_y + 42], fill=BG_COLOR, width=3) - - # Save the file - os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True) - img.save(OUTPUT_PATH) - print(f"Success! Logo saved to: {OUTPUT_PATH}") - -if __name__ == "__main__": - create_logo() diff --git a/frontend/public/plugin-logos/generate_icons.py b/frontend/public/plugin-logos/generate_icons.py new file mode 100644 index 0000000..6588e26 --- /dev/null +++ b/frontend/public/plugin-logos/generate_icons.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +""" +Generate plugin icons using Gemini 2.5 Flash Image API. +Based on: https://ai.google.dev/gemini-api/docs/image-generation +""" +from google import genai +from PIL import Image +import os +import time + +# API Key +client = genai.Client(api_key="AIzaSyBF3_KnttSerq2BK2CaRKSTJHN8BoXp6Hw") + +OUTPUT_DIR = os.path.dirname(os.path.abspath(__file__)) + +# Plugin configurations with improved prompts +plugins = [ + { + 'filename': 'daily-appointment-summary.png', + 'prompt': '''Generate a professional app icon, 512x512 pixels, square with rounded corners. + +Style: Modern gradient background transitioning from deep indigo (#4338ca) to vibrant blue (#3b82f6). + +Central element: A stylized white clipboard or document with 3 horizontal lines representing a list, and a small clock or sun symbol in the top right corner indicating "daily". + +Design principles: Minimalist, clean lines, no text, suitable for a scheduling/calendar application. The icon should be instantly recognizable as "daily summary" or "daily report" at small sizes like 32x32.''' + }, + { + 'filename': 'no-show-tracker.png', + 'prompt': '''Generate a professional app icon, 512x512 pixels, square with rounded corners. + +Style: Modern gradient background from deep red (#b91c1c) to coral red (#ef4444). + +Central element: A white calendar or appointment card with a prominent empty circle or "absent" symbol - perhaps a chair silhouette with a question mark, or a clock with a slash through it indicating a missed appointment. + +Design principles: Minimalist, clean lines, no text. The icon should clearly communicate "missed" or "no-show" at a glance. Professional warning aesthetic without being alarming.''' + }, + { + 'filename': 'birthday-greetings.png', + 'prompt': '''Generate a professional app icon, 512x512 pixels, square with rounded corners. + +Style: Modern gradient background from magenta (#c026d3) to pink (#ec4899). + +Central element: A white birthday cake with 2-3 simple candles, or a wrapped gift box with a ribbon bow. Add subtle confetti dots or small stars around it for a celebratory feel. + +Design principles: Minimalist, cheerful but professional, clean lines, no text. Should feel warm and celebratory while still fitting a business application aesthetic.''' + }, + { + 'filename': 'monthly-revenue-report.png', + 'prompt': '''Generate a professional app icon, 512x512 pixels, square with rounded corners. + +Style: Modern gradient background from emerald green (#059669) to teal (#14b8a6). + +Central element: A white line chart showing an upward trend with 3-4 data points connected by lines, or ascending bar chart with dollar sign integrated subtly. The graph should clearly show growth/success. + +Design principles: Minimalist, professional finance/analytics aesthetic, clean lines, no text. Should instantly communicate "revenue" and "growth" at small sizes.''' + }, + { + 'filename': 'appointment-reminder-24hr.png', + 'prompt': '''Generate a professional app icon, 512x512 pixels, square with rounded corners. + +Style: Modern gradient background from amber (#d97706) to orange (#f97316). + +Central element: A white notification bell with a small circular badge, combined with a "24" numeral or a clock face showing time. The bell should be the primary focus with the time element secondary. + +Design principles: Minimalist, attention-grabbing but professional, clean lines, no text except possibly "24". Should clearly indicate "reminder" and "advance notice" at small sizes.''' + }, + { + 'filename': 'inactive-customer-reengagement.png', + 'prompt': '''Generate a professional app icon, 512x512 pixels, square with rounded corners. + +Style: Modern gradient background from violet (#7c3aed) to purple (#a855f7). + +Central element: A white person silhouette or user icon with a curved "return" arrow circling back to them, or a magnet symbol attracting a small person icon. Should convey "bringing customers back". + +Design principles: Minimalist, warm and welcoming professional aesthetic, clean lines, no text. Should communicate "re-engagement" or "win back" at a glance.''' + } +] + + +def generate_logo(prompt: str, filename: str) -> bool: + """Generate a logo using Gemini 2.5 Flash Image (per official docs)""" + print(f"\nGenerating {filename}...") + + try: + # Using the exact format from Google's documentation + response = client.models.generate_content( + model='gemini-2.5-flash-image', + contents=[prompt], + ) + + # Process response per docs + for part in response.parts: + if part.inline_data is not None: + # Get the raw image data and convert to PIL + from io import BytesIO + image_data = part.inline_data.data + pil_image = Image.open(BytesIO(image_data)) + + # Resize to 256x256 for consistency + if pil_image.size != (256, 256): + pil_image = pil_image.resize((256, 256), Image.Resampling.LANCZOS) + + output_path = os.path.join(OUTPUT_DIR, filename) + pil_image.save(output_path, 'PNG') + print(f" ✓ Saved: {output_path}") + return True + elif part.text is not None: + print(f" Model text: {part.text[:100]}...") + + print(" ✗ No image in response") + return False + + except Exception as e: + error_msg = str(e) + if "RESOURCE_EXHAUSTED" in error_msg or "quota" in error_msg.lower(): + print(f" ✗ Quota exceeded - try again later") + else: + print(f" ✗ Error: {type(e).__name__}: {error_msg[:300]}") + return False + + +def main(): + print("=" * 60) + print("Generating Plugin Icons with Gemini 2.5 Flash Image") + print("=" * 60) + + success_count = 0 + for i, plugin in enumerate(plugins): + if i > 0: + print("\nWaiting 5 seconds...") + time.sleep(5) + + if generate_logo(plugin['prompt'], plugin['filename']): + success_count += 1 + + print("\n" + "=" * 60) + print(f"Complete: {success_count}/{len(plugins)} icons generated") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/frontend/public/plugin-logos/generate_plugin_logos.py b/frontend/public/plugin-logos/generate_plugin_logos.py deleted file mode 100644 index 6144a82..0000000 --- a/frontend/public/plugin-logos/generate_plugin_logos.py +++ /dev/null @@ -1,155 +0,0 @@ -import os -import sys - -try: - from PIL import Image, ImageDraw, ImageFont -except ImportError: - print("Error: Pillow library not found.") - print("Please install it using: pip install Pillow") - sys.exit(1) - -# Configuration -OUTPUT_DIR = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos" -SIZE = (144, 144) -RADIUS = 30 - -def create_base_logo(bg_color): - """Creates the base image with rounded corners and background color.""" - img = Image.new('RGBA', SIZE, (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - rect_bounds = [0, 0, SIZE[0], SIZE[1]] - draw.rounded_rectangle(rect_bounds, radius=RADIUS, fill=bg_color) - return img, draw - -def save_logo(img, filename): - """Saves the image to the output directory.""" - path = os.path.join(OUTPUT_DIR, filename) - os.makedirs(os.path.dirname(path), exist_ok=True) - img.save(path) - print(f"Saved: {path}") - -# 1. No-Show Customer Tracker (Red, Person with X) -def create_no_show_logo(): - img, draw = create_base_logo("#dc2626") # Red - - # Person Silhouette - cx, cy = 72, 72 - # Head - draw.ellipse([cx-15, 35, cx+15, 65], fill="white") - # Shoulders/Body - draw.rounded_rectangle([cx-30, 70, cx+30, 130], radius=10, fill="white") - - # Cancel Symbol (Red X on white circle or just overlaid) - # Let's use a thick Red X overlay - x_center_y = 85 - half = 20 - width = 8 - - # Draw X - draw.line([cx - half, x_center_y - half, cx + half, x_center_y + half], fill="#dc2626", width=width) - draw.line([cx + half, x_center_y - half, cx - half, x_center_y + half], fill="#dc2626", width=width) - - save_logo(img, "no-show-tracker.png") - -# 2. Birthday Greeting Campaign (Pink, Gift) -def create_birthday_logo(): - img, draw = create_base_logo("#ec4899") # Pink - - # Gift Box - box_w, box_h = 70, 60 - box_x = (SIZE[0] - box_w) // 2 - box_y = (SIZE[1] - box_h) // 2 + 10 - - # Box body - draw.rectangle([box_x, box_y, box_x + box_w, box_y + box_h], fill="white") - - # Ribbons (Lighter Pink) - r_w = 12 - ribbon_color = "#fbcfe8" - # Vertical - draw.rectangle([box_x + (box_w - r_w)//2, box_y, box_x + (box_w + r_w)//2, box_y + box_h], fill=ribbon_color) - # Horizontal - draw.rectangle([box_x, box_y + (box_h - r_w)//2, box_x + box_w, box_y + (box_h + r_w)//2], fill=ribbon_color) - - # Bow - bow_w = 20 - draw.ellipse([72 - bow_w, box_y - 15, 72, box_y], fill="white") - draw.ellipse([72, box_y - 15, 72 + bow_w, box_y], fill="white") - - save_logo(img, "birthday-greetings.png") - -# 3. Monthly Revenue Report (Green, Chart) -def create_revenue_logo(): - img, draw = create_base_logo("#10b981") # Green - - # Bar Chart - margin_bottom = 110 - bar_w = 20 - gap = 10 - start_x = (144 - (3 * bar_w + 2 * gap)) // 2 - - # Bars - heights = [30, 50, 75] - for i, h in enumerate(heights): - x = start_x + i * (bar_w + gap) - draw.rectangle([x, margin_bottom - h, x + bar_w, margin_bottom], fill="white") - - # Trend Arrow (Rising) - arrow_start = (start_x - 5, margin_bottom - 10) - arrow_end = (start_x + 3 * bar_w + 2 * gap + 5, margin_bottom - 90) - - # Simple line for trend - # draw.line([arrow_start, arrow_end], fill="white", width=4) - - save_logo(img, "monthly-revenue-report.png") - -# 4. Appointment Reminder (24hr) (Amber, Bell) -def create_reminder_logo(): - img, draw = create_base_logo("#f59e0b") # Amber - - cx, cy = 72, 70 - r = 30 - - # Bell Dome - draw.chord([cx - r, cy - r, cx + r, cy + r], 180, 0, fill="white") - draw.rectangle([cx - r, cy, cx + r, cy + 20], fill="white") - - # Bell Flare - draw.polygon([(cx - r, cy + 20), (cx + r, cy + 20), (cx + r + 5, cy + 30), (cx - r - 5, cy + 30)], fill="white") - - # Clapper - draw.ellipse([cx - 8, cy + 28, cx + 8, cy + 42], fill="white") - - # Notification Dot - draw.ellipse([cx + 15, cy - 25, cx + 35, cy - 5], fill="#dc2626") - - save_logo(img, "appointment-reminder-24hr.png") - -# 5. Inactive Customer Re-engagement (Purple, Heart) -def create_inactive_logo(): - img, draw = create_base_logo("#8b5cf6") # Purple - - hx, hy = 72, 75 - size = 18 - - # Heart Shape - # Left circle - draw.ellipse([hx - size * 2, hy - size, hx, hy + size], fill="white") - # Right circle - draw.ellipse([hx, hy - size, hx + size * 2, hy + size], fill="white") - # Bottom triangle - draw.polygon([(hx - size * 2 + 1, hy + 4), (hx + size * 2 - 1, hy + 4), (hx, hy + size * 2 + 8)], fill="white") - - # Refresh/Loop Arrow (Subtle arc above) - bbox = [25, 25, 119, 119] - draw.arc(bbox, start=180, end=0, fill="#ddd6fe", width=5) - draw.polygon([(119, 72), (110, 60), (128, 60)], fill="#ddd6fe") - - save_logo(img, "inactive-customer-reengagement.png") - -if __name__ == "__main__": - create_no_show_logo() - create_birthday_logo() - create_revenue_logo() - create_reminder_logo() - create_inactive_logo() diff --git a/frontend/public/plugin-logos/generate_with_2_0_flash.py b/frontend/public/plugin-logos/generate_with_2_0_flash.py deleted file mode 100644 index 5571a5f..0000000 --- a/frontend/public/plugin-logos/generate_with_2_0_flash.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 -from google import genai -from google.genai import types -from PIL import Image -from io import BytesIO - -# Setup Client -client = genai.Client(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw") - -# Test with a simple prompt -prompt = "Create a simple modern app icon with a blue background and white envelope symbol, square format, flat design, minimalist" - -print(f"Testing gemini-2.0-flash-exp...") -print(f"Prompt: '{prompt}'...") - -try: - response = client.models.generate_content( - model='gemini-2.0-flash-exp', - contents=prompt, - config=types.GenerateContentConfig( - response_modalities=["IMAGE"] - ) - ) - - # Save the Image - if response.candidates[0].content.parts: - for part in response.candidates[0].content.parts: - if part.inline_data: - image_data = part.inline_data.data - image = Image.open(BytesIO(image_data)) - image.save("test_2_0_flash.png") - print("✓ Success! Saved test_2_0_flash.png") - else: - print(f"✗ Model returned text: {part.text[:100]}") - else: - print("✗ No content parts in response") - -except Exception as e: - print(f"✗ Error: {type(e).__name__}: {str(e)[:300]}") diff --git a/frontend/public/plugin-logos/generate_with_gemini.py b/frontend/public/plugin-logos/generate_with_gemini.py deleted file mode 100644 index 7e8cbe1..0000000 --- a/frontend/public/plugin-logos/generate_with_gemini.py +++ /dev/null @@ -1,194 +0,0 @@ -#!/usr/bin/env python3 -import os -import sys -import requests -import base64 -from pathlib import Path - -# Gemini API configuration -API_KEY = "AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw" -API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent" - -OUTPUT_DIR = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos" - -# Plugin configurations -plugins = [ - { - 'filename': 'daily-appointment-summary.png', - 'prompt': '''Create a modern, professional icon/logo for a plugin called "Daily Appointment Summary Email". - -Design requirements: -- Square icon, 512x512 pixels -- Flat design style with a slight gradient -- Primary color: Indigo/blue (#4f46e5) -- Icon should combine email and calendar/scheduling concepts -- Clean, minimalist design suitable for a SaaS application -- White or light elements on the colored background -- Rounded corners (similar to modern app icons) -- Professional and trustworthy appearance - -The icon represents a plugin that sends daily email summaries of appointments to staff members.''' - }, - { - 'filename': 'no-show-tracker.png', - 'prompt': '''Create a modern, professional icon/logo for a plugin called "No-Show Customer Tracker". - -Design requirements: -- Square icon, 512x512 pixels -- Flat design style with a slight gradient -- Primary color: Red (#dc2626) -- Icon should represent missed appointments or absent customers -- Could show a person silhouette with an X, slash, or cancel symbol -- Clean, minimalist design suitable for a SaaS application -- White or light elements on the colored background -- Rounded corners (similar to modern app icons) -- Professional appearance - -The icon represents a plugin that tracks customers who miss their appointments.''' - }, - { - 'filename': 'birthday-greetings.png', - 'prompt': '''Create a modern, professional icon/logo for a plugin called "Birthday Greeting Campaign". - -Design requirements: -- Square icon, 512x512 pixels -- Flat design style with a slight gradient -- Primary color: Pink (#ec4899) -- Icon should represent birthdays and celebrations -- Could show a birthday cake, gift, party hat, or balloon -- Clean, minimalist design suitable for a SaaS application -- White or light elements on the colored background -- Rounded corners (similar to modern app icons) -- Friendly and celebratory appearance - -The icon represents a plugin that sends birthday emails with special offers to customers.''' - }, - { - 'filename': 'monthly-revenue-report.png', - 'prompt': '''Create a modern, professional icon/logo for a plugin called "Monthly Revenue Report". - -Design requirements: -- Square icon, 512x512 pixels -- Flat design style with a slight gradient -- Primary color: Green (#10b981) -- Icon should represent business growth, analytics, and financial reporting -- Could show an upward trending chart, graph, or money symbol -- Clean, minimalist design suitable for a SaaS application -- White or light elements on the colored background -- Rounded corners (similar to modern app icons) -- Professional and successful appearance - -The icon represents a plugin that generates comprehensive monthly business statistics and revenue reports.''' - }, - { - 'filename': 'appointment-reminder-24hr.png', - 'prompt': '''Create a modern, professional icon/logo for a plugin called "Appointment Reminder (24hr)". - -Design requirements: -- Square icon, 512x512 pixels -- Flat design style with a slight gradient -- Primary color: Amber/Orange (#f59e0b) -- Icon should represent notifications, alerts, and reminders -- Could show a bell with a notification badge, clock, or alarm -- Clean, minimalist design suitable for a SaaS application -- White or light elements on the colored background -- Rounded corners (similar to modern app icons) -- Attention-grabbing but professional appearance - -The icon represents a plugin that sends reminder emails to customers 24 hours before their appointments.''' - }, - { - 'filename': 'inactive-customer-reengagement.png', - 'prompt': '''Create a modern, professional icon/logo for a plugin called "Inactive Customer Re-engagement". - -Design requirements: -- Square icon, 512x512 pixels -- Flat design style with a slight gradient -- Primary color: Purple (#8b5cf6) -- Icon should represent customer retention, returning customers, or re-engagement -- Could show a heart, person with return arrow, refresh symbol, or comeback concept -- Clean, minimalist design suitable for a SaaS application -- White or light elements on the colored background -- Rounded corners (similar to modern app icons) -- Warm and welcoming appearance - -The icon represents a plugin that wins back customers who haven't booked appointments recently.''' - } -] - -def generate_image(prompt, filename): - """Generate an image using Gemini API""" - print(f"\nGenerating {filename}...") - - headers = { - "Content-Type": "application/json" - } - - payload = { - "contents": [{ - "parts": [{ - "text": prompt - }] - }], - "generationConfig": { - "temperature": 1, - "topK": 40, - "topP": 0.95, - "maxOutputTokens": 8192, - } - } - - try: - response = requests.post( - f"{API_URL}?key={API_KEY}", - headers=headers, - json=payload, - timeout=120 - ) - - if response.status_code == 200: - result = response.json() - - # Check if there's an image in the response - if 'candidates' in result and len(result['candidates']) > 0: - candidate = result['candidates'][0] - if 'content' in candidate and 'parts' in candidate['content']: - for part in candidate['content']['parts']: - if 'inlineData' in part: - # Extract and save the image - image_data = base64.b64decode(part['inlineData']['data']) - output_path = os.path.join(OUTPUT_DIR, filename) - with open(output_path, 'wb') as f: - f.write(image_data) - print(f"✓ Saved: {output_path}") - return True - elif 'text' in part: - print(f"Response: {part['text'][:200]}...") - - print(f"✗ No image generated. Response: {result}") - return False - else: - print(f"✗ API Error ({response.status_code}): {response.text}") - return False - - except Exception as e: - print(f"✗ Error: {str(e)}") - return False - -def main(): - # Create output directory - os.makedirs(OUTPUT_DIR, exist_ok=True) - - print("Starting logo generation with Gemini API...") - print("=" * 60) - - success_count = 0 - for plugin in plugins: - if generate_image(plugin['prompt'], plugin['filename']): - success_count += 1 - - print("\n" + "=" * 60) - print(f"Generation complete: {success_count}/{len(plugins)} successful") - -if __name__ == "__main__": - main() diff --git a/frontend/public/plugin-logos/generate_with_gemini_sdk.py b/frontend/public/plugin-logos/generate_with_gemini_sdk.py deleted file mode 100644 index 742694a..0000000 --- a/frontend/public/plugin-logos/generate_with_gemini_sdk.py +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env python3 -import google.generativeai as genai -import os -from PIL import Image -import io - -# Configure API Key -genai.configure(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw") - -OUTPUT_DIR = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos" - -# Plugin configurations -plugins = [ - { - 'filename': 'daily-appointment-summary.png', - 'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners. -Design: Indigo blue gradient background (#4f46e5). White simple envelope icon combined with a small calendar symbol. -Style: Flat design, clean geometric shapes, professional SaaS application aesthetic. -The icon should be instantly recognizable at 48x48 pixels.''' - }, - { - 'filename': 'no-show-tracker.png', - 'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners. -Design: Red gradient background (#dc2626). White simple person silhouette with a bold X or cancel symbol overlay. -Style: Flat design, clean geometric shapes, professional SaaS application aesthetic. -The icon should clearly convey "missed appointment" at small sizes.''' - }, - { - 'filename': 'birthday-greetings.png', - 'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners. -Design: Pink gradient background (#ec4899). White simple birthday cake with candles or gift box with bow. -Style: Flat design, clean geometric shapes, cheerful yet professional aesthetic. -The icon should feel celebratory and friendly at small sizes.''' - }, - { - 'filename': 'monthly-revenue-report.png', - 'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners. -Design: Green gradient background (#10b981). White simple upward trending line chart or bar graph showing growth. -Style: Flat design, clean geometric shapes, professional business analytics aesthetic. -The icon should convey success and growth at small sizes.''' - }, - { - 'filename': 'appointment-reminder-24hr.png', - 'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners. -Design: Amber/orange gradient background (#f59e0b). White simple notification bell with a small red alert dot or badge. -Style: Flat design, clean geometric shapes, attention-grabbing yet professional aesthetic. -The icon should clearly indicate alerts and reminders at small sizes.''' - }, - { - 'filename': 'inactive-customer-reengagement.png', - 'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners. -Design: Purple gradient background (#8b5cf6). White simple heart symbol with a circular refresh/return arrow around it. -Style: Flat design, clean geometric shapes, warm and welcoming professional aesthetic. -The icon should convey customer care and comeback at small sizes.''' - } -] - -def generate_image(prompt, filename): - """Generate an image using Gemini 2.0 Flash Image Generation""" - print(f"\nGenerating {filename}...") - - try: - # Use the image generation model - model = genai.GenerativeModel('gemini-2.0-flash-exp-image-generation') - - # Generate the image - response = model.generate_content(prompt) - - # Check for generated image - if hasattr(response, 'parts') and response.parts: - for part in response.parts: - if hasattr(part, 'inline_data') and part.inline_data: - # Extract image data - image_data = part.inline_data.data - - # Save the image - output_path = os.path.join(OUTPUT_DIR, filename) - with open(output_path, 'wb') as f: - f.write(image_data) - - print(f"✓ Saved: {output_path} ({len(image_data)} bytes)") - return True - - # If no image data found, check if there's text response - if hasattr(response, 'text'): - print(f"✗ No image generated. Response text: {response.text[:200]}") - else: - print(f"✗ No image generated. Response: {response}") - - return False - - except Exception as e: - print(f"✗ Error: {type(e).__name__}: {str(e)}") - return False - -def main(): - # Create output directory - os.makedirs(OUTPUT_DIR, exist_ok=True) - - print("Starting logo generation with Gemini 2.0 Flash Image Generation...") - print("=" * 60) - - success_count = 0 - for plugin in plugins: - if generate_image(plugin['prompt'], plugin['filename']): - success_count += 1 - - print("\n" + "=" * 60) - print(f"Generation complete: {success_count}/{len(plugins)} successful") - -if __name__ == "__main__": - main() diff --git a/frontend/public/plugin-logos/generate_with_imagen.py b/frontend/public/plugin-logos/generate_with_imagen.py deleted file mode 100644 index 9aef91b..0000000 --- a/frontend/public/plugin-logos/generate_with_imagen.py +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env python3 -import os -import sys -import requests -import base64 -import time -from pathlib import Path - -# Imagen API configuration -API_KEY = "AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw" -MODEL = "imagen-4.0-generate-preview-06-06" -API_URL = f"https://generativelanguage.googleapis.com/v1beta/models/{MODEL}:predict" - -OUTPUT_DIR = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos" - -# Plugin configurations -plugins = [ - { - 'filename': 'daily-appointment-summary.png', - 'prompt': '''A modern, minimalist app icon for "Daily Appointment Summary Email" plugin. -Square format with rounded corners. Indigo blue gradient background (#4f46e5). -White icon showing a combination of an envelope and a calendar. -Flat design, clean lines, professional SaaS application style. -The icon should be simple and recognizable at small sizes.''' - }, - { - 'filename': 'no-show-tracker.png', - 'prompt': '''A modern, minimalist app icon for "No-Show Customer Tracker" plugin. -Square format with rounded corners. Red gradient background (#dc2626). -White icon showing a person silhouette with an X or cancel symbol overlay. -Flat design, clean lines, professional SaaS application style. -The icon should convey missed appointments clearly at small sizes.''' - }, - { - 'filename': 'birthday-greetings.png', - 'prompt': '''A modern, minimalist app icon for "Birthday Greeting Campaign" plugin. -Square format with rounded corners. Pink gradient background (#ec4899). -White icon showing a birthday cake or gift box with a bow. -Flat design, clean lines, cheerful yet professional style. -The icon should be celebratory and friendly at small sizes.''' - }, - { - 'filename': 'monthly-revenue-report.png', - 'prompt': '''A modern, minimalist app icon for "Monthly Revenue Report" plugin. -Square format with rounded corners. Green gradient background (#10b981). -White icon showing an upward trending chart or bar graph. -Flat design, clean lines, professional business analytics style. -The icon should convey growth and success at small sizes.''' - }, - { - 'filename': 'appointment-reminder-24hr.png', - 'prompt': '''A modern, minimalist app icon for "Appointment Reminder" plugin. -Square format with rounded corners. Amber/orange gradient background (#f59e0b). -White icon showing a notification bell with a small red badge or alert dot. -Flat design, clean lines, attention-grabbing yet professional style. -The icon should convey alerts and reminders clearly at small sizes.''' - }, - { - 'filename': 'inactive-customer-reengagement.png', - 'prompt': '''A modern, minimalist app icon for "Inactive Customer Re-engagement" plugin. -Square format with rounded corners. Purple gradient background (#8b5cf6). -White icon showing a heart with a circular refresh arrow around it. -Flat design, clean lines, warm and welcoming professional style. -The icon should convey customer care and return at small sizes.''' - } -] - -def generate_image(prompt, filename): - """Generate an image using Imagen API""" - print(f"\nGenerating {filename}...") - - headers = { - "Content-Type": "application/json" - } - - payload = { - "instances": [{ - "prompt": prompt - }], - "parameters": { - "sampleCount": 1 - } - } - - try: - response = requests.post( - f"{API_URL}?key={API_KEY}", - headers=headers, - json=payload, - timeout=120 - ) - - if response.status_code == 200: - result = response.json() - - # Check if there's an image in the predictions - if 'predictions' in result and len(result['predictions']) > 0: - prediction = result['predictions'][0] - if 'bytesBase64Encoded' in prediction: - # Extract and save the image - image_data = base64.b64decode(prediction['bytesBase64Encoded']) - output_path = os.path.join(OUTPUT_DIR, filename) - with open(output_path, 'wb') as f: - f.write(image_data) - print(f"✓ Saved: {output_path} ({len(image_data)} bytes)") - return True - elif 'image' in prediction and 'bytesBase64Encoded' in prediction['image']: - image_data = base64.b64decode(prediction['image']['bytesBase64Encoded']) - output_path = os.path.join(OUTPUT_DIR, filename) - with open(output_path, 'wb') as f: - f.write(image_data) - print(f"✓ Saved: {output_path} ({len(image_data)} bytes)") - return True - - print(f"✗ No image generated. Response: {str(result)[:300]}...") - return False - else: - print(f"✗ API Error ({response.status_code}): {response.text[:500]}") - return False - - except Exception as e: - print(f"✗ Error: {str(e)}") - return False - -def main(): - # Create output directory - os.makedirs(OUTPUT_DIR, exist_ok=True) - - print("Starting logo generation with Imagen 4.0 API...") - print("=" * 60) - - success_count = 0 - for i, plugin in enumerate(plugins): - if i > 0: - # Add delay between requests to avoid rate limiting - print("Waiting 2 seconds before next request...") - time.sleep(2) - - if generate_image(plugin['prompt'], plugin['filename']): - success_count += 1 - - print("\n" + "=" * 60) - print(f"Generation complete: {success_count}/{len(plugins)} successful") - -if __name__ == "__main__": - main() diff --git a/frontend/public/plugin-logos/inactive-customer-reengagement.png b/frontend/public/plugin-logos/inactive-customer-reengagement.png index 357b4cd..d83ccef 100644 Binary files a/frontend/public/plugin-logos/inactive-customer-reengagement.png and b/frontend/public/plugin-logos/inactive-customer-reengagement.png differ diff --git a/frontend/public/plugin-logos/monthly-revenue-report.png b/frontend/public/plugin-logos/monthly-revenue-report.png index e440e3a..a411a2b 100644 Binary files a/frontend/public/plugin-logos/monthly-revenue-report.png and b/frontend/public/plugin-logos/monthly-revenue-report.png differ diff --git a/frontend/public/plugin-logos/no-show-tracker.png b/frontend/public/plugin-logos/no-show-tracker.png index 2ab4ee6..861fe79 100644 Binary files a/frontend/public/plugin-logos/no-show-tracker.png and b/frontend/public/plugin-logos/no-show-tracker.png differ diff --git a/frontend/public/plugin-logos/test_gemini_image.py b/frontend/public/plugin-logos/test_gemini_image.py deleted file mode 100644 index 399d18a..0000000 --- a/frontend/public/plugin-logos/test_gemini_image.py +++ /dev/null @@ -1,43 +0,0 @@ -import google.generativeai as genai -import os -from PIL import Image -import io - -# Configure API Key -genai.configure(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw") - -# Try to initialize the model -try: - generation_model = genai.GenerativeModel('gemini-pro-vision') - print("Model initialized successfully") - - # Define prompt - prompt = "A modern app icon with a blue background and white envelope symbol" - print(f"Generating image for: '{prompt}'...") - - # Try to generate - response = generation_model.generate_content(prompt) - - print(f"Response type: {type(response)}") - print(f"Response: {response}") - - if hasattr(response, 'parts') and response.parts: - print(f"Parts: {response.parts}") - if hasattr(response.parts[0], 'inline_data'): - print("Has inline_data!") - image_data = response.parts[0].inline_data.data - image = Image.open(io.BytesIO(image_data)) - image.save("test_output.png") - print("Success! Image saved to test_output.png") - else: - print("No inline_data attribute") - print(f"Part attributes: {dir(response.parts[0])}") - else: - print("No parts in response or response.parts doesn't exist") - if hasattr(response, 'text'): - print(f"Response text: {response.text}") - -except Exception as e: - print(f"Error: {type(e).__name__}: {str(e)}") - import traceback - traceback.print_exc() diff --git a/frontend/public/plugin-logos/test_gemini_native_image.py b/frontend/public/plugin-logos/test_gemini_native_image.py deleted file mode 100644 index 5c5eb92..0000000 --- a/frontend/public/plugin-logos/test_gemini_native_image.py +++ /dev/null @@ -1,41 +0,0 @@ -from google import genai -from google.genai import types -from PIL import Image -from io import BytesIO -import os - -# Setup Client -client = genai.Client(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw") - -# Define the Prompt -prompt = "Create a simple modern app icon with a blue background and white envelope symbol, square format, flat design, minimalist" - -print(f"Asking Gemini to generate: '{prompt}'...") - -# Call the Gemini Model -try: - response = client.models.generate_content( - model='gemini-2.5-flash-image', - contents=prompt, - config=types.GenerateContentConfig( - response_modalities=["IMAGE"] # Tell Gemini to draw, not talk - ) - ) - - # Save the Image - if response.candidates[0].content.parts: - for part in response.candidates[0].content.parts: - if part.inline_data: - image_data = part.inline_data.data - image = Image.open(BytesIO(image_data)) - image.save("test_gemini_native.png") - print("Success! Saved test_gemini_native.png") - else: - print("Model returned text instead of image:", part.text) - else: - print("No content parts in response") - -except Exception as e: - print(f"Error: {type(e).__name__}: {e}") - import traceback - traceback.print_exc() diff --git a/frontend/public/plugin-logos/test_google_genai.py b/frontend/public/plugin-logos/test_google_genai.py deleted file mode 100644 index 298044c..0000000 --- a/frontend/public/plugin-logos/test_google_genai.py +++ /dev/null @@ -1,41 +0,0 @@ -from google import genai -from google.genai import types -from PIL import Image -from io import BytesIO -import os - -# Initialize the Client -client = genai.Client(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw") - -# Define the prompt -prompt = "A simple modern app icon with a blue background and white envelope symbol, square format, flat design" - -print(f"Generating image for: '{prompt}'...") - -# Call the API -try: - response = client.models.generate_images( - model='gemini-2.5-flash-image', - prompt=prompt, - config=types.GenerateImagesConfig( - number_of_images=1, - aspect_ratio="1:1", - safety_filter_level="BLOCK_LOW_AND_ABOVE", - person_generation="ALLOW_ADULT" - ) - ) - - # Handle the response - for i, generated_image in enumerate(response.generated_images): - # Convert raw bytes to an image - image = Image.open(BytesIO(generated_image.image.image_bytes)) - - # Save to disk - filename = f"test_generated_image_{i}.png" - image.save(filename) - print(f"Success! Saved {filename}") - -except Exception as e: - print(f"Error occurred: {type(e).__name__}: {e}") - import traceback - traceback.print_exc() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 607fb5a..b6e1edc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,7 @@ import { setCookie } from './utils/cookies'; // Import Login Page import LoginPage from './pages/LoginPage'; +import MFAVerifyPage from './pages/MFAVerifyPage'; import OAuthCallback from './pages/OAuthCallback'; // Import layouts @@ -66,6 +67,7 @@ import PlatformSupport from './pages/PlatformSupport'; // Import Platform Suppor import PluginMarketplace from './pages/PluginMarketplace'; // Import Plugin Marketplace page import MyPlugins from './pages/MyPlugins'; // Import My Plugins page import Tasks from './pages/Tasks'; // Import Tasks page for scheduled plugin executions +import EmailTemplates from './pages/EmailTemplates'; // Import Email Templates page import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications const queryClient = new QueryClient({ @@ -127,14 +129,22 @@ const AppContent: React.FC = () => { const { data: user, isLoading: userLoading, error: userError } = useCurrentUser(); const { data: business, isLoading: businessLoading, error: businessError } = useCurrentBusiness(); - const [darkMode, setDarkMode] = useState(false); + const [darkMode, setDarkMode] = useState(() => { + // Check localStorage first, then system preference + const saved = localStorage.getItem('darkMode'); + if (saved !== null) { + return JSON.parse(saved); + } + return window.matchMedia('(prefers-color-scheme: dark)').matches; + }); const updateBusinessMutation = useUpdateBusiness(); const masqueradeMutation = useMasquerade(); const logoutMutation = useLogout(); - // Apply dark mode class + // Apply dark mode class and persist to localStorage React.useEffect(() => { document.documentElement.classList.toggle('dark', darkMode); + localStorage.setItem('darkMode', JSON.stringify(darkMode)); }, [darkMode]); // Handle tokens in URL (from login or masquerade redirect) @@ -222,6 +232,7 @@ const AppContent: React.FC = () => { } /> } /> + } /> } /> } /> } /> @@ -236,6 +247,7 @@ const AppContent: React.FC = () => { return ( } /> + } /> } /> } /> } /> @@ -547,6 +559,16 @@ const AppContent: React.FC = () => { ) } /> + + ) : ( + + ) + } + /> } /> => { + const response = await apiClient.get('/api/auth/mfa/status/'); + return response.data; +}; + +// ============================================================================ +// SMS Setup +// ============================================================================ + +/** + * Send phone verification code + */ +export const sendPhoneVerification = async (phone: string): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.post('/api/auth/mfa/phone/send/', { phone }); + return response.data; +}; + +/** + * Verify phone number with code + */ +export const verifyPhone = async (code: string): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.post('/api/auth/mfa/phone/verify/', { code }); + return response.data; +}; + +/** + * Enable SMS MFA (requires verified phone) + */ +export const enableSMSMFA = async (): Promise => { + const response = await apiClient.post('/api/auth/mfa/sms/enable/'); + return response.data; +}; + +// ============================================================================ +// TOTP Setup (Authenticator App) +// ============================================================================ + +/** + * Initialize TOTP setup (returns QR code and secret) + */ +export const setupTOTP = async (): Promise => { + const response = await apiClient.post('/api/auth/mfa/totp/setup/'); + return response.data; +}; + +/** + * Verify TOTP code to complete setup + */ +export const verifyTOTPSetup = async (code: string): Promise => { + const response = await apiClient.post('/api/auth/mfa/totp/verify/', { code }); + return response.data; +}; + +// ============================================================================ +// Backup Codes +// ============================================================================ + +/** + * Generate new backup codes (invalidates old ones) + */ +export const generateBackupCodes = async (): Promise => { + const response = await apiClient.post('/api/auth/mfa/backup-codes/'); + return response.data; +}; + +/** + * Get backup codes status + */ +export const getBackupCodesStatus = async (): Promise => { + const response = await apiClient.get('/api/auth/mfa/backup-codes/status/'); + return response.data; +}; + +// ============================================================================ +// Disable MFA +// ============================================================================ + +/** + * Disable MFA (requires password or valid MFA code) + */ +export const disableMFA = async (credentials: { password?: string; mfa_code?: string }): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.post('/api/auth/mfa/disable/', credentials); + return response.data; +}; + +// ============================================================================ +// MFA Login Challenge +// ============================================================================ + +/** + * Send MFA code for login (SMS only) + */ +export const sendMFALoginCode = async (userId: number, method: 'SMS' | 'TOTP' = 'SMS'): Promise<{ success: boolean; message: string; method: string }> => { + const response = await apiClient.post('/api/auth/mfa/login/send/', { user_id: userId, method }); + return response.data; +}; + +/** + * Verify MFA code to complete login + */ +export const verifyMFALogin = async ( + userId: number, + code: string, + method: 'SMS' | 'TOTP' | 'BACKUP', + trustDevice: boolean = false +): Promise => { + const response = await apiClient.post('/api/auth/mfa/login/verify/', { + user_id: userId, + code, + method, + trust_device: trustDevice, + }); + return response.data; +}; + +// ============================================================================ +// Trusted Devices +// ============================================================================ + +/** + * List trusted devices + */ +export const listTrustedDevices = async (): Promise<{ devices: TrustedDevice[] }> => { + const response = await apiClient.get('/api/auth/mfa/devices/'); + return response.data; +}; + +/** + * Revoke a specific trusted device + */ +export const revokeTrustedDevice = async (deviceId: number): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.delete(`/api/auth/mfa/devices/${deviceId}/`); + return response.data; +}; + +/** + * Revoke all trusted devices + */ +export const revokeAllTrustedDevices = async (): Promise<{ success: boolean; message: string; count: number }> => { + const response = await apiClient.delete('/api/auth/mfa/devices/revoke-all/'); + return response.data; +}; diff --git a/frontend/src/api/profile.ts b/frontend/src/api/profile.ts index 593a544..cba63fb 100644 --- a/frontend/src/api/profile.ts +++ b/frontend/src/api/profile.ts @@ -121,29 +121,34 @@ export const changePassword = async ( }); }; -// 2FA API +// 2FA API (using new MFA endpoints) export const setupTOTP = async (): Promise => { - const response = await apiClient.post('/api/auth/2fa/totp/setup/'); + const response = await apiClient.post('/api/auth/mfa/totp/setup/'); return response.data; }; export const verifyTOTP = async (code: string): Promise => { - const response = await apiClient.post('/api/auth/2fa/totp/verify/', { code }); - return response.data; + const response = await apiClient.post('/api/auth/mfa/totp/verify/', { code }); + // Map response to expected format + return { + success: response.data.success, + recovery_codes: response.data.backup_codes || [], + }; }; export const disableTOTP = async (code: string): Promise => { - await apiClient.post('/api/auth/2fa/totp/disable/', { code }); + await apiClient.post('/api/auth/mfa/disable/', { mfa_code: code }); }; export const getRecoveryCodes = async (): Promise => { - const response = await apiClient.get('/api/auth/2fa/recovery-codes/'); - return response.data.codes; + const response = await apiClient.get('/api/auth/mfa/backup-codes/status/'); + // Note: Actual codes are only shown when generated, not retrievable later + return []; }; export const regenerateRecoveryCodes = async (): Promise => { - const response = await apiClient.post('/api/auth/2fa/recovery-codes/regenerate/'); - return response.data.codes; + const response = await apiClient.post('/api/auth/mfa/backup-codes/'); + return response.data.backup_codes; }; // Sessions API diff --git a/frontend/src/api/ticketEmailSettings.ts b/frontend/src/api/ticketEmailSettings.ts new file mode 100644 index 0000000..abfef92 --- /dev/null +++ b/frontend/src/api/ticketEmailSettings.ts @@ -0,0 +1,169 @@ +/** + * API client for ticket email settings + */ + +import apiClient from './client'; + +export interface TicketEmailSettings { + // IMAP settings (inbound) + imap_host: string; + imap_port: number; + imap_use_ssl: boolean; + imap_username: string; + imap_password_masked: string; + imap_folder: string; + // SMTP settings (outbound) + smtp_host: string; + smtp_port: number; + smtp_use_tls: boolean; + smtp_use_ssl: boolean; + smtp_username: string; + smtp_password_masked: string; + smtp_from_email: string; + smtp_from_name: string; + // General settings + support_email_address: string; + support_email_domain: string; + is_enabled: boolean; + delete_after_processing: boolean; + check_interval_seconds: number; + max_attachment_size_mb: number; + allowed_attachment_types: string[]; + // Status + last_check_at: string | null; + last_error: string; + emails_processed_count: number; + is_configured: boolean; + is_imap_configured: boolean; + is_smtp_configured: boolean; + created_at: string; + updated_at: string; +} + +export interface TicketEmailSettingsUpdate { + // IMAP settings + imap_host?: string; + imap_port?: number; + imap_use_ssl?: boolean; + imap_username?: string; + imap_password?: string; + imap_folder?: string; + // SMTP settings + smtp_host?: string; + smtp_port?: number; + smtp_use_tls?: boolean; + smtp_use_ssl?: boolean; + smtp_username?: string; + smtp_password?: string; + smtp_from_email?: string; + smtp_from_name?: string; + // General settings + support_email_address?: string; + support_email_domain?: string; + is_enabled?: boolean; + delete_after_processing?: boolean; + check_interval_seconds?: number; + max_attachment_size_mb?: number; + allowed_attachment_types?: string[]; +} + +export interface TestConnectionResult { + success: boolean; + message: string; +} + +export interface FetchNowResult { + success: boolean; + message: string; + processed: number; +} + +export interface IncomingTicketEmail { + id: number; + message_id: string; + from_address: string; + from_name: string; + to_address: string; + subject: string; + body_text: string; + extracted_reply: string; + ticket: number | null; + ticket_subject: string; + matched_user: number | null; + ticket_id_from_email: string; + processing_status: 'PENDING' | 'PROCESSED' | 'FAILED' | 'SPAM' | 'NO_MATCH' | 'DUPLICATE'; + processing_status_display: string; + error_message: string; + email_date: string; + received_at: string; + processed_at: string | null; +} + +/** + * Get ticket email settings + */ +export const getTicketEmailSettings = async (): Promise => { + const response = await apiClient.get('/api/tickets/email-settings/'); + return response.data; +}; + +/** + * Update ticket email settings + */ +export const updateTicketEmailSettings = async ( + data: TicketEmailSettingsUpdate +): Promise => { + const response = await apiClient.patch('/api/tickets/email-settings/', data); + return response.data; +}; + +/** + * Test IMAP connection + */ +export const testImapConnection = async (): Promise => { + const response = await apiClient.post('/api/tickets/email-settings/test-imap/'); + return response.data; +}; + +/** + * Test SMTP connection + */ +export const testSmtpConnection = async (): Promise => { + const response = await apiClient.post('/api/tickets/email-settings/test-smtp/'); + return response.data; +}; + +// Legacy alias for backwards compatibility +export const testEmailConnection = testImapConnection; + +/** + * Manually trigger email fetch + */ +export const fetchEmailsNow = async (): Promise => { + const response = await apiClient.post('/api/tickets/email-settings/fetch-now/'); + return response.data; +}; + +/** + * Get incoming email audit log + */ +export const getIncomingEmails = async (params?: { + status?: string; + ticket?: number; +}): Promise => { + const response = await apiClient.get('/api/tickets/incoming-emails/', { params }); + return response.data; +}; + +/** + * Reprocess a failed incoming email + */ +export const reprocessIncomingEmail = async (id: number): Promise<{ + success: boolean; + message: string; + comment_id?: number; + ticket_id?: number; +}> => { + const response = await apiClient.post(`/api/tickets/incoming-emails/${id}/reprocess/`); + return response.data; +}; diff --git a/frontend/src/components/EmailTemplateForm.tsx b/frontend/src/components/EmailTemplateForm.tsx new file mode 100644 index 0000000..47d726f --- /dev/null +++ b/frontend/src/components/EmailTemplateForm.tsx @@ -0,0 +1,456 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { + X, + Save, + Eye, + Code, + FileText, + Monitor, + Smartphone, + Plus, + AlertTriangle, + ChevronDown +} from 'lucide-react'; +import api from '../api/client'; +import { EmailTemplate, EmailTemplateCategory, EmailTemplateVariableGroup } from '../types'; + +interface EmailTemplateFormProps { + template?: EmailTemplate | null; + onClose: () => void; + onSuccess: () => void; +} + +const EmailTemplateForm: React.FC = ({ + template, + onClose, + onSuccess, +}) => { + const { t } = useTranslation(); + const isEditing = !!template; + + // Form state + const [name, setName] = useState(template?.name || ''); + const [description, setDescription] = useState(template?.description || ''); + const [subject, setSubject] = useState(template?.subject || ''); + const [htmlContent, setHtmlContent] = useState(template?.htmlContent || ''); + const [textContent, setTextContent] = useState(template?.textContent || ''); + const [category, setCategory] = useState(template?.category || 'OTHER'); + + // UI state + const [activeTab, setActiveTab] = useState<'html' | 'text'>('html'); + const [editorMode, setEditorMode] = useState<'visual' | 'code'>('code'); + const [previewDevice, setPreviewDevice] = useState<'desktop' | 'mobile'>('desktop'); + const [showPreview, setShowPreview] = useState(false); + const [showVariables, setShowVariables] = useState(false); + + // Fetch available variables + const { data: variablesData } = useQuery<{ variables: EmailTemplateVariableGroup[] }>({ + queryKey: ['email-template-variables'], + queryFn: async () => { + const { data } = await api.get('/api/email-templates/variables/'); + return data; + }, + }); + + // Preview mutation + const previewMutation = useMutation({ + mutationFn: async () => { + const { data } = await api.post('/api/email-templates/preview/', { + subject, + html_content: htmlContent, + text_content: textContent, + }); + return data; + }, + }); + + // Create/Update mutation + const saveMutation = useMutation({ + mutationFn: async () => { + const payload = { + name, + description, + subject, + html_content: htmlContent, + text_content: textContent, + category, + scope: 'BUSINESS', // Business users only create business templates + }; + + if (isEditing && template) { + const { data } = await api.patch(`/api/email-templates/${template.id}/`, payload); + return data; + } else { + const { data } = await api.post('/api/email-templates/', payload); + return data; + } + }, + onSuccess: () => { + onSuccess(); + }, + }); + + const handlePreview = () => { + previewMutation.mutate(); + setShowPreview(true); + }; + + const insertVariable = (code: string) => { + if (activeTab === 'html') { + setHtmlContent(prev => prev + code); + } else if (activeTab === 'text') { + setTextContent(prev => prev + code); + } + }; + + const categories: { value: EmailTemplateCategory; label: string }[] = [ + { value: 'APPOINTMENT', label: t('emailTemplates.categoryAppointment', 'Appointment') }, + { value: 'REMINDER', label: t('emailTemplates.categoryReminder', 'Reminder') }, + { value: 'CONFIRMATION', label: t('emailTemplates.categoryConfirmation', 'Confirmation') }, + { value: 'MARKETING', label: t('emailTemplates.categoryMarketing', 'Marketing') }, + { value: 'NOTIFICATION', label: t('emailTemplates.categoryNotification', 'Notification') }, + { value: 'REPORT', label: t('emailTemplates.categoryReport', 'Report') }, + { value: 'OTHER', label: t('emailTemplates.categoryOther', 'Other') }, + ]; + + const isValid = name.trim() && subject.trim() && (htmlContent.trim() || textContent.trim()); + + return ( +
+
+ {/* Modal Header */} +
+

+ {isEditing + ? t('emailTemplates.edit', 'Edit Template') + : t('emailTemplates.create', 'Create Template')} +

+ +
+ + {/* Modal Body */} +
+
+ {/* Left Column - Form */} +
+ {/* Name */} +
+ + setName(e.target.value)} + placeholder={t('emailTemplates.namePlaceholder', 'e.g., Appointment Confirmation')} + 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" + /> +
+ + {/* Category */} +
+ + +
+ + {/* Description */} +
+ + setDescription(e.target.value)} + placeholder={t('emailTemplates.descriptionPlaceholder', 'Brief description of when this template is used')} + 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" + /> +
+ + {/* Subject */} +
+ + setSubject(e.target.value)} + placeholder={t('emailTemplates.subjectPlaceholder', 'e.g., Your appointment is confirmed!')} + 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" + /> +
+ + {/* Variables Dropdown */} +
+ + + {showVariables && variablesData?.variables && ( +
+ {variablesData.variables.map((group) => ( +
+

+ {group.category} +

+
+ {group.items.map((variable) => ( + + ))} +
+
+ ))} +
+ )} +
+ + {/* Content Tabs */} +
+
+
+ + +
+ + {/* Editor Mode Toggle (for HTML only) */} + {activeTab === 'html' && ( +
+ + +
+ )} +
+ + {/* Content Editor */} + {activeTab === 'html' && ( +